Layout

Solid 2Unstable

A cart drawer that keeps up

Change a quantity or remove a piece, and the lines below close up at once, before the store has answered.

Live demo

Your cart lives on the store: what you add here is in the header's cart too.

Reduced motion: The drawer appears in place and the lines close up at once; the fades stay.

Why

A cart where nothing moves until the server answers feels broken on a slow connection; one where lines jump into gaps feels careless. When a removed line fades while the rest close up, and a new one slides in at the bottom, the cart reads as one list changing, and it does so the moment you tap.

Each line is a layout element inside an <AnimatePresence mode="popLayout">, so one that's removed leaves the flow at once and the lines below slide up while it fades. The suggestions under the list are layout too, and slide down as lines arrive. The lines on screen are the store's cart with the changes still on their way laid over it, so they animate on the tap, not when the store answers: on Solid 1 from the router's pending submissions, on Solid 2 from an optimistic store, which drops a change the store refuses by itself.

The drawer is position: fixed, over a page you may have scrolled. Wardic measures every line where it's drawn, so nothing needs marking for that.

The code

React 19's useOptimistic lays the changes on their way over the store's lines, as Solid 2's optimistic store does (Solid 1 reads the router's pending submissions). Motion's version needs a LayoutGroup around the drawer's body: when "Your cart is empty" goes at the end of its fade, nothing there renders again, and Motion measures a layout element as it renders, so the suggestions would jump up into its space; Wardic sees their box change, and glides them. And a change is a server action that calls refresh(), which reads the cart again with everything else on the page.

examples/cart-drawer/Demo.tsx
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { CartLine, Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, wardic } from "@wardic/solid/unstable/v2"
import { revalidate } from "@solidjs/router"
import { action, createMemo, createOptimisticStore, createSignal, For, Loading, onSettled, refresh, Show } from "solid-js"
import { cart, products } from "~/lib/store.ts"
import { addToCart, setCartQuantity } from "~/server/store.ts"

const SUGGESTED = ["white-bowl", "celadon-bowl", "slip-dish", "painted-jug"]

// The drawer slides in from the right edge; a line arrives from the right and leaves the same way.
const drawer = { initial: { x: 460 }, animate: { x: 0 }, exit: { x: 460 }, transition: { type: "spring", stiffness: 380, damping: 40 } } as const
const line = { initial: { opacity: 0, x: 32 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: 32 } } as const
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const

