Cart Page and Cart Drawer Intermediate 15 min read
Building a Cart Drawer (Mini Cart)
Create a slide-out cart drawer that updates dynamically, showing cart contents without leaving the current page.
A cart drawer (or mini cart) shows cart contents in a slide-out panel. Customers can review their cart without leaving the page, reducing friction in the shopping experience.
Cart Drawer Structure
{# snippets/cart-drawer.liquid #}
<cart-drawer class="cart-drawer" id="cart-drawer"> <div class="cart-drawer__overlay" data-cart-close></div>
<div class="cart-drawer__panel" role="dialog" aria-label="Shopping cart" aria-modal="true"> <div class="cart-drawer__header"> <h2 class="cart-drawer__title"> Your Cart <span class="cart-drawer__count">({{ cart.item_count }})</span> </h2> <button class="cart-drawer__close" data-cart-close aria-label="Close cart"> {% render 'icon-close' %} </button> </div>
<div class="cart-drawer__body" data-cart-body> {%- if cart.item_count > 0 -%} {% render 'cart-drawer-items' %} {%- else -%} {% render 'cart-drawer-empty' %} {%- endif -%} </div>
{%- if cart.item_count > 0 -%} <div class="cart-drawer__footer"> {% render 'cart-drawer-footer' %} </div> {%- endif -%} </div></cart-drawer>Cart Drawer Items
{# snippets/cart-drawer-items.liquid #}
<ul class="cart-drawer__items"> {%- for item in cart.items -%} <li class="cart-drawer__item" data-cart-item data-key="{{ item.key }}"> <a href="{{ item.url }}" class="cart-drawer__item-image"> <img src="{{ item.image | image_url: width: 150 }}" alt="{{ item.image.alt | default: item.product.title }}" width="75" height="75" loading="lazy" > </a>
<div class="cart-drawer__item-details"> <a href="{{ item.url }}" class="cart-drawer__item-title"> {{ item.product.title }} </a>
{%- if item.variant.title != 'Default Title' -%} <p class="cart-drawer__item-variant">{{ item.variant.title }}</p> {%- endif -%}
{%- if item.properties.size > 0 -%} <ul class="cart-drawer__item-properties"> {%- for property in item.properties -%} {%- unless property.first contains '_' -%} <li>{{ property.first }}: {{ property.last }}</li> {%- endunless -%} {%- endfor -%} </ul> {%- endif -%}
<div class="cart-drawer__item-price"> {%- if item.original_line_price != item.final_line_price -%} <span class="cart-drawer__item-original"> {{ item.original_line_price | money }} </span> {%- endif -%} <span class="cart-drawer__item-final"> {{ item.final_line_price | money }} </span> </div> </div>
<div class="cart-drawer__item-actions"> {% render 'quantity-selector', value: item.quantity, min: 0, data_key: item.key %}
<button class="cart-drawer__item-remove" data-remove-item data-key="{{ item.key }}" aria-label="Remove {{ item.product.title }}" > {% render 'icon-trash' %} </button> </div> </li> {%- endfor -%}</ul>Cart Drawer Footer
{# snippets/cart-drawer-footer.liquid #}
<div class="cart-drawer__totals"> {%- if cart.cart_level_discount_applications.size > 0 -%} <div class="cart-drawer__discounts"> {%- for discount in cart.cart_level_discount_applications -%} <div class="cart-drawer__discount"> <span>{{ discount.title }}</span> <span>-{{ discount.total_allocated_amount | money }}</span> </div> {%- endfor -%} </div> {%- endif -%}
<div class="cart-drawer__subtotal"> <span>Subtotal</span> <span data-cart-subtotal>{{ cart.total_price | money }}</span> </div>
<p class="cart-drawer__shipping-note"> Shipping and taxes calculated at checkout </p></div>
<div class="cart-drawer__buttons"> <a href="{{ routes.cart_url }}" class="button button--secondary"> View Cart </a> <a href="{{ routes.checkout_url }}" class="button button--primary"> Checkout </a></div>Cart Drawer CSS
.cart-drawer { position: fixed; inset: 0; z-index: 1000; pointer-events: none;}
.cart-drawer.is-open { pointer-events: auto;}
/* Overlay */.cart-drawer__overlay { position: absolute; inset: 0; background: rgba(0, 0, 0, 0.5); opacity: 0; transition: opacity 0.3s ease;}
.cart-drawer.is-open .cart-drawer__overlay { opacity: 1;}
/* Panel */.cart-drawer__panel { position: absolute; top: 0; right: 0; bottom: 0; width: 100%; max-width: 420px; background: var(--color-background); display: flex; flex-direction: column; transform: translateX(100%); transition: transform 0.3s ease;}
.cart-drawer.is-open .cart-drawer__panel { transform: translateX(0);}
/* Header */.cart-drawer__header { display: flex; align-items: center; justify-content: space-between; padding: var(--spacing-md) var(--spacing-lg); border-bottom: 1px solid var(--color-border);}
.cart-drawer__title { font-size: 1.125rem; margin: 0;}
.cart-drawer__count { font-weight: 400; color: var(--color-text-light);}
.cart-drawer__close { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; padding: 0; background: none; border: none; cursor: pointer;}
.cart-drawer__close svg { width: 20px; height: 20px;}
/* Body */.cart-drawer__body { flex: 1; overflow-y: auto; padding: var(--spacing-md);}
/* Items */.cart-drawer__items { list-style: none; padding: 0; margin: 0;}
.cart-drawer__item { display: grid; grid-template-columns: 75px 1fr; gap: var(--spacing-md); padding: var(--spacing-md) 0; border-bottom: 1px solid var(--color-border);}
.cart-drawer__item-image img { width: 75px; height: 75px; object-fit: cover; border-radius: var(--border-radius);}
.cart-drawer__item-details { display: flex; flex-direction: column; gap: var(--spacing-xs);}
.cart-drawer__item-title { font-weight: 500; color: inherit; text-decoration: none; font-size: 0.9375rem;}
.cart-drawer__item-title:hover { text-decoration: underline;}
.cart-drawer__item-variant,.cart-drawer__item-properties { font-size: 0.8125rem; color: var(--color-text-light); margin: 0;}
.cart-drawer__item-properties { list-style: none; padding: 0;}
.cart-drawer__item-price { margin-top: auto; font-size: 0.9375rem;}
.cart-drawer__item-original { text-decoration: line-through; color: var(--color-text-light); margin-right: var(--spacing-xs);}
.cart-drawer__item-actions { grid-column: 2; display: flex; align-items: center; gap: var(--spacing-sm);}
.cart-drawer__item-remove { display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; background: none; border: none; color: var(--color-text-light); cursor: pointer;}
.cart-drawer__item-remove:hover { color: var(--color-sale);}
/* Footer */.cart-drawer__footer { padding: var(--spacing-md) var(--spacing-lg); border-top: 1px solid var(--color-border); background: var(--color-background-secondary);}
.cart-drawer__subtotal { display: flex; justify-content: space-between; font-size: 1.125rem; font-weight: 600; margin-bottom: var(--spacing-xs);}
.cart-drawer__shipping-note { font-size: 0.8125rem; color: var(--color-text-light); margin: 0 0 var(--spacing-md);}
.cart-drawer__buttons { display: flex; gap: var(--spacing-sm);}
.cart-drawer__buttons .button { flex: 1;}Cart Drawer JavaScript
class CartDrawer extends HTMLElement { constructor() { super(); this.overlay = this.querySelector('[data-cart-close]'); this.closeButton = this.querySelector('.cart-drawer__close'); }
connectedCallback() { // Close on overlay click this.overlay?.addEventListener('click', () => this.close()); this.closeButton?.addEventListener('click', () => this.close());
// Close on escape key document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && this.isOpen) { this.close(); } });
// Listen for cart updates document.addEventListener('cart:added', () => { this.refresh().then(() => this.open()); });
document.addEventListener('cart:updated', () => this.refresh()); }
get isOpen() { return this.classList.contains('is-open'); }
open() { this.classList.add('is-open'); document.body.style.overflow = 'hidden';
// Focus management this.previouslyFocused = document.activeElement; this.querySelector('.cart-drawer__close')?.focus();
// Trap focus this.trapFocus(); }
close() { this.classList.remove('is-open'); document.body.style.overflow = '';
// Restore focus this.previouslyFocused?.focus(); }
toggle() { if (this.isOpen) { this.close(); } else { this.open(); } }
async refresh() { try { const response = await fetch('/?section_id=cart-drawer'); const html = await response.text();
const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
// Update drawer content const newBody = doc.querySelector('[data-cart-body]'); const currentBody = this.querySelector('[data-cart-body]');
if (newBody && currentBody) { currentBody.innerHTML = newBody.innerHTML; }
// Update footer const newFooter = doc.querySelector('.cart-drawer__footer'); const currentFooter = this.querySelector('.cart-drawer__footer');
if (currentFooter && newFooter) { currentFooter.innerHTML = newFooter.innerHTML; } else if (!newFooter && currentFooter) { currentFooter.remove(); }
// Update count const newCount = doc.querySelector('.cart-drawer__count'); const currentCount = this.querySelector('.cart-drawer__count'); if (newCount && currentCount) { currentCount.textContent = newCount.textContent; }
// Update header cart count this.updateHeaderCount(); } catch (error) { console.error('Failed to refresh cart:', error); } }
updateHeaderCount() { fetch('/cart.js') .then((res) => res.json()) .then((cart) => { document.querySelectorAll('[data-cart-count]').forEach((el) => { el.textContent = cart.item_count; el.hidden = cart.item_count === 0; }); }); }
trapFocus() { const focusableElements = this.querySelectorAll( 'a, button, input, textarea, select, [tabindex]:not([tabindex="-1"])' );
const firstElement = focusableElements[0]; const lastElement = focusableElements[focusableElements.length - 1];
this.addEventListener('keydown', (e) => { if (e.key !== 'Tab') return;
if (e.shiftKey) { if (document.activeElement === firstElement) { e.preventDefault(); lastElement.focus(); } } else { if (document.activeElement === lastElement) { e.preventDefault(); firstElement.focus(); } } }); }}
customElements.define('cart-drawer', CartDrawer);Cart Toggle Button
Add a button to open the cart drawer:
{# In header #}<button class="header__cart-toggle" data-cart-toggle aria-label="Open cart"> {% render 'icon-cart' %} <span class="header__cart-count" data-cart-count {% if cart.item_count == 0 %}hidden{% endif %}> {{ cart.item_count }} </span></button>document.querySelectorAll('[data-cart-toggle]').forEach((button) => { button.addEventListener('click', () => { document.querySelector('cart-drawer')?.toggle(); });});Section Rendering API
Use Section Rendering to get updated cart HTML:
async function refreshCartDrawer() { const response = await fetch('/?section_id=cart-drawer'); const html = await response.text();
// Parse and update const parser = new DOMParser(); const doc = parser.parseFromString(html, 'text/html');
const newDrawer = doc.querySelector('cart-drawer'); const currentDrawer = document.querySelector('cart-drawer');
if (newDrawer && currentDrawer) { currentDrawer.innerHTML = newDrawer.innerHTML; }}Complete Cart Drawer Section
{# sections/cart-drawer.liquid #}
<cart-drawer class="cart-drawer" id="cart-drawer"> <div class="cart-drawer__overlay" data-cart-close></div>
<div class="cart-drawer__panel" role="dialog" aria-label="Shopping cart" aria-modal="true" > <div class="cart-drawer__header"> <h2 class="cart-drawer__title"> Your Cart <span class="cart-drawer__count">({{ cart.item_count }})</span> </h2> <button class="cart-drawer__close" data-cart-close aria-label="Close"> {% render 'icon-close' %} </button> </div>
<div class="cart-drawer__body" data-cart-body> {%- if cart.item_count > 0 -%} <ul class="cart-drawer__items"> {%- for item in cart.items -%} {% render 'cart-drawer-item', item: item %} {%- endfor -%} </ul> {%- else -%} {% render 'cart-drawer-empty' %} {%- endif -%} </div>
{%- if cart.item_count > 0 -%} <div class="cart-drawer__footer"> {% render 'cart-drawer-footer' %} </div> {%- endif -%} </div></cart-drawer>
{% schema %}{ "name": "Cart Drawer", "settings": [ { "type": "checkbox", "id": "show_recommendations", "label": "Show recommendations", "default": false } ]}{% endschema %}Practice Exercise
Build a cart drawer that:
- Slides in from the right
- Has an overlay that closes it
- Shows all cart items with images
- Displays quantity selectors
- Shows subtotal and checkout button
- Updates when items are added
- Traps focus while open
Test by:
- Adding items from product pages
- Opening and closing the drawer
- Keyboard navigation
- Screen reader accessibility
Key Takeaways
- Cart drawer is a dialog with proper ARIA attributes
- Use Section Rendering to refresh content
- Trap focus for accessibility
- Overlay click should close drawer
- Escape key should close drawer
- Update cart count in header
- Prevent body scroll when open
- Handle empty state gracefully
What’s Next?
The next lesson covers Empty Cart State and Conditional Rendering for handling empty carts.
Finished this lesson?
Mark it complete to track your progress.
Discussion
Loading comments...