registry:ui · Feedback
spin — a name, not a cursor
The loading indicator in ora's voice. You hand it a NAME: any of the 60 text spinners curated from cli-spinners (frames and intervals verbatim — dots, line, arc, bounce…), or a name from the generated svg artifact (blocks-wave by default, the vendored loader packs and your own files through the vite plugin). The union closes at build time, so a misspelled spinner is a compile error, never a shipped blank. role=status keeps loading polite by construction; under prefers-reduced-motion the text frames rest on frame 0 and the svg clock freezes — observed live, not once at mount.
$npx jixoai-ui add spin
https://ui.jixoai.com/r/spin.json
overview
Overview
A name, two corpora, one flat CSS engine: the union closes at build time, the frames render once each, and reduced motion is two named measured channels — never an assumption.
You hand it a NAME, not a cursor. The name lane crosses two corpora: the curated cli-spinners text catalog (60 names, frames
and per-spinner intervals verbatim) and the generated svg artifact (blocks-wave plus
the vendored packs — your own loaders through the vite plugin). The artifact lane
resolves FIRST, and the union closes at build time: a misspelled spinner is a
compile error, never a shipped blank. Both lanes announce through role=status — polite by construction.
The text lane is the FLAT CSS ENGINE: every frame renders ONCE as its own grid cell span, phased by a negative animation delay — no element churn, no JS clock; JS only fills the parameters (one shared keyframes rule per timing set). The trail is the opacity animation itself, the box never breathes (the widest frame's advance width holds the cell), and reduced motion is a static media kill landing on the base face: frame 0 alone, LIVE media semantics, zero JS. The svg lane keeps its OWN engine — the SMIL document inside the artwork — frozen through a live matchMedia listener that pauses the clock. Two named channels, measured, never conflated.
The eight axes are near-zero by measurement: density stamps a managed rung and size
is the family's §13 hybrid (numbers stamp the §1 carrier; the ABSENT state rides the
density ruler's var(--jx-icon)); shape/radius/color/elevation/motion supply unread —
the ink is pinned to the primary hue through the ROOT-PINNED ALIAS chain
(--jx-primary: var(--primary)), and the tiers are THREE (the task-74 pin-finder:
33 of 40 inks FLIP under root dark, the 7 holds are exactly the data-theme
demo stages): a scoped dark island cannot repaint it; unpinned root-level dark
FLIPS it (measured at the elements and by a body-level move); data-theme
stages locally pin. Kinship: skeleton (the fleet's other motion surface —
the pulse beside the spin), progress (determinate loading), toast (the completion announcement).
spin
One name, two corpora. The playground drives the lower instance across the name lane; the upper rows are the default text posture and the svg artifact at its size ladder.
dots · defaultblocks-wave · 24<Spin spinner="dots" size=16 interval=auto linger=auto lingerType="auto" /><script lang="ts">
import Spin from '@ui/spin';
</script>
<!-- default: the 'dots' text spinner under role=status -->
<Spin label="loading checks" />
<!-- any catalog name — the union is the list, a typo is a compile error -->
<Spin spinner="line" label="syncing" />
<Spin spinner="simpleDots" label="fetching" />
<!-- the svg lane: names from the generated artifact take size -->
<Spin spinner="blocks-wave" label="building" size={24} />
<!-- wrapping posture: children flip the component into a container -->
<Spin label="syncing">…content…</Spin>catalog
The text catalog — family representatives
Sixty names, frames and per-spinner intervals copied verbatim from cli-spinners (the corpus ora itself renders). One representative per visual family is shown live; the full union rides spin-catalog.ts — every member renders with the same prop.
dots 80/160dots2 120/0line 160/0pipe 120/120simpleDots 160/160·startarc 120/120·botharrow 120/0bounce 160/20growVertical 120/120·bothtoggle3 1000/500·bothstar 160/80timings
The linger trail — hand-tuned timing pairs
Every catalog name carries a HAND-TUNED interval/linger pair (the Owner's five: dots 80/160, dots2 120/0, pipe 120/120, line 160/0, simpleDots 160/160 — the rest family-curation; the gallery captions show each pair). Both props take number | 'auto' with 'auto' (the default) resolving the tuned pair; explicit numbers override, and both ride the family's one Defaults contract, so a context — or the plugin mounting one — can set them ambiently for every spinner at once.
growVertical · auto (120/120)dots · auto (80/160)dots · custom 160/480pong · linger 0 — no residuePure CSS, flat, DevTools-friendly. Every frame of the spinner renders ONCE in the SAME grid cell with white-space: pre — no element churn, no JS clock; JS only fills the
animation parameters (one shared keyframes rule per parameter set + a negative per-frame delay phasing
it into its slot), so the cycle is compositor-smooth, scrub/pause/replay-able in the DevTools Animations
panel, and freezes on frame 0 under prefers-reduced-motion through a
static media kill — zero JS in the reduced-motion path. The box never breathes: a slow blank frame
(simpleDots' three spaces) holds the widest frame's advance width.
svg lane
The svg artifact — blocks-wave and your own loaders
Names that resolve in the generated artifact render the svg posture: the component owns the whole <svg> root (viewBox from the data, the resolved square size, currentColor painting by artwork nature, aria-hidden), and the artwork's inner markup — SMIL animate elements included — crosses through the same RAW-gated sink the icon pipeline uses.
size · var(--jx-icon) (absent default)size · 24 (pinned)tail-spinspinning-circles3-dots-bouncebars-scaleclockdocs:cadence — a channel entryReduced motion, two named channels. SMIL-animated loaders freeze through root.pauseAnimations() driven by the component's live matchMedia('(prefers-reduced-motion: reduce)') listener — frozen means the first frame (blocks-wave's resting 3×3 grid), and
un-reducing restarts the clock. CSS-keyframed loaders ride the second channel:
spin.css's static animation: none kill scoped to [data-jx-spin-svg]. The channels are documented, never
conflated — a custom loader chooses its engine by how its artwork is written.
Independent instance timelines. SMIL
syncbase references resolve by element id ACROSS THE DOCUMENT — duplicate ids across
instances used to cross-wire (a freshly switched-to loader resolved its begin chains onto
another instance's long-past timeline and froze until a lucky re-insertion). Every
instance now namespaces its ids at render (id, .begin/.end refs, gradient url(#…)):
each syncbase graph is self-contained, unmount-safe, and independently phased — the
deterministic transform rides RAW-gated payload only; artifact bytes stay verbatim.
Native SMIL timelines. The svg lane's clock is the engine's own: SSR'd loaders animate from HTML parse (motion by ~0.6s, before hydration), swapped instances from insertion (~80ms) — the component never touches the clock, so hydration cannot snap a running loader back to its resting pose.
Custom svg spinners come from @jixoai/ui-vite-plugin's spinners feature: opt in with jixoai({ spinners }), name your svg files, and
the plugin regenerates the committed artifact — the shipped @jixoai/spin-set stays a plain inline module with zero
npm deps, so the DEFAULT artifact installs plugin-free either way.
// vite.config.ts — @jixoai/ui-vite-plugin's spinners feature
import { jixoai, defineSpinnerChannel } from '@jixoai/ui-vite-plugin';
import { magecdn } from '@jixoai/ui-vite-plugin/spinners/magecdn';
import { sam } from '@jixoai/ui-vite-plugin/spinners/svg-loaders';
export default {
plugins: [
jixoai({
// bare {} = the vendored blocks-wave only. Custom loaders ride
// either lane — a CHANNEL (an id + prefix + its own record, the
// icon channel contract; entries join as prefix:name keys) or
// the flat record (kebab or prefix:name keys; explicit beats
// channels and built-ins, the icons override law)
spinners: {
channels: [
// your own channel — the public factory validates shape +
// grammar; one channel per prefix is enforced at config time
defineSpinnerChannel({
id: 'myco',
prefix: 'myco',
spinners: {
'cadence': '<svg …>…</svg>', // inline literal
'wave-loader': { file: './src/loaders/wave.svg' },
},
}),
// a pack as a channel — pick is a filter over the 94
magecdn({ pick: ['clock', 'bars-scale'] }),
// SamHerbert's 12 under the sam: prefix
sam(),
],
spinners: {
'flat-loader': { file: './src/loaders/flat.svg' },
'myco:cadence': '<svg …>…</svg>', // same FULL name overrides the channel entry
},
},
}),
],
};
// then regenerate the committed artifact (the repo's single writer):
// npm run gen:spins
// <Spin spinner="myco:cadence" />, <Spin spinner="magecdn:clock" />,
// <Spin spinner="sam:tail-spin" /> — all type-check and render; the
// SpinName union closes with the artifact, so a typo is a compile
// error exactly like the text lane.postures
Two postures — the law unchanged
Bare renders the inline glyph; wrapping children flips the component into an aria-busy container whose scrim OWNS pointer events — never a visual mask over live hit areas (loading and disabled are different states).
wrapped content — scrim owns pointers
usage
Usage
Import the component, hand it a spinner name — the label is the polite announcement, children flip the wrapping posture.
<script lang="ts">
import Spin from '@ui/spin';
</script>
<!-- default: the 'dots' text spinner under role=status -->
<Spin label="loading checks" />
<!-- any catalog name — the union is the list, a typo is a compile error -->
<Spin spinner="line" label="syncing" />
<Spin spinner="simpleDots" label="fetching" />
<!-- the svg lane: names from the generated artifact take size -->
<Spin spinner="blocks-wave" label="building" size={24} />
<!-- wrapping posture: children flip the component into a container -->
<Spin label="syncing">…content…</Spin>theming
Theming
One paint word for both lanes: currentColor in the primary hue — through the ROOT-PINNED alias (--jx-primary: var(--primary)), and the tiers are three: a scoped dark island cannot repaint it, unpinned root-level dark FLIPS it (33/40 census, the task-74 pin-finder), data-theme stages locally pin. The wrapping badge rides the popover tokens.
| Token | Default | Source |
|---|---|---|
| text-primary | frames + svg paint | color |
| interval | per spinner (80-400ms) | component |
| bg-popover | status pill fill | color |
| --scrim | wrap posture dim | color |
api
API
Three props, a snippet, and a class — the name lane is the whole vocabulary switch.
Properties
| Property | Type | Default | Description |
|---|---|---|---|
| spinner | SpinName | TextSpinnerName | 'dots' | The spinner's name — the generated svg artifact resolves FIRST, the text catalog second; an svg spinner named like a text one overrides it (explicit config beats built-ins, the icons law). The union closes at build time — a typo is a compile error. |
| label | string | 'loading' | Announced to assistive tech ("loading checks"). |
| interval | number | 'auto' | 'auto' | The frame step in ms — explicit prop > the Defaults slot (context/plugin injectable) > the spinner’s HAND-TUNED catalog pair. The svg lane ignores it (its clock is the SMIL document). |
| linger | number | 'auto' | 'auto' | The frame linger duration: each retiring frame stays in the cursor’s own grid cell fading out LINEARLY for this long. 'auto' = the spinner’s HAND-TUNED catalog pair (dots 160, line 0…); 0 = hide at the interval handoff. The trail depth = linger / interval. Context/plugin injectable like the interval; reduced motion is a static CSS kill. |
| lingerType | 'auto' | 'end' | 'start' | 'both' | 'auto' | The opacity animation mode: 'end' fades OUT after the duty window (the default trail); 'start' fades IN through the previous frame's window and COMPLETES exactly at the handoff — the frame arrives solid the instant the previous one exits (simpleDots); 'both' breathes — fade in, hold, fade out (arc, toggle3, growVertical). 'auto' (the default) resolves the spinner’s tuned type; linger 0 collapses every type to the discrete blink. Context/plugin injectable. |
| children | Snippet | — | Wrapping content = container posture with scrim + aria-busy. |
| class | string | '' | Lands on the root (the inline span, the svg, or the wrapping grid). |
Universal props
| Property | Type | Default | Description |
|---|---|---|---|
| size | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | the base scale — root font-size; parts size in em |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' · ambient scope | corner geometry (CSS corner-shape; §14 degrade table) |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | corner size; auto = the concentric broadcast (§3) |
| density | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' · ambient scope | spacing/leading scale over the kernel channels (§4) |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' · ambient scope | the hue axis of the fixed oklch primary system (§5) |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' · ambient scope | light/dark profile; system = the JS-mutable global (§6) |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' · ambient scope | official M3 levels over the surface ladder (§7) |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' · ambient scope | intensity across the motion kernels (§8) |
axes
The eight axes on spin
The ZERO-READER CLASS, measured: the family animates by its OWN engine (the flat CSS keyframes + the SMIL clock) and reads NONE of the eight carriers — density stamps a managed rung, size is the family's §13 hybrid (numbers stamp the §1 carrier; ABSENT rides the density ruler's var(--jx-icon)), and shape / radius / color / elevation / motion supply unread. The ink is pinned to the primary hue through the ROOT-PINNED alias — the separator find's shape #2 — frozen under scoped islands, FLIPPED by unpinned root dark, PINNED by data-theme stages (three tiers, the task-74 pin-finder). Reduced motion is two named channels, measured live.
| Property | Type | Default | Description |
|---|---|---|---|
| density | '2xs' | 'xs' | 'sm' | 'default' | 'lg' | 'auto' | number (+ the five legacy spellings) | 'auto' | MANAGED STAMP, ZERO-READER CLASS — the resolved rung is stamped on the root (data-density, measured 'sm' under density="small" on both postures) and the ambient scope channel keeps flowing, but NOTHING in the family reads it (grep receipt). THE LIVE CONSUMER is the RULER, not the axis: the absent-size svg edge rides var(--jx-icon), which the ambient density scope scales. Number unit: coefficient. |
| size | number | string (the family's own hybrid, §13) | absent → var(--jx-icon) | THE HYBRID OWN — a NUMBER is the universal size axis' number lane verbatim (px) and additionally stamps the §1 carrier for descendants; a string is any CSS length (the family's own lane); ABSENT is a STATE: the svg edge rides the density ruler's var(--jx-icon) through an inline style (attrs cannot carry var()). The text posture ignores the slot entirely (it paints var(--jx-text)). Zero size-effective READERS (grep receipt) — the svg's width/height attributes or the ruler inline style are the consumption. Number unit: px. |
| shape | 'round' | 'scoop' | 'bevel' | 'notch' | 'square' | 'squircle' | 'auto' | 'auto' | SUPPLY-ONLY — zero shape-channel readers (grep receipt: no corner-shape consumer in ui/spin/; a loader glyph has no corners of its own). Number unit: none. |
| radius | 'small' | 'medium' | 'large' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero radius-effective readers (grep receipt); the wrap posture pill uses the family hairline, not the radius axis. Number unit: px. |
| color | 'primary' | 'secondary' | 'error' | 'warn' | 'success' | 'info' | 'auto' | number | string | 'auto' | SUPPLY-ONLY — the ink is PINNED to the primary hue (glyph + cursor + inline all read --jx-primary); zero --jx-color-effective readers (grep receipt). The hue axis never reaches the loader — repainting means re-theming --primary, not spinning the color lane. Number unit: hue degrees. |
| theme | 'light' | 'dark' | 'system' | 'auto' | 'auto' | THE ROOT-PINNED ALIAS (the separator find's shape #2, measured) — the ink reads --jx-primary, a :root-declared alias (--jx-primary: var(--primary), one of 116 `: var(` chains in the token table) whose var() substitutes AT THE ROOT: a scoped .dark island re-derives inherited --primary but the ink HELDS the light value (measured frozen), while root-level html.dark re-derives it — measured flip for UNPINNED inks; data-theme demo stages locally pin (the three-tier model, task-74). class:dark stamps for composed descendants. No number lane. |
| elevation | 'level-1' | 'level0' | 'level1' | 'level2' | 'level3' | 'level4' | 'level5' | 'auto' | number | 'auto' | SUPPLY-ONLY — zero elevation-carrier readers (grep receipt); the wrap pill's shadow is the family's --jx-shadow token, not the axis. Number unit: dp. |
| motion | 'reduced' | 'subtle' | 'normal' | 'expressive' | 'auto' | number | 'auto' | THE FAMILY IS MOTION — and the axis stays SUPPLY-ONLY (zero motion-effective readers, grep receipt): the animation is the flat CSS engine's own (per-frame opacity keyframes, negative-delay phasing; pulse-in-pixels receipts: rAF-sampled opacity oscillates, transform/transition/background are computed-style NEGATIVES), and reduced motion is TWO NAMED CHANNELS measured live — the text frames' static CSS kill (animation: none → frame 0 face) and the svg lane's SMIL clock pause (root.pauseAnimations() on the matchMedia listener). Number unit: coefficient. |
Receipts: the flat engine (the growVertical seat: 10 frames mounted, ONE shared keyframes rule jx-spin-f10-i120-l120-both inside the single style[data-jx-spin-frames] element — 12 rules accumulated, the dots rule f10-i80-l160-end among them — with per-frame NEGATIVE delays −120ms…−1200ms over a 1200ms cycle), the pulse-in-pixels pair (rAF-sampled opacity of a mid frame oscillates the full range — 7/32 samples nonzero — while transform stays 'none', background transparent, and transition-duration 0s: opacity is the only animated channel), reduced motion measured LIVE on both channels (emulated reduce → frame animation-name 'none' with frame 0's opacity-1 face; the svg instance's animationsPaused() flips true and back), the theme split (the ink HELD the light primary under a scoped .dark island and re-derived at root dark unpinned: FLIPS at the elements — the earlier element-holds read was the stage pin, superseded by the task-74 census; measured), the density stamp (data-density="sm" on the seat panel) and the grep receipts (zero --jx-*-effective readers over ui/spin/) were measured on this page's served DOM (probe, task 41). LAW #19 id landscape: duplicate ids NONE page-wide; the keyed gallery mounts 11/11. The query() seat below rides the md viewport key (48rem) on the density lane's rung stamp.
<script lang="ts">
import Spin from '@ui/spin';
import { query } from '@lib/universal-props-query.svelte';
</script>
<!-- the md key is the registered VIEWPORT scale (48rem): below it the
small rung stamps, at 48rem+ large wins — data-density flips on
the element while nothing in the family reads it (the zero-reader
class): the stamp IS the receipt. size itself REJECTS query() —
it is the family's own number|string hybrid, not an axis seat. -->
<Spin spinner="blocks-wave" density={query({ md: 'large' }, 'small')} label="loading" />The md key is the registered VIEWPORT scale (48rem): below it the small rung stamps (data-density="sm"); at 48rem and wider large wins ("lg" at a 1280 viewport, measured) — the rung flips on the element while no family rule consumes it (the zero-reader class): the stamp is the receipt. The size prop itself is the family's own number|string hybrid and REJECTS query() — the lanes-vs-passthroughs boundary, typecheck-proven; the named/auto/query lanes are not adopted on the loader.
Spin · universal props
<!-- §13: the number lane verbatim -->
<Spin spinner="blocks-wave" size={24} label="loading" />
<Spin spinner="dots" label="loading" />a11y
Accessibility
role=status is polite by construction — loading is never an alert; the scrim stops pointers without disabling anything; both animated lanes go quiet under reduced motion.
ARIA
| Attribute | Value | Description |
|---|---|---|
| role | status | Polite live region — announced when the reader is idle, never assertive |
| aria-label | label prop | Announced to assistive tech ("loading", "loading checks"…) |
| aria-busy | "true" | On the wrapping container posture |
| aria-hidden | true | On the decorative frames and the svg artwork themselves |