export default function CartDrawer() {
  const [open, setOpen] = createSignal(false)
  const [problem, setProblem] = createSignal<string>()
  const all = createMemo(() => products())

  // The cart's lines, derived from the store's, keyed by product. A write inside an action shows at
  // once as a prediction over them, and is dropped when the action settles: the refreshed lines
  // then take its place, or, if the store refused, the line goes back to what it was.
  const [lines, setLines] = createOptimisticStore<Array<CartLine>>(async () => [...(await cart()).lines], [], { key: "productId" })

  const change = action(function* (id: string, quantity: number) {
    setLines((draft) => draft.map((one) => lineAt(one, id, quantity)).filter((one) => one.quantity > 0))
    setProblem(undefined)
    try {
      yield setCartQuantity(id, quantity)
    } catch (error) {
      setProblem(messageOf(error))
    }
    // The router's cart is stale now: read it again, here and in the header, then reconcile.
    revalidate(cart.key)
    refresh(lines)
  })

  const add = action(function* (id: string) {
    setLines((draft) => withOneMore(draft, id))
    setProblem(undefined)
    try {
      yield addToCart(id, 1)
    } catch (error) {
      setProblem(messageOf(error))
    }
    revalidate(cart.key)
    refresh(lines)
  })

  const productOf = (id: string): Product | undefined => all().find((product) => product.id === id)
  const priceOf = (id: string) => {
    const product = productOf(id)
    if (product === undefined) return 0
    return product.price
  }
  const count = () => lines.reduce((sum, one) => sum + one.quantity, 0)
  const subtotal = () => lines.reduce((sum, one) => sum + one.quantity * priceOf(one.productId), 0)
  const suggestions = () => SUGGESTED.filter((id) => !lines.some((one) => one.productId === id))

  onSettled(() => {
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false)
    }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  })

  return (
    <div class="cart-demo">
      <p class="muted">Your cart lives on the store: what you add here is in the header's cart too.</p>
      <Loading fallback={<button class="button accent open-cart">Open the cart</button>}>
        <button class="button accent open-cart" onClick={() => setOpen(true)}>
          Open the cart · {count()}
        </button>
      </Loading>
      <AnimatePresence>
        <Show when={open()}>
          <wardic.div class="backdrop" {...fade} onClick={() => setOpen(false)} />
          <wardic.aside class="drawer" role="dialog" aria-modal="true" aria-label="Your cart" {...drawer}>
            <header class="drawer-head">
              <h2>Your cart</h2>
              <button class="icon-button" aria-label="Close" onClick={() => setOpen(false)}>
                ✕
              </button>
            </header>
            <Loading fallback={<p class="muted">Loading your cart…</p>}>
              <div class="drawer-body">
                <Show when={problem()}>
                  {(message) => (
                    <p class="error-line" role="alert">
                      {message()}
                    </p>
                  )}
                </Show>
                {/* Each line is a layout element: when one goes, popLayout takes it out of the flow
                    at once, and the lines below slide up into its place while it fades. */}
                <ul class="cart-lines">
                  <AnimatePresence mode="popLayout" initial={false}>
                    <For each={lines}>
                      {(one) => (
                        <wardic.li class="cart-line" layout {...line}>
                          <Show when={productOf(one.productId)}>
                            {(product) => (
                              <>
                                <img src={photoOf(one.productId).small} width="56" height="56" alt="" />
                                <div class="cart-line-text">
                                  <strong>{product().name}</strong>
                                  <span class="muted">{formatPrice(product().price)}</span>
                                </div>
                                <div class="stepper" role="group" aria-label={`${product().name} quantity`}>
                                  <button aria-label="One fewer" onClick={() => change(one.productId, one.quantity - 1)}>
                                    −
                                  </button>
                                  <span>{one.quantity}</span>
                                  <button aria-label="One more" onClick={() => change(one.productId, one.quantity + 1)}>
                                    +
                                  </button>
                                </div>
                                <button class="link-button" onClick={() => change(one.productId, 0)}>
                                  Remove
                                </button>
                              </>
                            )}
                          </Show>
                        </wardic.li>
                      )}
                    </For>
                  </AnimatePresence>
                </ul>
                <AnimatePresence initial={false}>
                  <Show when={lines.length === 0}>
                    <wardic.p class="cart-empty" {...fade}>
                      Your cart is empty.
                    </wardic.p>
                  </Show>
                </AnimatePresence>
                {/* The suggestions sit under the lines and slide down as lines arrive: layout="position",
                    as their block only travels. One added to the cart leaves, and the rest close up. */}
                <Show when={suggestions().length > 0}>
                  <wardic.section class="suggestions" layout="position">
                    <h3>You might also like</h3>
                    <ul>
                      <AnimatePresence mode="popLayout" initial={false}>
                        <For each={suggestions()}>
                          {(id) => (
                            <wardic.li layout="position" {...fade}>
                              <Show when={productOf(id)}>
                                {(product) => (
                                  <button class="suggestion" onClick={() => add(id)}>
                                    <img src={photoOf(id).small} width="44" height="44" alt="" />
                                    <span>{product().name}</span>
                                    <span class="add" aria-hidden="true">
                                      +
                                    </span>
                                  </button>
                                )}
                              </Show>
                            </wardic.li>
                          )}
                        </For>
                      </AnimatePresence>
                    </ul>
                  </wardic.section>
                </Show>
              </div>
              <footer class="drawer-foot">
                <span>Subtotal</span>
                <strong>{formatPrice(subtotal())}</strong>
                <button class="button accent" disabled={lines.length === 0}>
                  Check out
                </button>
              </footer>
            </Loading>
          </wardic.aside>
        </Show>
      </AnimatePresence>
    </div>
  )
}

/** `lines` with one more of a product: its line, or a new one at the end. */
function withOneMore(lines: ReadonlyArray<CartLine>, productId: string): Array<CartLine> {
  if (lines.some((one) => one.productId === productId)) return lines.map((one) => lineAt(one, productId, one.quantity + 1))
  return [...lines, { productId, quantity: 1 }]
}

/** `one` at `quantity` if it's `productId`'s line, capped at 99. */
function lineAt(one: CartLine, productId: string, quantity: number): CartLine {
  if (one.productId !== productId) return one
  return { productId, quantity: Math.min(quantity, 99) }
}

