Layout
Solid 2UnstableAdd 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 { revalidate } 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/unstable/v2"
import { action, createMemo, createOptimistic, createSignal, Loading, Match, onSettled, Show, Switch } from "solid-js"
import { cart, product } from "~/lib/store.ts"
import { addToCart } from "~/server/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 = createMemo(() => product("poppy-plate"))
const photo = photoOf("poppy-plate")
const [quantity, setQuantity] = createSignal(1)
const [added, setAdded] = createSignal(false)
const [failure, setFailure] = createSignal<string>()
// "Adding…" is an optimistic value: set inside the action, it shows at once, and goes back by
// itself when the action settles, whether the store answered or refused.
const [adding, setAdding] = createOptimistic(false)
// "Added ✓" stays a few seconds, then the button offers to add again.
let reset: ReturnType<typeof setTimeout> | undefined
onSettled(() => () => clearTimeout(reset))
const buy = action(function* (id: string, howMany: number) {
setAdding(true)
setAdded(false)
setFailure(undefined)
try {
yield addToCart(id, howMany)
// The cart has changed: the header's count reads it again.
revalidate(cart.key)
setAdded(true)
clearTimeout(reset)
reset = setTimeout(() => setAdded(false), 3000)
} catch (error) {
setFailure(messageOf(error))
}
})
return (
<Loading fallback={<p class="muted">Loading the piece…</p>}>
<article class="buy-box">
<div class="photo">
<img src={photo.small} srcset={srcsetOf(photo)} sizes="160px" width={photo.width} height={photo.height} alt={piece().name} />
</div>
<div class="buy-body">
<h2 class="name">{piece().name}</h2>
<p class="muted">{piece().tagline}</p>
<p class="price">{formatPrice(piece().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() === 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. */}
<wardic.button class="button accent buy" layout disabled={adding()} onClick={() => buy(piece().id, quantity())}>
<wardic.span layout="position">
<Switch fallback="Add to cart">
<Match when={adding()}>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={failure()}>
{(message) => (
<p class="error-line" role="alert">
{message()}
</p>
)}
</Show>
</div>
</article>
</Loading>
)
}
function messageOf(error: unknown): string {
if (error instanceof Error) return error.message
return String(error)
}
// 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)
}