Entrances

Solid 2Unstable

Stock that fills in as it arrives

Each piece's stock meter fills in once the store's count arrives, and says when it has settled. Switch to after the delivery and they fill up from where they are.

Live demo

Reduced motion: The bars are at their level at once, Wardic's reduced motion snapping their scale, and each label shows its count straight away.

Why

A meter that fills to its level says more than a number alone, and one that waits for the real count before it fills never shows a guess. Switch to after the next delivery and each bar grows from where it was, so you see what the delivery brings.

On Solid 2 the count is an async memo over the store's server function, and each bar's animate reads it. Under <Loading> the meters wait as a skeleton until the first count lands, then fill from initial. A later count keeps the bars where they are until it lands, with no flash of the skeleton, and isPending says the store is being checked. The label beside each bar reads the bar's ward: isPending(() => bar.settled()) holds while its animation runs ("filling…"), and the count shows once it has landed.

On Solid 1, createAsync reads the count under <Suspense>, a switch runs in a transition so the bars stay while the next count loads, and each bar says when it's done through onAnimationStart and onAnimationComplete, setting a signal. Solid 2 reads both from what it already has: the wait is the memo's, and "animating" is settled() pending. Try the header's delay to watch the wait.

The code

Motion has no async values. Its version reads the count as React does: the page's server component starts the read, and the bars' client component waits for it with use(promise) under <Suspense>. A switch is a transition, with a promise kept for each when, as use needs the same one each render. Each bar's "filling…" is state set from onAnimationStart and onAnimationComplete, as on Solid 1; Solid 2 reads both from what it already has, the memo's pending count and the ward's settled(). And Motion starts each fill once React has hydrated the rows, where Wardic starts it as they stream in, from the CSS it writes into them.

examples/stock/Demo.tsx
import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { ward, wardic } from "@wardic/solid/unstable/v2"
import { createMemo, createSignal, For, isPending, Loading } from "solid-js"
import { stock } from "~/lib/store.ts"

/** When the stock is counted: now, or after the next delivery. */
type When = "now" | "delivery"

/** A shelf holds eight of a piece: a full meter. */
const SHELF = 8

const PIECES = PRODUCTS.filter((product) => ["blossom-jar", "bottle-vase", "tulip-bowl", "lagoon-vase"].includes(product.id))

const FILL = { type: "spring", stiffness: 140, damping: 22 } as const

export default function Stock() {
  const [when, setWhen] = createSignal<When>("now")
  // Each piece's count, from the store: an async memo, so what reads it waits for it. Under
  // `<Loading>` the meters wait as a skeleton for the first count; a later one keeps the bars where
  // they are until it lands.
  const levels = createMemo(() => stock(when()))
  return (
    <div class="stock">
      <div class="stock-head">
        <div class="segmented" role="group" aria-label="When">
          <button aria-pressed={String(when() === "now") as "true" | "false"} onClick={() => setWhen("now")}>
            Now
          </button>
          <button aria-pressed={String(when() === "delivery") as "true" | "false"} onClick={() => setWhen("delivery")}>
            After the delivery
          </button>
        </div>
      </div>
      <Loading fallback={<Skeleton />}>
        <p class="stock-status muted" aria-live="polite">
          {isPending(() => levels()) ? "Checking the store…" : "The store's count"}
        </p>
        <ul class="stock-list">
          <For each={PIECES}>{(piece) => <StockRow piece={piece} left={() => levels()[piece.id] ?? 0} />}</For>
        </ul>
      </Loading>
    </div>
  )
}

function StockRow(props: { readonly piece: Product; readonly left: () => number }) {
  // The bar's ward only listens: its own props fill it.
  const bar = ward()
  // While its animation runs, its last landing is pending: "filling…", then the count once it lands.
  const label = () => (isPending(() => bar.settled()) ? "filling…" : `✓ ${props.left()} left`)
  return (
    <li class="stock-row">
      <img src={photoOf(props.piece.id).small} width="48" height="48" alt="" />
      <span class="name">{props.piece.name}</span>
      <span class="meter">
        <wardic.span class="meter-fill" wardRef={bar} initial={{ scaleX: 0 }} animate={{ scaleX: props.left() / SHELF }} transition={FILL} />
      </span>
      <span class="stock-label">{label()}</span>
    </li>
  )
}

