Agent docs

Spinner / Skeleton / SkeletonGroup / Progress

Loading primitives, thin wrappers over daisyUI's loading and progress.

The kit's loading primitives, thin wrappers over daisyUI's own loading and progress elements.

The canon behind it. docs/design-language/04-loading-and-states.md §A1 (04-loading-and-states.md:15, the house rule: skeletons for content, spinners for processes) and §A5 (04-loading-and-states.md:27, stagger is bounded so a long list neither lockstep-pulses nor lags), plus docs/design-language/01-foundations.md §A4 (01-foundations.md:24, scales are short and shared, Spinner named among the components on the one xs..xl ladder).

Progress moves. A determinate Progress renders a div track with role="progressbar" (aria-valuemin/max/now) and a width-driven fill that slides to each new value over 450ms (a native <progress> value cannot be transitioned in Chromium); the indeterminate bar stays the native element for its stripe animation. Owner ruling doc 59 V3: the slide is the default, never a page's useEffect. Stilled under prefers-reduced-motion.

When to reach for it#

The house rule comes before any prop: skeletons are for content, spinners are for processes (04-loading-and-states.md §A1). If what's loading has a shape, a card, a row, a list, a form, reach for Skeleton or SkeletonGroup so the placeholder occupies the space the real content will. A spinner is for a transient process with no shape of its own: a background job running, a connection being established, a submit in flight, always paired with a short present-tense verb phrase rather than left bare. Both shipped Spinner call sites follow this: "Connecting {org} to Walmart" (ConnectLaunch.tsx:56-58) and "Your job is processing" (ImportParts.tsx:256-258), neither one standing in for a list or a card that just hasn't loaded yet. Reaching for a spinner to cover a data fetch that will resolve into a list, a table, or a card is the most common way this system gets misused; the canon records a real unmarked instance of exactly that, spinner-with-word mixed with cell skeletons on one page with neither choice stated as deliberate (04-loading-and-states.md §C).

Progress is neither of the above: a determinate or indeterminate bar for a quantity that has a known extent, a percentage, a step count, a byte count, not an indeterminate wait. Reach for it when you can say how far along something is; reach for Spinner when you can only say that it's happening.

Contract#

Spinner: size (daisyUI's own xs-xl scale) and color (the 8-way daisyUI semantic palette plus current, which inherits the surrounding text color rather than picking one of the eight). It is the shared dispatcher every loading prop in the kit renders through internally (spinner.tsx:24, e.g. RenderIcon's own loading branch); components should never build a one-off spinner.

Skeleton: variant is block (sized entirely via className), line (a text-height bar, the default shape), or circle (rounded-full, pair with a size-* className for an avatar-shaped placeholder). stagger (an index) and staggerMs (the per-index delay step, default 120) exist on Skeleton itself for a hand-placed cascade, but no shipped call site in either app sets them directly; every real staggered list goes through SkeletonGroup's own boolean stagger instead, the same divergence card.md already notes for Card's unrelated stagger prop, do not confuse the per-component knob with the group-level one.

SkeletonGroup: a section-level placeholder assembled from one of four presets, lines (stacked bars, default), media (avatar plus lines), card (image block plus lines), grid (tiles). lines controls bars per block for the lines/media/card presets; count and cols (2 | 3 | 4) control the grid preset's tile grid. stagger is true for the default step or a number to override it; the shipped pattern is boolean true everywhere it appears, never a numeric override. Real usage in either app only exercises the lines and grid presets (Admin.tsx:781-795, NewCatalog.tsx:153, FileDetail.tsx:81, 181, and roughly twenty more lines call sites across walmart's catalog review tabs); media and card have no shipped usage found.

Progress: value/max (default max=100) for a determinate bar; omit value for the indeterminate sliding bar, which has no shipped usage found in either app, every real usage sets a value. color is the 8-way SemanticColor (default primary, progress.tsx:38; not the daisyUI loading palette Spinner uses); real usage always derives it from a computed threshold rather than a fixed color, color={pct < 50 ? 'error' : pct <= 80 ? 'warning' : 'success'} (UnlistedParts.tsx:157). size is xs | sm | md, default md; every shipped call site overrides it down to sm or xs, no shipped usage of the default md. label renders a right-aligned string; true would render the computed percentage automatically, but no shipped call site uses true. Only one shipped call site sets an explicit "n/m" string label (UnlistedParts.tsx:156); the other three omit label and let the row's own numbers carry that information instead. A comment on one of them, "the same warn/over voice the numbers speak" (UsageTable.tsx:86-87), explains why the bar's color matches the row's other value-derived colors, a related but separate choice from the missing label. skeleton renders a bar-shaped placeholder; no shipped usage found.

