Layout

Solid 2Unstable

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.

Live demo

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.

examples/add-to-cart/Demo.tsx
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)
}
examples/add-to-cart/Demo.tsx
// 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")} />
}
examples/add-to-cart/Client.tsx
"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)
}
Run Motion's version →