Cart Components Intermediate 12 min read

Line Item Component with Quantity Updates

Build cart line item components with quantity controls, remove buttons, and optimistic updates. Handle variant info and line item properties.

Cart line items are the heart of the cart experience. Each item needs to display product info, handle quantity changes, support removal, and provide visual feedback during updates. Let’s build it right.

Theme Integration

This component is part of the cart drawer component hierarchy:

snippets/cart-drawer.liquid (included in theme.liquid)
└── <div id="cart-drawer-root">
└── CartDrawer (React)
└── CartItems ← You are here
└── CartLineItem (multiple)

See Cart Drawer Architecture for the complete Liquid snippet setup and cart data serialization.

Data Source

Prop/StateSourceLiquid Field
itemsZustand storecart.items
item.keyStoreitem.key (unique line identifier)
item.titleStoreitem.product.title
item.variantTitleStoreitem.variant.title
item.quantityStoreitem.quantity
item.priceStoreitem.variant.price
item.linePriceStoreitem.line_price
item.discountedPriceStoreitem.final_line_price
item.imageStoreitem.image
item.urlStoreitem.url
item.propertiesStoreitem.properties
isPendingZustand store- (tracks pending API calls)

CartItems Container

src/components/cart/CartDrawer/CartItems.tsx
import type { CartLineItem } from '@/types/cart';
import { CartLineItem as LineItem } from './CartLineItem';
import styles from './CartItems.module.css';
interface CartItemsProps {
items: CartLineItem[]; // Array of line items from cart state.
}
/**
* CartItems renders the list of line items in the cart.
* Uses semantic list markup for accessibility.
*/
export function CartItems({ items }: CartItemsProps) {
return (
<ul className={styles.list} role="list">
{items.map((item) => (
<li key={item.key} className={styles.item}>
<LineItem item={item} />
</li>
))}
</ul>
);
}
src/components/cart/CartDrawer/CartItems.module.css
.list {
list-style: none;
margin: 0;
padding: 0;
}
.item {
/* Border between items, not on last */
border-bottom: 1px solid var(--color-border);
}
.item:last-child {
border-bottom: none;
}

CartLineItem Component

src/components/cart/CartDrawer/CartLineItem.tsx
import { useState } from 'react';
import type { CartLineItem as LineItemType } from '@/types/cart';
import { useCart } from '@/stores/cart';
import { formatMoney } from '@/utils/money';
import { QuantitySelector } from './QuantitySelector';
import { RemoveButton } from './RemoveButton';
import styles from './CartLineItem.module.css';
interface CartLineItemProps {
item: LineItemType; // Single line item data.
}
/**
* CartLineItem displays a single cart item with image, details,
* quantity controls, and remove button.
*/
export function CartLineItem({ item }: CartLineItemProps) {
// Get cart actions from store.
const { updateItem, removeItem, isItemPending } = useCart();
// Check if this item is currently being updated.
const isPending = isItemPending(item.key);
// Handle quantity change - calls store action.
const handleQuantityChange = async (newQuantity: number) => {
if (newQuantity < 1) return;
await updateItem(item.key, newQuantity);
};
// Handle remove - calls store action.
const handleRemove = async () => {
await removeItem(item.key);
};
return (
<article
className={`${styles.lineItem} ${isPending ? styles.pending : ''}`}
aria-busy={isPending}
>
{/* Product image with link */}
<a href={item.url} className={styles.imageLink}>
{item.image ? (
<img
src={item.image}
alt={item.title}
className={styles.image}
width={80}
height={80}
loading="lazy"
/>
) : (
<div className={styles.imagePlaceholder}>
<ImagePlaceholderIcon />
</div>
)}
</a>
{/* Product info */}
<div className={styles.info}>
{/* Title links to product page */}
<a href={item.url} className={styles.title}>
{item.title}
</a>
{/* Variant title (e.g., "Red / Large") */}
{item.variantTitle && item.variantTitle !== 'Default Title' && (
<p className={styles.variant}>{item.variantTitle}</p>
)}
{/* Line item properties (custom fields) */}
{item.properties && Object.keys(item.properties).length > 0 && (
<LineItemProperties properties={item.properties} />
)}
{/* Price display */}
<div className={styles.pricing}>
{/* Show discounted price if different from original */}
{item.discountedPrice < item.price ? (
<>
<span className={styles.originalPrice}>
{formatMoney(item.price)}
</span>
<span className={styles.salePrice}>
{formatMoney(item.discountedPrice)}
</span>
</>
) : (
<span className={styles.price}>{formatMoney(item.price)}</span>
)}
</div>
</div>
{/* Quantity and remove controls */}
<div className={styles.controls}>
<QuantitySelector
quantity={item.quantity}
onChange={handleQuantityChange}
disabled={isPending}
max={item.available ? undefined : item.quantity} // Can't increase if unavailable.
/>
<RemoveButton onClick={handleRemove} disabled={isPending} />
</div>
{/* Line total */}
<div className={styles.lineTotal}>
<span className={styles.lineTotalLabel}>Total</span>
<span className={styles.lineTotalValue}>
{formatMoney(item.linePrice)}
</span>
</div>
{/* Pending overlay */}
{isPending && <div className={styles.pendingOverlay} />}
</article>
);
}
/**
* Renders custom line item properties (gift notes, customizations, etc.).
*/
function LineItemProperties({ properties }: { properties: Record<string, string> }) {
// Filter out properties starting with underscore (hidden properties).
const visibleProperties = Object.entries(properties).filter(
([key]) => !key.startsWith('_')
);
if (visibleProperties.length === 0) return null;
return (
<dl className={styles.properties}>
{visibleProperties.map(([key, value]) => (
<div key={key} className={styles.property}>
<dt className={styles.propertyKey}>{key}:</dt>
<dd className={styles.propertyValue}>{value}</dd>
</div>
))}
</dl>
);
}
/**
* Placeholder icon for items without images.
*/
function ImagePlaceholderIcon() {
return (
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="8.5" cy="8.5" r="1.5" />
<polyline points="21 15 16 10 5 21" />
</svg>
);
}

