Jixoai UI v0.5: the spin component and loader packs
jixoai-ui v0.5.0 adds the spin component: <Spin spinner> resolves the generated svg artifact first (blocks-wave default, 94+12 loaders behind the plugin's ./spinners sub-entries), then the 59-name cli-spinners text catalog. The text lane is a flat CSS-animation engine — every frame rendered once, JS only fills animation parameters; lingerType end/start/both shapes the fade, and each catalog name carries a hand-tuned interval/linger pair. The plugin side aligns with the icon system through the full channel API — defineSpinnerChannel and the prefix:name namespace (0.5.1).
Ce billet est rédigé en anglais. Lire en chinois →
$ git log v0.4.0..v0.5.1 --oneline | wc -l
41
jixoai-ui v0.5 (2026-09-13, currently 0.5.1) contains 41 commits: 7 feat, 14 fix, 14 docs, the rest test, chore, and merges. The main changes are the spin component and the plugin-side spinners feature (with the full channel API landing in 0.5.1). No breaking changes; upgrading requires no migration.
The spin component
New <Spin>. The loading indicator takes a name as its parameter, after ora: plain text output, no [ ] wrapping. The spinner prop resolves through two lanes — svg artifact names first, text catalog names second:
<!-- svg lane: compile-time artifact names, blocks-wave the default -->
<Spin spinner="blocks-wave" />
<!-- text lane: the 59-name cli-spinners catalog, frames verbatim from cli-spinners@2.9.2 -->
<Spin spinner="dots" />
Names are a compile-time union; an unknown name fails type checking, and at runtime produces a cached-once warning. Sizing introduces no units of its own: the text lane uses var(--jx-text), the svg lane var(--jx-icon) — density-ruler variables, the same values jixoai.com's site typography uses.
Accessibility is built in: role="status" (with the implicit aria-live semantics), aria-busy in the wrapping posture, and a static frame under prefers-reduced-motion — no caller configuration.
The text lane: flat CSS animation
All frames render once (a one-cell grid + white-space: pre, so frame switches cause no layout change); JS only writes the animation parameters: one injected @keyframes rule per parameter set, one negative animation-delay per frame phasing it into its slot.
Consequences: frame switches cause no DOM mutations; the animation runs on the compositor, so main-thread jank does not affect the beat; DevTools' Animations panel can inspect every slot and linger interval frame by frame. prefers-reduced-motion is a static media kill, not a runtime branch.
linger and lingerType
The fade parameter is linger: the previous frame stays visible for linger milliseconds after the handoff, fading linearly. Type number | 'auto'. lingerType controls the fade's shape:
end(default): hold the full slot, then a linear fade-out;start: the fade-in completes before the handoff boundary — fully displayed when the next frame takes over;both: fades on both ends.
'auto' values are hand-tuned per name in the curator script: dots 80/160, dots2 120/0, pipe 120/120, line 160/0, simpleDots 160/160 (the · glyph). interval and linger accept explicit values, with priority: explicit value > Defaults slot > the catalog's tuned pair.
Two issues fixed along the way:
Same-percentage keyframe stops merge: in
10%{opacity:1}10%{opacity:0}the latter swallows the former, degrading the whole span to a linear transition; a discrete hide needssteps(1,start)on the stop.The solid-frame constraint: when linger ≥ interval, the entry length must cap at interval/2, or opacity peaks at a single instant.
The svg lane: SMIL
The svg lane runs the artwork's own SMIL. The component handles three things:
per-instance id namespacing: with the same loader rendered twice,
begin="x.end"-style syncbase references never resolve across instances;a
{#key}full rebuild on switch: replacing innerHTML inside a persistent svg element activates the animation in Chrome 1 time out of 12;dynamic-insertion activation: stylesheet text reprocessing +
setCurrentTime(0), executed only whendocument.readyState === 'complete', so hydration never resets a running animation.
All 94 magecdn loaders were pixel-verified through the component path: 83 passed directly, 11 depend on the activation handling; 15 context-dependent dynamic loaders are excluded with receipts. Colors normalize to currentColor, following the text color — no per-theme configuration. The demos in this post are the live component; switching the site theme verifies the dark rendering directly.
vite-plugin: the spinners feature
@jixoai/ui-vite-plugin adds the spinners feature (0.4.0; the channel API completed it in 0.5.1), mirroring the icon library's interface: jixoai({ spinners }), off by default — code generation runs only when enabled; sources are inline or { file } references; the RAW safety gate rejects untrusted shapes; the artifact has a single writer — gen:spins generates, verify:spins gates, and a hand-edit fails CI.
The feature does not use svgo: convertShapeToPath rewrites <circle> into <path>, while SMIL <animate> targets elements by shape type — the animation targets are lost after the pass. Loader svgs pass through under RAW semantics, with normalization limited to an explicit checklist (strip xml declarations, #fff → currentColor).
Custom extensibility aligns with the icon system through the full channel API (0.5.1). A channel is a first-class instance: defineSpinnerChannel({ id, prefix, spinners, peerPackage?, defaultsNote? }) — id grammar /^[a-z][a-z0-9-]*$/, prefix grammar /^[a-z][a-z0-9]*$/; shape + grammar validate at the factory, set-level uniqueness at config normalization (one entry per id, ONE channel per prefix — two channels sharing a myco: namespace fail at startup by name). A channel's entries join the artifact as prefix:name keys (myco:pulse, magecdn:clock, sam:tail-spin). Merge order: built-in manifest → channels in registration order → the flat record; a same full name overrides, last-writer (the icons override law).
Sources remain inline svg strings or { file } references; the full-name grammar widens to flat (/^[a-z0-9][a-z0-9-]*$/, digit-leading legal) or prefix:name, so the flat record can also override a channel entry with an explicit myco:pulse key. The generated SpinName union closes with the artifact — a typo is a compile error. Zero component changes: namespaced names are ordinary union strings.
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 defineConfig({
plugins: [
jixoai({
spinners: {
channels: [
// your own channel: entries join as myco:…
defineSpinnerChannel({
id: 'myco',
prefix: 'myco',
spinners: {
cadence: '<svg …>…</svg>', // inline literal
'wave-loader': { file: './src/loaders/wave.svg' }, // {file} reference
},
}),
// a pack as a channel: pick is a filter (2 of the 94)
magecdn({ pick: ['clock', 'bars-scale'] }),
// SamHerbert's 12 under the sam: prefix
sam(),
],
spinners: {
'flat-loader': { file: './src/loaders/flat.svg' }, // the flat lane unchanged
'myco:cadence': '<svg …>…</svg>', // same FULL name overrides the channel entry
},
},
}),
],
});
// regenerate the artifact: npm run gen:spins
// <Spin spinner="myco:cadence" />, <Spin spinner="magecdn:clock" />,
// <Spin spinner="sam:tail-spin" /> all join the type union
Two packs ship as sub-entries: @jixoai/ui-vite-plugin/spinners/magecdn (94 loaders, each verified) and @jixoai/ui-vite-plugin/spinners/svg-loaders (SamHerbert's 12, MIT, upstream credit). Each sub-entry offers both wiring forms — the spread lane (magecdnSpinners Record, spread by name, same-name override by spread order; unchanged since 0.4.0) and the channel factory (magecdn({ pick? }) / sam({ pick? })). The docs:cadence loader on the docs site's spin page is a live example wired through the channel lane.

Other changes
Icon baseline: a0a512e9 added gripVertical as the 39th built-in icon without updating the frozen expectations, leaving 14 test failures on main. This release re-records every affected expectation; the plugin suite went fully green for the first time (489/489 at 0.5.0; 510/510 at 0.5.1 with the channel battery); verify:all passes.
Playground controls: new PlayTiming (an auto | custom segmented control + gated number inputs); PlaySegmented gained a
bind:channel; PlayNumber switched to event-driven commits, fixing typed values being reverted by a stale resync (typing 32 reverted to 16).Density ruler:
--jx-text/--jx-iconare now the source of spin's default sizes.The remaining 12 fix commits are fixes from the walkthrough rounds.
Upgrading
No breaking changes:
npx jixoai-ui@latest add spin
Existing projects: npx jixoai-ui@latest upgrade.
Links
Changelog: GitHub Release v0.5.1 · compare v0.4.0...v0.5.1
Docs & registry: ui.jixoai.com · the spin docs page · README
The contract source: the spin-ora-svg-lane proposal
Feedback: GitHub Issues
This site's series: Jixoai UI v0.4.0 · Jixoai UI v0.3.0
