Layout
Add to cart, and see it's done
The button says what it's doing, adding then added, and makes room for a way to the cart.
Loading the piece…
Reduced motion: The button changes width at once, and "View cart" fades in without sliding.
Why
A button that works out of sight leaves people pressing it twice. "Adding…" while the request is out, then "Added ✓" with a link to the cart, answers the question before it's asked. But a label that changes length changes the button's width, and a button that snaps between widths looks broken.
The button is a layout element, so its width animates whenever its label changes it. The label inside is layout="position": it travels to its new place in the button without stretching as the button scales. "View cart" enters beside it through an <AnimatePresence>. Set a longer delay, or a failure, in the store's settings (⚙) to see the states in between.
The code
React 19's useOptimistic is Solid 2's createOptimistic: "Adding…" shows at once inside the transition, and goes when it ends, the store answered or not (Solid 1 reads the router's useSubmission). The write is a server action that calls refresh(), which renders the page's server components again: the piece is read again along with the cart, where Solid revalidates the cart's query alone.
import { createAsync, useAction, useSubmission } from "@solidjs/router"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import { AnimatePresence, wardic } from "@wardic/solid"
import { createSignal, Match, onCleanup, Show, Suspense, Switch } from "solid-js"
import { addToCart, product } from "~/lib/store.ts"
// "View cart" slides out from behind the button once the piece is in the cart.
const viewCart = {
initial: { opacity: 0, x: -12 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -12, transition: { type: "tween", duration: 0.12 } },
} as const
export default function AddToCart() {
const piece = createAsync(() => product("poppy-plate"))
const add = useAction(addToCart)
const adding = useSubmission(addToCart)
const [quantity, setQuantity] = createSignal(1)
const [added, setAdded] = createSignal(false)
// "Added ✓" stays a few seconds, then the button offers to add again.
let reset: ReturnType<typeof setTimeout> | undefined
onCleanup(() => clearTimeout(reset))
const buy = async (id: string) => {
setAdded(false)
await add(id, quantity())
setAdded(true)
clearTimeout(reset)
reset = setTimeout(() => setAdded(false), 3000)
}
return (
<Suspense fallback={<p class="muted">Loading the piece…</p>}>
<Show when={piece()}>
{(shown) => {
const photo = photoOf(shown().id)
return (
<article class="buy-box">
<div class="photo">
<img src={photo.small} srcset={srcsetOf(photo)} sizes="160px" width={photo.width} height={photo.height} alt={shown().name} />
</div>
<div class="buy-body">
<h2 class="name">{shown().name}</h2>
<p class="muted">{shown().tagline}</p>
<p class="price">{formatPrice(shown().price)}</p>
<div class="buy-row">
<div class="stepper" role="group" aria-label="Quantity">
<button aria-label="One fewer" disabled={quantity() === 1} onClick={() => setQuantity(quantity() - 1)}>
−
</button>
<span aria-live="polite">{quantity()}</span>
<button aria-label="One more" disabled={quantity() === shown().stock} onClick={() => setQuantity(quantity() + 1)}>
+
</button>
</div>
{/* The button's width follows its label: layout animates the change, and the label,
layout="position", travels with it instead of stretching. */}
<wardic.button class="button accent buy" layout disabled={adding.pending} onClick={() => buy(shown().id).catch(() => undefined)}>
<wardic.span layout="position">
<Switch fallback="Add to cart">
<Match when={adding.pending}>Adding…</Match>
<Match when={added()}>Added ✓</Match>
</Switch>
</wardic.span>
</wardic.button>
<AnimatePresence>
<Show when={added()}>
<wardic.a class="view-cart" href="/layout/cart-drawer" {...viewCart}>
View cart →
</wardic.a>
</Show>
</AnimatePresence>
</div>
<Show when={adding.error}>
{(error) => (
<p class="error-line" role="alert">
{String((error() as Error).message)}
</p>
)}
</Show>
</div>
</article>
)
}}
</Show>
</Suspense>
)
}
// Add to cart, a server component: it starts the piece's read and hands its promise to the client part.
import { getProduct } from "~/lib/store.ts"
import { AddToCart } from "./Client.tsx"
export default function Demo() {
return <AddToCart piece={getProduct("poppy-plate")} />
}
"use client"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf, srcsetOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, motion } from "motion/react"
import Link from "next/link"
import { startTransition, Suspense, use, useEffect, useOptimistic, useRef, useState } from "react"
import { addToCart } from "~/lib/store.ts"
// "View cart" slides out from behind the button once the piece is in the cart.
const viewCart = {
initial: { opacity: 0, x: -12 },
animate: { opacity: 1, x: 0 },
exit: { opacity: 0, x: -12, transition: { type: "tween", duration: 0.12 } },
} as const
const MotionLink = motion.create(Link)
export function AddToCart({ piece }: { readonly piece: Promise<Product> }) {
return (
<Suspense fallback={<p className="muted">Loading the piece…</p>}>
<BuyBox piece={piece} />
</Suspense>
)
}
function BuyBox(props: { readonly piece: Promise<Product> }) {
const piece = use(props.piece)
const photo = photoOf("poppy-plate")
const [quantity, setQuantity] = useState(1)
const [added, setAdded] = useState(false)
const [failure, setFailure] = useState<string>()
// "Adding…" is an optimistic value: set inside the transition, it shows at once, and goes back by
// itself when the transition ends, whether the store answered or refused.
const [adding, setAdding] = useOptimistic(false)
// "Added ✓" stays a few seconds, then the button offers to add again.
const reset = useRef<ReturnType<typeof setTimeout>>(undefined)
useEffect(() => () => clearTimeout(reset.current), [])
const buy = () =>
startTransition(async () => {
setAdding(true)
setAdded(false)
setFailure(undefined)
try {
// A server action: it refreshes the page, so the header's count reads the cart again.
await addToCart(piece.id, quantity)
startTransition(() => setAdded(true))
clearTimeout(reset.current)
reset.current = setTimeout(() => setAdded(false), 3000)
} catch (error) {
setFailure(messageOf(error))
}
})
return (
<article className="buy-box">
<div className="photo">
<img src={photo.small} srcSet={srcsetOf(photo)} sizes="160px" width={photo.width} height={photo.height} alt={piece.name} />
</div>
<div className="buy-body">
<h2 className="name">{piece.name}</h2>
<p className="muted">{piece.tagline}</p>
<p className="price">{formatPrice(piece.price)}</p>
<div className="buy-row">
<div className="stepper" role="group" aria-label="Quantity">
<button aria-label="One fewer" disabled={quantity === 1} onClick={() => setQuantity(quantity - 1)}>
−
</button>
<span aria-live="polite">{quantity}</span>
<button aria-label="One more" disabled={quantity === piece.stock} onClick={() => setQuantity(quantity + 1)}>
+
</button>
</div>
{/* The button's width follows its label: layout animates the change, and the label,
layout="position", travels with it instead of stretching. */}
<motion.button className="button accent buy" layout disabled={adding} onClick={buy}>
<motion.span layout="position">{labelOf(adding, added)}</motion.span>
</motion.button>
<AnimatePresence>
{added && (
<MotionLink className="view-cart" href="/layout/cart-drawer" {...viewCart}>
View cart →
</MotionLink>
)}
</AnimatePresence>
</div>
{failure && (
<p className="error-line" role="alert">
{failure}
</p>
)}
</div>
</article>
)
}
function labelOf(adding: boolean, added: boolean): string {
if (adding) return "Adding…"
if (added) return "Added ✓"
return "Add to cart"
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}