CartLineItem Styles

src/components/cart/CartDrawer/CartLineItem.module.css
.lineItem {
display: grid;
grid-template-columns: 80px 1fr auto;
grid-template-rows: auto auto;
gap: 0.75rem 1rem;
padding: 1rem 0;
position: relative;
transition: opacity 0.2s ease;
}
/* Fade when update is pending. */
.lineItem.pending {
opacity: 0.6;
pointer-events: none;
}
/* Pending overlay prevents interaction. */
.pendingOverlay {
position: absolute;
inset: 0;
z-index: 1;
}
/* Image column */
.imageLink {
grid-row: 1 / 3;
align-self: start;
}
.image {
width: 80px;
height: 80px;
object-fit: cover;
border-radius: var(--radius-sm);
background-color: var(--color-surface);
}
.imagePlaceholder {
width: 80px;
height: 80px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-surface);
border-radius: var(--radius-sm);
color: var(--color-text-muted);
}
/* Info column */
.info {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0; /* Allow text truncation. */
}
.title {
font-weight: 500;
color: var(--color-text);
text-decoration: none;
/* Truncate long titles. */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.title:hover {
text-decoration: underline;
}
.variant {
margin: 0;
font-size: 0.875rem;
color: var(--color-text-muted);
}
/* Line item properties (custom fields). */
.properties {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
}
.property {
display: flex;
gap: 0.25rem;
}
.propertyKey {
color: var(--color-text-muted);
}
.propertyValue {
margin: 0;
color: var(--color-text);
}
/* Pricing display. */
.pricing {
display: flex;
align-items: center;
gap: 0.5rem;
margin-top: 0.25rem;
}
.price {
font-weight: 500;
}
.originalPrice {
text-decoration: line-through;
color: var(--color-text-muted);
font-size: 0.875rem;
}
.salePrice {
color: var(--color-sale);
font-weight: 500;
}
/* Controls column */
.controls {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 0.5rem;
}
/* Line total */
.lineTotal {
grid-column: 2 / -1;
display: flex;
justify-content: space-between;
align-items: center;
padding-top: 0.5rem;
border-top: 1px dashed var(--color-border);
}
.lineTotalLabel {
font-size: 0.875rem;
color: var(--color-text-muted);
}
.lineTotalValue {
font-weight: 600;
}
/* Mobile: stack layout */
@media (max-width: 480px) {
.lineItem {
grid-template-columns: 70px 1fr;
}
.controls {
grid-column: 2;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
.lineTotal {
grid-column: 1 / -1;
}
}

QuantitySelector Component

src/components/cart/CartDrawer/QuantitySelector.tsx
import { useId } from 'react';
import styles from './QuantitySelector.module.css';
interface QuantitySelectorProps {
quantity: number; // Current quantity value.
onChange: (quantity: number) => void; // Called when quantity changes.
disabled?: boolean; // Disable controls during updates.
min?: number; // Minimum allowed quantity.
max?: number; // Maximum allowed quantity.
}
/**
* QuantitySelector provides +/- buttons and displays current quantity.
* Handles min/max bounds and disabled states.
*/
export function QuantitySelector({
quantity,
onChange,
disabled = false,
min = 1,
max = 99,
}: QuantitySelectorProps) {
const id = useId();
// Check if buttons should be disabled.
const canDecrease = quantity > min && !disabled;
const canIncrease = quantity < max && !disabled;
return (
<div className={styles.selector}>
<label htmlFor={id} className={styles.label}>
Quantity
</label>
<div className={styles.controls}>
{/* Decrease button */}
<button
type="button"
className={styles.button}
onClick={() => onChange(quantity - 1)}
disabled={!canDecrease}
aria-label="Decrease quantity"
>
<MinusIcon />
</button>
{/* Current quantity display */}
<input
id={id}
type="text"
className={styles.input}
value={quantity}
readOnly
aria-label="Quantity"
/>
{/* Increase button */}
<button
type="button"
className={styles.button}
onClick={() => onChange(quantity + 1)}
disabled={!canIncrease}
aria-label="Increase quantity"
>
<PlusIcon />
</button>
</div>
</div>
);
}
function MinusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="3" y1="7" x2="11" y2="7" />
</svg>
);
}
function PlusIcon() {
return (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="2">
<line x1="7" y1="3" x2="7" y2="11" />
<line x1="3" y1="7" x2="11" y2="7" />
</svg>
);
}
src/components/cart/CartDrawer/QuantitySelector.module.css
.selector {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* Visually hidden but accessible label. */
.label {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.controls {
display: flex;
align-items: center;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.button {
display: flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
padding: 0;
border: none;
background-color: var(--color-surface);
color: var(--color-text);
cursor: pointer;
transition: background-color 0.15s ease;
}
.button:hover:not(:disabled) {
background-color: var(--color-surface-hover);
}
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.input {
width: 2.5rem;
height: 2rem;
padding: 0;
border: none;
border-left: 1px solid var(--color-border);
border-right: 1px solid var(--color-border);
background-color: transparent;
text-align: center;
font-size: 0.875rem;
font-weight: 500;
color: var(--color-text);
}
/* Remove number input spinners. */
.input::-webkit-inner-spin-button,
.input::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
.input[type='number'] {
-moz-appearance: textfield;
}

RemoveButton Component

src/components/cart/CartDrawer/RemoveButton.tsx
import styles from './RemoveButton.module.css';
interface RemoveButtonProps {
onClick: () => void; // Called when remove is clicked.
disabled?: boolean; // Disable during pending operations.
}
/**
* RemoveButton allows removing an item from the cart.
* Shows a trash icon with accessible label.
*/
export function RemoveButton({ onClick, disabled = false }: RemoveButtonProps) {
return (
<button
type="button"
className={styles.button}
onClick={onClick}
disabled={disabled}
aria-label="Remove item"
>
<TrashIcon />
<span className={styles.text}>Remove</span>
</button>
);
}
function TrashIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5">
<path d="M2 4h12M5.333 4V2.667a1.333 1.333 0 0 1 1.334-1.334h2.666a1.333 1.333 0 0 1 1.334 1.334V4m2 0v9.333a1.333 1.333 0 0 1-1.334 1.334H4.667a1.333 1.333 0 0 1-1.334-1.334V4h9.334z" />
</svg>
);
}
src/components/cart/CartDrawer/RemoveButton.module.css
.button {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.375rem 0.5rem;
border: none;
background: transparent;
color: var(--color-text-muted);
font-size: 0.75rem;
cursor: pointer;
border-radius: var(--radius-sm);
transition: color 0.15s ease, background-color 0.15s ease;
}
.button:hover:not(:disabled) {
color: var(--color-error);
background-color: rgba(239, 68, 68, 0.1);
}
.button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.text {
/* Hide text on mobile, show icon only. */
}
@media (max-width: 480px) {
.text {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}

Optimistic Updates in Cart Store

// src/stores/cart.ts - Optimistic update pattern for line items
import { create } from 'zustand';
import type { Cart, CartLineItem } from '@/types/cart';
import * as cartApi from '@/api/cart';
interface CartState {
cart: Cart | null;
pendingUpdates: Set<string>; // Track items being updated.
error: string | null;
// Actions
updateItem: (key: string, quantity: number) => Promise<void>;
removeItem: (key: string) => Promise<void>;
// Helpers
isItemPending: (key: string) => boolean;
}
export const useCart = create<CartState>((set, get) => ({
cart: null,
pendingUpdates: new Set(),
error: null,
// Check if a specific item is pending.
isItemPending: (key) => get().pendingUpdates.has(key),
// Update item quantity with optimistic UI.
updateItem: async (key, quantity) => {
const previousCart = get().cart;
if (!previousCart) return;
// Mark item as pending.
set((state) => ({
pendingUpdates: new Set(state.pendingUpdates).add(key),
error: null,
}));
// Optimistic update: immediately update the UI.
set((state) => ({
cart: state.cart
? optimisticUpdateQuantity(state.cart, key, quantity)
: null,
}));
try {
// Make API call.
const serverCart = await cartApi.updateLineItem(key, quantity);
// Update with server response (source of truth).
set({ cart: transformCart(serverCart) });
} catch (error) {
// Rollback on error.
set({
cart: previousCart,
error: 'Failed to update quantity',
});
} finally {
// Clear pending state.
set((state) => {
const pending = new Set(state.pendingUpdates);
pending.delete(key);
return { pendingUpdates: pending };
});
}
},
// Remove item with optimistic UI.
removeItem: async (key) => {
const previousCart = get().cart;
if (!previousCart) return;
// Mark as pending.
set((state) => ({
pendingUpdates: new Set(state.pendingUpdates).add(key),
error: null,
}));
// Optimistic remove: immediately remove from UI.
set((state) => ({
cart: state.cart ? optimisticRemoveItem(state.cart, key) : null,
}));
try {
const serverCart = await cartApi.removeLineItem(key);
set({ cart: transformCart(serverCart) });
} catch (error) {
// Rollback on error.
set({
cart: previousCart,
error: 'Failed to remove item',
});
} finally {
set((state) => {
const pending = new Set(state.pendingUpdates);
pending.delete(key);
return { pendingUpdates: pending };
});
}
},
}));
// Helper: optimistically update quantity.
function optimisticUpdateQuantity(cart: Cart, key: string, quantity: number): Cart {
const items = cart.items.map((item) => {
if (item.key !== key) return item;
return {
...item,
quantity,
linePrice: item.price * quantity,
};
});
return recalculateCart(cart, items);
}
// Helper: optimistically remove item.
function optimisticRemoveItem(cart: Cart, key: string): Cart {
const items = cart.items.filter((item) => item.key !== key);
return recalculateCart(cart, items);
}
// Helper: recalculate cart totals.
function recalculateCart(cart: Cart, items: CartLineItem[]): Cart {
const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);
const totalPrice = items.reduce((sum, item) => sum + item.linePrice, 0);
return {
...cart,
items,
itemCount,
totalPrice,
};
}

Key Takeaways

  1. Optimistic updates: Update UI immediately, rollback on error
  2. Pending states: Track and display loading states per item
  3. Accessibility: Use proper labels, ARIA attributes, and semantic HTML
  4. Line properties: Support custom fields like gift notes
  5. Responsive design: Adapt layout for mobile screens
  6. Visual feedback: Show discounts, sale prices, and totals clearly

In the next lesson, we’ll build the cart totals and discount display components.

Finished this lesson?

Mark it complete to track your progress.

Discussion

Loading comments...