function messageOf(error: unknown): string {
  if (error instanceof Error) return error.message
  return String(error)
}
examples/cart-drawer/Demo.tsx
// A cart drawer, a server component: it starts the cart's read and the shop's, and hands their
// promises to the client part. A change to the cart refreshes the page, which reads the cart again.
import { getCart, listProducts } from "~/lib/store.ts"
import { CartDrawer } from "./Client.tsx"

export default function Demo() {
  return <CartDrawer cart={getCart()} products={listProducts()} />
}
examples/cart-drawer/Client.tsx
"use client"
import { formatPrice } from "@wardic/example-storefront-shared/format"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Cart, CartLine, Product } from "@wardic/example-storefront-shared/server"
import { AnimatePresence, LayoutGroup, motion } from "motion/react"
import { startTransition, Suspense, use, useEffect, useOptimistic, useState } from "react"
import { addToCart, setCartQuantity } from "~/lib/store.ts"

const SUGGESTED = ["white-bowl", "celadon-bowl", "slip-dish", "painted-jug"]

// The drawer slides in from the right edge; a line arrives from the right and leaves the same way.
const drawer = { initial: { x: 460 }, animate: { x: 0 }, exit: { x: 460 }, transition: { type: "spring", stiffness: 380, damping: 40 } } as const
const line = { initial: { opacity: 0, x: 32 }, animate: { opacity: 1, x: 0 }, exit: { opacity: 0, x: 32 } } as const
const fade = { initial: { opacity: 0 }, animate: { opacity: 1 }, exit: { opacity: 0 } } as const

interface Props {
  readonly cart: Promise<Cart>
  readonly products: Promise<ReadonlyArray<Product>>
}

/** A change on its way to the store: a line set to a quantity, or one more of a piece. */
type Change = { readonly kind: "set"; readonly id: string; readonly quantity: number } | { readonly kind: "add"; readonly id: string }

export function CartDrawer(props: Props) {
  const [open, setOpen] = useState(false)

  useEffect(() => {
    const onKey = (event: KeyboardEvent) => {
      if (event.key === "Escape") setOpen(false)
    }
    window.addEventListener("keydown", onKey)
    return () => window.removeEventListener("keydown", onKey)
  }, [])

  return (
    <div className="cart-demo">
      <p className="muted">Your cart lives on the store: what you add here is in the header&apos;s cart too.</p>
      <Suspense fallback={<button className="button accent open-cart">Open the cart</button>}>
        <OpenButton cart={props.cart} open={() => setOpen(true)} />
      </Suspense>
      <AnimatePresence>
        {open && <motion.div key="backdrop" className="backdrop" {...fade} onClick={() => setOpen(false)} />}
        {open && (
          <motion.aside key="drawer" className="drawer" role="dialog" aria-modal="true" aria-label="Your cart" {...drawer}>
            <header className="drawer-head">
              <h2>Your cart</h2>
              <button className="icon-button" aria-label="Close" onClick={() => setOpen(false)}>
                ✕
              </button>
            </header>
            <Suspense fallback={<p className="muted">Loading your cart…</p>}>
              <Lines {...props} />
            </Suspense>
          </motion.aside>
        )}
      </AnimatePresence>
    </div>
  )
}

function OpenButton({ cart, open }: { readonly cart: Promise<Cart>; readonly open: () => void }) {
  const count = use(cart).lines.reduce((sum, one) => sum + one.quantity, 0)
  return (
    <button className="button accent open-cart" onClick={open}>
      Open the cart · {count}
    </button>
  )
}

