Presence

"Added to cart", in toasts that make room

Each piece you add says so in a toast; when one goes, the others close the gap.

  • AnimatePresence mode="popLayout"
  • layout
  • exit
Live demo
  • Moon Vase
    Moon Vase$148
  • Poppy Plate
    Poppy Plate$54
  • Celadon Cone Bowl
    Celadon Cone Bowl$58

    Reduced motion: Toasts fade in and out without rising, and the others move to their new places at once.

    Why

    A toast is news that shouldn't stay: it arrives, says its piece and goes. With a few on screen, the stack is a list that changes at both ends, new toasts arriving at the bottom while older ones time out above them. If the gap a toast leaves snaps shut, the one you were reading jumps.

    The stack is an AnimatePresence in popLayout mode, and each toast a layout element. As a toast leaves, the presence takes it out of the stack's flow at once, held where it was while its exit plays, so the others close the gap as it fades, not after it has gone. A fourth toast pushes the oldest out the same way.

    Each "Add to cart" calls the store, so the header's count goes up too. Set the store to fail the next request (⚙ in the header) to see a toast for a failure.

    The code

    examples/toasts/Demo.tsx
    import { useAction } from "@solidjs/router"
    import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
    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, wardic } from "@wardic/solid"
    import { createSignal, For, onCleanup } from "solid-js"
    import { addToCart } from "~/lib/store.ts"
    
    interface Toast {
      readonly id: number
      readonly kind: "added" | "failed"
      readonly text: string
    }
    
    // A toast rises in at the bottom of the stack and shrinks away as it goes. Each is a layout
    // element: when one goes, the presence pops it out of the stack (mode="popLayout"), and the others
    // slide into its place while it fades.
    const toastMotion = {
      initial: { opacity: 0, y: 24, scale: 0.96 },
      animate: { opacity: 1, y: 0, scale: 1 },
      exit: { opacity: 0, scale: 0.9, transition: { type: "tween", duration: 0.18 } },
      transition: { type: "spring", stiffness: 420, damping: 32 },
    } as const
    
    const SHELF = PRODUCTS.filter((product) => ["moon-vase", "celadon-bowl", "poppy-plate"].includes(product.id))
    
    /** How long a toast stays, and how many show at once: a fourth pushes the oldest out. */
    const STAYS_MS = 4000
    const AT_MOST = 3
    
    export default function Toasts() {
      const add = useAction(addToCart)
      const [toasts, setToasts] = createSignal<ReadonlyArray<Toast>>([])
      let nextId = 0
      const timers = new Set<ReturnType<typeof setTimeout>>()
      onCleanup(() => timers.forEach(clearTimeout))
    
      const dismiss = (id: number) => setToasts((all) => all.filter((toast) => toast.id !== id))
      const show = (kind: Toast["kind"], text: string) => {
        const id = nextId++
        setToasts((all) => [...all, { id, kind, text }].slice(-AT_MOST))
        const timer = setTimeout(() => {
          timers.delete(timer)
          dismiss(id)
        }, STAYS_MS)
        timers.add(timer)
      }
    
      const buy = async (product: Product) => {
        try {
          await add(product.id, 1)
          show("added", `${product.name} is in your cart.`)
        } catch (error) {
          show("failed", `${product.name} wasn't added: ${messageOf(error)}`)
        }
      }
    
      return (
        <div class="toast-demo">
          <ul class="shelf">
            <For each={SHELF}>
              {(product) => {
                const photo = photoOf(product.id)
                return (
                  <li class="shelf-item">
                    <div class="photo">
                      <img src={photo.small} srcset={srcsetOf(photo)} sizes="160px" width={photo.width} height={photo.height} alt={product.name} />
                    </div>
                    <div class="line">
                      <span class="name">{product.name}</span>
                      <span class="price">{formatPrice(product.price)}</span>
                    </div>
                    <wardic.button class="button quiet" whileHover={{ y: -1 }} whileTap={{ scale: 0.96 }} onClick={() => void buy(product)}>
                      Add to cart
                    </wardic.button>
                  </li>
                )
              }}
            </For>
          </ul>
          <ol class="toasts" aria-live="polite" aria-label="Notifications">
            <AnimatePresence mode="popLayout">
              <For each={toasts()}>
                {(toast) => (
                  <wardic.li class="toast" data-kind={toast.kind} layout {...toastMotion}>
                    <span>{toast.text}</span>
                    <button class="icon-button" aria-label="Dismiss" onClick={() => dismiss(toast.id)}>
                      ✕
                    </button>
                  </wardic.li>
                )}
              </For>
            </AnimatePresence>
          </ol>
        </div>
      )
    }
    
    function messageOf(error: unknown): string {
      if (error instanceof Error) return error.message
      return String(error)
    }
    
    examples/toasts/Demo.tsx
    "use client"
    import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
    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 { useEffect, useRef, useState } from "react"
    import { addToCart } from "~/lib/store.ts"
    
    interface Toast {
      readonly id: number
      readonly kind: "added" | "failed"
      readonly text: string
    }
    
    // A toast rises in at the bottom of the stack and shrinks away as it goes. Each is a layout
    // element: when one goes, the presence pops it out of the stack (mode="popLayout"), and the others
    // slide into its place while it fades.
    const toastMotion = {
      initial: { opacity: 0, y: 24, scale: 0.96 },
      animate: { opacity: 1, y: 0, scale: 1 },
      exit: { opacity: 0, scale: 0.9, transition: { type: "tween", duration: 0.18 } },
      transition: { type: "spring", stiffness: 420, damping: 32 },
    } as const
    
    const SHELF = PRODUCTS.filter((product) => ["moon-vase", "celadon-bowl", "poppy-plate"].includes(product.id))
    
    /** How long a toast stays, and how many show at once: a fourth pushes the oldest out. */
    const STAYS_MS = 4000
    const AT_MOST = 3
    
    export default function Toasts() {
      const [toasts, setToasts] = useState<ReadonlyArray<Toast>>([])
      const nextId = useRef(0)
      const timers = useRef(new Set<ReturnType<typeof setTimeout>>())
      useEffect(() => {
        const all = timers.current
        return () => all.forEach(clearTimeout)
      }, [])
    
      const dismiss = (id: number) => setToasts((all) => all.filter((toast) => toast.id !== id))
      const show = (kind: Toast["kind"], text: string) => {
        const id = nextId.current++
        setToasts((all) => [...all, { id, kind, text }].slice(-AT_MOST))
        const timer = setTimeout(() => {
          timers.current.delete(timer)
          dismiss(id)
        }, STAYS_MS)
        timers.current.add(timer)
      }
    
      const buy = async (product: Product) => {
        try {
          // A server action: it refreshes the page, so the header's count reads the cart again.
          await addToCart(product.id, 1)
          show("added", `${product.name} is in your cart.`)
        } catch (error) {
          show("failed", `${product.name} wasn't added: ${messageOf(error)}`)
        }
      }
    
      return (
        <div className="toast-demo">
          <ul className="shelf">
            {SHELF.map((product) => {
              const photo = photoOf(product.id)
              return (
                <li key={product.id} className="shelf-item">
                  <div className="photo">
                    <img src={photo.small} srcSet={srcsetOf(photo)} sizes="160px" width={photo.width} height={photo.height} alt={product.name} />
                  </div>
                  <div className="line">
                    <span className="name">{product.name}</span>
                    <span className="price">{formatPrice(product.price)}</span>
                  </div>
                  <motion.button className="button quiet" whileHover={{ y: -1 }} whileTap={{ scale: 0.96 }} onClick={() => buy(product)}>
                    Add to cart
                  </motion.button>
                </li>
              )
            })}
          </ul>
          <ol className="toasts" aria-live="polite" aria-label="Notifications">
            <AnimatePresence mode="popLayout">
              {toasts.map((toast) => (
                <motion.li key={toast.id} className="toast" data-kind={toast.kind} layout {...toastMotion}>
                  <span>{toast.text}</span>
                  <button className="icon-button" aria-label="Dismiss" onClick={() => dismiss(toast.id)}>
                    ✕
                  </button>
                </motion.li>
              ))}
            </AnimatePresence>
          </ol>
        </div>
      )
    }
    
    function messageOf(error: unknown): string {
      if (error instanceof Error) return error.message
      return String(error)
    }
    
    Run Motion's version →