Sanctioned combinations#

CombinationProducesWhere usedWhy
Spinner paired with a present-tense verb phrase, never bareA process indicator that says what is happening, not just that something isConnectLaunch.tsx:56-58, ImportParts.tsx:256-258Canon §A1: a spinner is legitimate only "paired with a short verb phrase"
SkeletonGroup preset="lines" stagger inside a panel that is about to hold real prose-shaped contentA section placeholder that shimmers in as a cascade rather than popping in at onceThe dominant pattern across walmart's catalog review tabs (ReviewTab.tsx:458, Review2Tab.tsx:65, MappingTab.tsx:116, and roughly fifteen siblings)Canon §A5: stagger is bounded so a long list "neither pulse in lockstep nor wait seconds for their tail"
Progress color computed from the same numeric threshold that also picks the adjacent text's toneOne color rule read once, applied to both the bar and the number beside itUnlistedParts.tsx:153-159 (though see the recorded regression below)Keeps the bar and its label from disagreeing about how urgent the number is
Progress size="sm" or "xs" inside a table cell or a compact row, never the md defaultA progress indicator that fits row height instead of dominating itUsageTable.tsx:88-93, UsageOverview.tsx:83-87, Home.tsx:332-337Every shipped placement is inside a dense row or a narrow column; md has no shipped call site

Banned combinations#

Do not use Spinner in place of a Skeleton/SkeletonGroup for content that has a known shape (a table's rows, a card's fields, a list). This is the single most-recorded misuse in the canon: "content placeholders are skeletons, not spinners with a word" (04-loading-and-states.md §A1), and a real unmarked instance of the opposite already shipped on one page (04-loading-and-states.md §C).

Do not blank text that is already known ahead of the data. A skeleton is for genuinely unknown content only; static labels, and a title derivable from the URL, should render immediately rather than bone under a Skeleton "for no reason" (04-loading-and-states.md §A3).

Do not drive Progress's adjacent color-coded text from a hardcoded or dynamically-interpolated Tailwind class instead of the same prop-driven color the bar itself uses. A recorded regression did exactly this: a percentage label built its className as `text-${tier}`, which Tailwind's static scanner never emits since it only sees the literal template string, so the number silently rendered in inherited color while the adjacent <Progress color={tier}> (a prop, not a template literal) kept working correctly (walmart-mvp/docs/validation/skeptical-review-r1.md, review finding 3, citing UnlistedParts.tsx:49). Drive both the bar and any adjacent text-color from the same prop-driven lookup, never a template-interpolated class.

Before you adopt this#

Five questions to answer before reaching for Spinner/Skeleton/SkeletonGroup/Progress.

  1. Does the shell, a parent layout, or a global provider already render this? Not applicable, these are content primitives; no shell already renders them.
  2. Does this app already ship a local implementation of the same thing? Not applicable, no local Spinner/Skeleton clone is documented in either app.
  3. Does this app's kit pin reach the version this component or prop landed in? Not applicable, no version-landed prop is noted in this doc.
  4. Does the component derive its own accessible name and keyboard path, or must the call site supply them? Not applicable, these primitives carry no interactive control to name.
  5. Which canon §A rules bind this surface, and which does the composition break? §A1 keeps skeletons for content and spinners for processes, paired with a verb phrase.

Travels with#

RenderIcon's own loading branch renders through Spinner's visual shape internally; see icon.md.

Card's skeleton mode is the one sanctioned instance of a whole framed surface going into placeholder state at once; SkeletonGroup is for placeholdering a section inside an already-real surface. See card.md.

PageInfo/PageLoading (not documented here) is the one sanctioned full-page spinner, for a route-level transient state; a page reaching for a bare Spinner at the top level should usually be reaching for that instead.

Snippet#

// walmart-mvp/frontend/src/pages/UnlistedParts.tsx:148-160
if (!r.required_fields_total) {
return <span className="text-base-content/40 text-sm">n/a</span>;
}
const pct = completionPct(r);
return (
<Progress
value={r.required_fields_complete}
max={r.required_fields_total}
label={`${r.required_fields_complete}/${r.required_fields_total}`}
color={pct < 50 ? 'error' : pct <= 80 ? 'warning' : 'success'}
size="sm"
/>
);
@versable-git/ui · reference, canon, and method, read in place