function Lines(props: Props) {
  const products = use(props.products)
  const [problem, setProblem] = useState<string>()
  // The cart's lines: the store's, with the changes still on their way laid over them. A change
  // shows at once, and goes when its transition ends: the refreshed cart then takes its place, or,
  // if the store refused, the line goes back to what it was.
  const [lines, change] = useOptimistic(use(props.cart).lines, (now: ReadonlyArray<CartLine>, next: Change) => {
    if (next.kind === "add") return withOneMore(now, next.id)
    return now.map((one) => lineAt(one, next.id, next.quantity)).filter((one) => one.quantity > 0)
  })

  // Each is a server action, which refreshes the page: the cart is read again, here and in the header.
  const setQuantity = (id: string, quantity: number) =>
    startTransition(async () => {
      change({ kind: "set", id, quantity })
      setProblem(undefined)
      try {
        await setCartQuantity(id, quantity)
      } catch (error) {
        setProblem(messageOf(error))
      }
    })
  const add = (id: string) =>
    startTransition(async () => {
      change({ kind: "add", id })
      setProblem(undefined)
      try {
        await addToCart(id, 1)
      } catch (error) {
        setProblem(messageOf(error))
      }
    })

  const productOf = (id: string): Product | undefined => products.find((product) => product.id === id)
  const subtotal = lines.reduce((sum, one) => sum + one.quantity * (productOf(one.productId)?.price ?? 0), 0)
  const suggestions = SUGGESTED.filter((id) => !lines.some((one) => one.productId === id))

  return (
    <>
      <div className="drawer-body">
        {/* A LayoutGroup: when "Your cart is empty" goes at the end of its exit, nothing here
            renders again, and Motion measures a layout element when it renders. In a group, one
            member's change has the others measure, so the suggestions glide up into its space
            instead of jumping. */}
        <LayoutGroup>
          {problem && (
            <p className="error-line" role="alert">
              {problem}
            </p>
          )}
          {/* Each line is a layout element: when one goes, popLayout takes it out of the flow at
              once, and the lines below slide up into its place while it fades. */}
          <ul className="cart-lines">
            <AnimatePresence mode="popLayout" initial={false}>
              {lines.map((one) => {
                const product = productOf(one.productId)
                return (
                  <motion.li key={one.productId} className="cart-line" layout {...line}>
                    {product && (
                      <>
                        <img src={photoOf(one.productId).small} width="56" height="56" alt="" />
                        <div className="cart-line-text">
                          <strong>{product.name}</strong>
                          <span className="muted">{formatPrice(product.price)}</span>
                        </div>
                        <div className="stepper" role="group" aria-label={`${product.name} quantity`}>
                          <button aria-label="One fewer" onClick={() => setQuantity(one.productId, one.quantity - 1)}>
                            −
                          </button>
                          <span>{one.quantity}</span>
                          <button aria-label="One more" onClick={() => setQuantity(one.productId, one.quantity + 1)}>
                            +
                          </button>
                        </div>
                        <button className="link-button" onClick={() => setQuantity(one.productId, 0)}>
                          Remove
                        </button>
                      </>
                    )}
                  </motion.li>
                )
              })}
            </AnimatePresence>
          </ul>
          <AnimatePresence initial={false}>
            {lines.length === 0 && (
              <motion.p className="cart-empty" {...fade}>
                Your cart is empty.
              </motion.p>
            )}
          </AnimatePresence>
          {/* The suggestions sit under the lines and slide down as lines arrive: layout="position",
              as their block only travels. One added to the cart leaves, and the rest close up. */}
          {suggestions.length > 0 && (
            <motion.section className="suggestions" layout="position">
              <h3>You might also like</h3>
              <ul>
                <AnimatePresence mode="popLayout" initial={false}>
                  {suggestions.map((id) => {
                    const product = productOf(id)
                    return (
                      <motion.li key={id} layout="position" {...fade}>
                        {product && (
                          <button className="suggestion" onClick={() => add(id)}>
                            <img src={photoOf(id).small} width="44" height="44" alt="" />
                            <span>{product.name}</span>
                            <span className="add" aria-hidden="true">
                              +
                            </span>
                          </button>
                        )}
                      </motion.li>
                    )
                  })}
                </AnimatePresence>
              </ul>
            </motion.section>
          )}
        </LayoutGroup>
      </div>
      <footer className="drawer-foot">
        <span>Subtotal</span>
        <strong>{formatPrice(subtotal)}</strong>
        <button className="button accent" disabled={lines.length === 0}>
          Check out
        </button>
      </footer>
    </>
  )
}

/** `lines` with one more of a product: its line, or a new one at the end. */
function withOneMore(lines: ReadonlyArray<CartLine>, productId: string): ReadonlyArray<CartLine> {
  if (lines.some((one) => one.productId === productId)) return lines.map((one) => lineAt(one, productId, one.quantity + 1))
  return [...lines, { productId, quantity: 1 }]
}

/** `one` at `quantity` if it's `productId`'s line, capped at 99. */
function lineAt(one: CartLine, productId: string, quantity: number): CartLine {
  if (one.productId !== productId) return one
  return { productId, quantity: Math.min(quantity, 99) }
}

function messageOf(error: unknown): string {
  if (error instanceof Error) return error.message
  return String(error)
}
Run Motion's version →