Skip to content

Jixoai UI v0.5: the spin component and loader packs

jixoai v0.5.1 ui

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.

dots
arc
line
blocks-wave
tail-spin
the spinner prop's two resolution lanes: dots / arc / line are text-catalog names, blocks-wave / tail-spin svg artifact names. Rendered by the live component, not a screenshot

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.

dots
dots
dots
growVertical
dots under interval 160 with linger 0 / 160 / 480; growVertical carries its tuned pair 120/120. Rendered by the live component

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 needs steps(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.

dots
dots2
line
pipe
simpleDots
arc
arrow
bounce
growVertical
toggle3
star
one representative per catalog family. Rendered by the live component

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 when document.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.

blocks-wave
3-dots-bounce
bars-scale
clock
tail-spin
spinning-circles
the six loaders of the svg artifact set, SMIL running live. Rendered by the live component

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, #fffcurrentColor).

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.

24 of the magecdn pack's 94 loaders (every fourth), currentColor painting

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-icon are 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