function Skeleton() {
  return (
    <ul class="stock-list" aria-busy="true">
      <For each={PIECES}>
        {() => (
          <li class="stock-row">
            <span class="skeleton stock-skeleton-photo" />
            <span class="skeleton stock-skeleton-line" />
          </li>
        )}
      </For>
    </ul>
  )
}
examples/stock/Demo.tsx
// Stock that fills in as it arrives, a server component: it starts the first count and hands its
// promise to the client part.
import { getStock } from "~/lib/store.ts"
import { Stock } from "./Client.tsx"

export default function Demo() {
  return <Stock now={getStock("now")} />
}
examples/stock/Client.tsx
"use client"
import { PRODUCTS } from "@wardic/example-storefront-shared/catalog"
import { photoOf } from "@wardic/example-storefront-shared/images"
import type { Product } from "@wardic/example-storefront-shared/server"
import { motion } from "motion/react"
import { Suspense, use, useState, useTransition } from "react"
import { getStock } from "~/lib/store.ts"

/** When the stock is counted: now, or after the next delivery. */
type When = "now" | "delivery"
type Counts = Readonly<Record<string, number>>

/** A shelf holds eight of a piece: a full meter. */
const SHELF = 8

const PIECES = PRODUCTS.filter((product) => ["blossom-jar", "bottle-vase", "tulip-bowl", "lagoon-vase"].includes(product.id))

const FILL = { type: "spring", stiffness: 140, damping: 22 } as const

export function Stock({ now }: { readonly now: Promise<Counts> }) {
  const [when, setWhen] = useState<When>("now")
  // One count per `when`, kept: `use` needs the same promise each render. The server started "now".
  const [counts, setCounts] = useState<Partial<Record<When, Promise<Counts>>>>({ now })
  // A switch is a transition: the bars stay where they are until the new count has arrived.
  const [checking, startChecking] = useTransition()
  const choose = (next: When) =>
    startChecking(() => {
      setWhen(next)
      if (counts[next] === undefined) setCounts({ ...counts, [next]: getStock(next) })
    })
  return (
    <div className="stock">
      <div className="stock-head">
        <div className="segmented" role="group" aria-label="When">
          <button aria-pressed={when === "now"} onClick={() => choose("now")}>
            Now
          </button>
          <button aria-pressed={when === "delivery"} onClick={() => choose("delivery")}>
            After the delivery
          </button>
        </div>
      </div>
      {/* The first count waits as a skeleton. */}
      <Suspense fallback={<Skeleton />}>
        <Rows counts={counts[when] ?? now} checking={checking} />
      </Suspense>
    </div>
  )
}

function Rows({ counts, checking }: { readonly counts: Promise<Counts>; readonly checking: boolean }) {
  const levels = use(counts)
  return (
    <>
      <p className="stock-status muted" aria-live="polite">
        {checking ? "Checking the store…" : "The store's count"}
      </p>
      <ul className="stock-list">
        {PIECES.map((piece) => (
          <StockRow key={piece.id} piece={piece} left={levels[piece.id] ?? 0} />
        ))}
      </ul>
    </>
  )
}

function StockRow({ piece, left }: { readonly piece: Product; readonly left: number }) {
  // Motion tells an animation's start and end through callbacks: "filling…" between them, then the
  // count once it lands.
  const [filling, setFilling] = useState(true)
  return (
    <li className="stock-row">
      <img src={photoOf(piece.id).small} width="48" height="48" alt="" />
      <span className="name">{piece.name}</span>
      <span className="meter">
        <motion.span
          className="meter-fill"
          initial={{ scaleX: 0 }}
          animate={{ scaleX: left / SHELF }}
          transition={FILL}
          onAnimationStart={() => setFilling(true)}
          onAnimationComplete={() => setFilling(false)}
        />
      </span>
      <span className="stock-label">{filling ? "filling…" : `✓ ${left} left`}</span>
    </li>
  )
}

function Skeleton() {
  return (
    <ul className="stock-list" aria-busy="true">
      {PIECES.map((piece) => (
        <li key={piece.id} className="stock-row">
          <span className="skeleton stock-skeleton-photo" />
          <span className="skeleton stock-skeleton-line" />
        </li>
      ))}
    </ul>
  )
}
Run Motion's version →