Spinner
One icon that turns, and says so.
src/components/rahti_ui/spinner.rs
The spinner
Four lines: an icon, two utilities and two attributes.
shadcn's own demo. The whole component is `size-4 animate-spin` on lucide's loader circle, plus the two attributes that make it a status rather than decoration: `role="status"` and `aria-label="Loading"`.
It is an icon, so this port is a thin wrapper over the icon set rather than a hand-written `<svg>` — and naming it is what takes the icon's decorative `aria-hidden="true"` off, which the icon crate does on its own as soon as anything gives an icon a name.
Sizes
The one prop, and what it costs.
shadcn's size demo: `size-3`, the default `size-4`, `size-6`, `size-8`. Every one of them contends with the component's own `size-4`, which makes this one of the few components here whose override normally has to be *merged* rather than joined — a plain join would leave both sizes in the class list and let the stylesheet's order decide, which would make `size-3` lose and `size-8` win.
The merge carries only the component's own `size-4 animate-spin` and the caller's class — not lucide's `lucide lucide-loader-circle`. Measured in the served HTML: a `class` handed to an icon is *appended* to the icon's own, and the runtime substitutes the `{…}` where it stands, so lucide's markers survive on their own. Putting them in the merge as well writes them twice.
In a button
Where a spinner earns its own size class.
shadcn's button demo. This is the arrangement the size rule exists for: a Button sizes a bare icon with `[&_svg:not([class*='size-'])]:size-4`, and a spinner already has a size class — so it is exempt from that selector and keeps whatever it asked for.
The buttons are `disabled`, which is shadcn's and is the honest state for a control that is busy: it stops a second click, and `disabled:opacity-50` already says it visually. The spinner is announced as `status`, so a screen reader learns something is happening even though the button's own label has not changed.
In a badge
And the one component that overrides a spinner's size.
shadcn's badge demo, and the exception to the rule above. A Badge sizes its icons with `[&>svg]:size-3!` — important on purpose — so a badge spinner is 12px whatever it asked for. That is the badge overriding the spinner rather than the other way round, and it is deliberate on shadcn's side: a 16px glyph is too big for a 20px pill.
`badge_icon(IconSide::Start)` is the same `data-icon` attribute any badge icon takes, and the badge trims its left padding for it. A spinner is just an icon that moves — nothing about it is special to the badge.
In an input
A field that is waiting on something.
shadcn's input-group demo. The addon lays the spinner out like any other child — nothing is passed between them, and the field being `disabled` is what says the form is busy.
The second one has text beside the spinner that already says what is happening, so the spinner's own `aria-label="Loading"` is a second announcement of the same thing. See the next section.
In an empty state
The demo this page was missing until the Empty was built.
shadcn's seventh demo. It was the one example missing from this page when the Spinner was ported, because the Empty did not exist yet — this is it restored.
The Empty's icon media variant carries `[&_svg:not([class*='size-'])]:size-4`, and a spinner already has a size class — so once again it keeps its own. That is the third component on this page whose sizing a spinner is exempt from, and the Badge is still the only one that overrides it.
Saying what it is waiting for
The label, and when to take it off.
shadcn hardcodes `aria-label="Loading"`. A `label` prop is the one thing this port adds, because a page that is waiting on something specific can say so — and it costs nothing when it is not used.
The third one is the case worth knowing. A spinner beside the words "Validating..." announces the same fact twice, so unsetting `role` and `aria-label` puts the icon back to decoration — which is what the icon crate does by default for any icon nobody named.
The port
One attribute, one added prop, and one demo that has to wait.
Five components in a row where the published registry and the published page disagree. This one is the mildest — a single attribute — but it is the same lesson: diff the two rather than trusting either.