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>
{# 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:

  1. Slides in from the right
  2. Has an overlay that closes it
  3. Shows all cart items with images
  4. Displays quantity selectors
  5. Shows subtotal and checkout button
  6. Updates when items are added
  7. Traps focus while open

Test by:

  • Adding items from product pages
  • Opening and closing the drawer
  • Keyboard navigation
  • Screen reader accessibility

Key Takeaways

  1. Cart drawer is a dialog with proper ARIA attributes
  2. Use Section Rendering to refresh content
  3. Trap focus for accessibility
  4. Overlay click should close drawer
  5. Escape key should close drawer
  6. Update cart count in header
  7. Prevent body scroll when open
  8. 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...