Shopping cart blocks
Two places a cart shows up in a store: the cart page, and a drawer that opens
when you add something. Each block is one .svelte file built from
SvNumberInput and SvButton, with SvTextInput for the promo code on the
page and SvDrawer for the slide-over. Money is kept in integer cents and
formatted with
Intl.NumberFormat only when it is shown, and the product pictures are inline
SVG tinted from the --sg-* tokens.
Installation
Add the whole group, or one block by its name (shown under each demo):
See them first with npx @svgrid/ui try blocks/shopping-carts. No project needed.
Cart page block
Three line items, each with an SvNumberInput quantity (1 to 10), a Remove
link whose toast has Undo, and Save for later, which moves the line to a saved
list with a Move to cart button. The summary column shows the subtotal, a
promo code (WELCOME15 takes 15% off, anything else shows a field error),
estimated shipping that turns free at $150 after the discount, and Checkout
with a loading state. Below 760 px the summary moves under the items.
npx @svgrid/ui add shopping-carts-page
Cart slide-over block
A grid of six products. Add to cart puts the product in the cart (or adds one
to its quantity) and slides in an SvDrawer with the lines, quantities,
Remove links, the subtotal, Checkout and Continue shopping. The Cart button in
the header shows the item count and reopens the drawer.
npx @svgrid/ui add shopping-carts-panel
One cart for the whole app
Each block's core keeps its own copy of the lines, which is right for a demo
and wrong for a store: the badge in the navbar and the cart page must show the
same cart. Keep the cart in a shared module instead. A .svelte.ts file can
hold $state, and every component that imports it reads the same object:
// src/lib/cart.svelte.ts
export type CartLine = { id: string; name: string; variant: string; priceCents: number; qty: number; art: string; tint: string }
export const cart = $state<{ lines: CartLine[] }>({ lines: [] })
export const cartCount = () => cart.lines.reduce((n, l) => n + l.qty, 0)
/** Only ids and quantities go in the cookie; prices come from your database. */
function persist() {
const pairs = cart.lines.map((l) => [l.id, l.qty])
document.cookie = `cart=${encodeURIComponent(JSON.stringify(pairs))}; path=/; max-age=2592000; samesite=lax`
}
export function add(product: Omit<CartLine, 'qty'>) {
const line = cart.lines.find((l) => l.id === product.id)
if (line) line.qty += 1
else cart.lines.push({ ...product, qty: 1 })
persist()
}
export function remove(id: string) {
cart.lines = cart.lines.filter((l) => l.id !== id)
persist()
}
export function setQty(id: string, qty: number) {
const line = cart.lines.find((l) => l.id === id)
if (line) line.qty = Math.max(1, Math.round(qty))
persist()
}
/** Browser only: take the lines the server resolved from the cookie. */
export function hydrate(lines: CartLine[]) {
cart.lines = lines
}
The cookie is what lets the server render the right count on the first paint.
Read it in the root layout's load and look the products up, so names and
prices never come from the browser (getProductsByIds stands in for your
database call and returns a Map keyed by id):
// src/routes/+layout.server.ts
import type { LayoutServerLoad } from './$types'
import { getProductsByIds } from '$lib/server/products'
export const load: LayoutServerLoad = async ({ cookies }) => {
let pairs: [string, number][] = []
try { pairs = JSON.parse(cookies.get('cart') ?? '[]') } catch { /* bad cookie: empty cart */ }
const products = await getProductsByIds(pairs.map(([id]) => id))
const cart = pairs.flatMap(([id, qty]) => {
const p = products.get(id)
return p ? [{ ...p, qty }] : []
})
return { cart }
}
A module is loaded once per server process and shared by every request, so
never write to cart during server rendering: one shopper's lines would show
up for the next. Render the count from data on the server and switch to the
module in the browser:
<!-- src/routes/+layout.svelte -->
<script lang="ts">
import { browser } from '$app/environment'
import { cartCount, hydrate } from '$lib/cart.svelte.js'
let { data, children } = $props()
if (browser) hydrate(data.cart)
const count = $derived(browser ? cartCount() : data.cart.reduce((n, l) => n + l.qty, 0))
</script>
<header>
<a href="/cart" aria-label={`Cart, ${count} items`}>Cart ({count})</a>
</header>
{@render children()}
To drive a block from this module, delete the lines (or items) state in
its core and read cart.lines instead, and make add, remove and setQty
call the module's functions. The totals in the cores are $derived from the
lines, so they keep working unchanged. If your users sign in, a server session
works the same way: replace persist() with a fetch to an endpoint that
saves the cart, and read it from the session in the layout load instead of
the cookie.
Checkout goes through the core's onCheckout option. It receives a plain copy
of the lines; return your request's promise so the button shows its loading
state, and throw to leave the cart as it is:
const cart = createCartPanel({
onCheckout: async (lines) => {
const r = await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(lines.map((l) => ({ id: l.id, qty: l.qty }))) })
if (!r.ok) throw new Error('Checkout failed')
location.href = (await r.json()).url
},
})
Headless
Every block here is a styled render of the core in its <script module>:
createCartPage and createCartPanel. To draw your own markup, take a core
alone with npx @svgrid/ui add shopping-carts-panel --headless (it writes
shopping-carts-panel.svelte.ts) and spread its prop-getters on plain
elements. add sets open, so the panel shows as soon as something goes in,
and a checkout that resolves closes it:
<script lang="ts">
import { createCartPanel } from './shopping-carts-panel.svelte.js'
const cart = createCartPanel({ lines: [] })
</script>
<button {...cart.triggerProps()}>Cart ({cart.count})</button>
{#each cart.products as p (p.id)}
<button {...cart.addProps(p)}>Add {p.name}, {cart.format(p.priceCents)}</button>
{/each}
{#if cart.open}
<div role="dialog" aria-label="Cart">
<ul>
{#each cart.lines as line (line.id)}
<li>
{line.name} x {line.qty}
<button onclick={() => cart.remove(line.id)}>Remove</button>
</li>
{/each}
</ul>
<p>Subtotal {cart.format(cart.subtotal)}</p>
<button disabled={cart.busy || !cart.lines.length} onclick={cart.checkout}>Checkout</button>
<button onclick={cart.close}>Continue shopping</button>
</div>
{/if}
See also
Live examples
- Cart page block - Cart page with SvNumberInput quantities, Remove with an Undo toast and Save for later moving items to a saved list. The summary column has subtotal, estimated shipping, an SvTextInput promo code (WELCOME15 takes 15% off) and Checkout.
- Cart slide-over block - Product grid whose Add to cart SvButtons open an SvDrawer cart with SvNumberInput quantities, Remove, subtotal, Checkout and Continue shopping; the header Cart button shows the item count.