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.

  • layout
  • layout="position"
  • AnimatePresence
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 { 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>
  )
}
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 →