flyingsalmon

Button

The action component. It triggers one action and shows that action's busyness itself. Its loading morph is the motion template every other component copies.

Loading is the only result it owns

A button shows that its action is running. It never shows that the action succeeded or failed — that result belongs to the app, shown where the user is already looking. There is no success tick and no error shake: the changed item, the form's result slot, or a persistent notice carries the outcome.

Variants

Six variants cover every action shape. default is the one primary action on a view. outline and secondary carry the actions beside it, ghost the ones inside a dense row. destructive stays soft — a red tint with red text, never a solid red fill, because a delete button should read as serious, not as an alarm. amber is a color and nothing more: it sets an action apart from the primary one without claiming a meaning the app has not given it. There is no link variant: a link is an <a>.

Hover moves the surface one step and keeps the label above AA. The three neutral variants wash to the same indigo tint, so a surface without a color of its own borrows the accent. Two hovers step the label with the surface, because the surface step alone would drop it under AA: destructive in both modes, and default in dark, where the fill deepens instead of paling out.

Light
Dark

Sizes

Six sizes. default at 36px and sm at 32px match the two input heights, so a field and its submit button line up in a row. icon and icon-sm are the square ones. field-icon at 28px and field-icon-sm at 24px are square buttons for inside a 36px or 32px field box: with pr-0.75 on the box, the button sits 4px inside its top, bottom, and right edges, its corners rounded to match the box’s. The four square sizes render no label, so give them an aria-label. There is no xs and no lg.

Light
Dark

Leading icon

The icon prop fills the leading slot. There is no trailing slot — a trailing icon reads as a menu or a link, and this component is neither. An icon-size button takes the same prop, or its child if you prefer that spelling. There is no asChild: a button is a <button>.

Light
Dark

Loading

loading puts the spinner in the leading slot. With an icon there, the spinner replaces it instantly — no cross-fade, because two icons dissolving into each other reads as a glitch. With no icon, the slot appears and the button widens around it on a spring. The label stays at full opacity the whole time, so the button never hides what it does. Icon sizes show the spinner alone.

Light
Dark

The width morph

Press the button to watch the slot open and close. The width animates through motion's layout prop with spring-bounce going in and the same spring coming back at --motion-base. Nothing around the button jumps, because the morph runs on transforms.

Light
Dark

Disabled

Disabled dims the button to 50% and takes no pointer events. Loading is not disabled. A disabled button leaves the tab order; a loading one must not, or a keyboard user loses their place mid-action.

Light
Dark

Inside a form

A button defaults to type="button", so a Cancel beside the submit does not post the form. The submit button says type="submit" explicitly. HTML's own default is the other way round and turns every button in an action row into a submit, silently. The same default protects a dialog footer or card footer rendered inside a form.

Focus and press

Tab to a button for the focus ring: 3px held off the button by a 2px gap in the page color, so it stays visible on the filled variants too. The ring takes the color of the button under it. default rings in its own indigo, destructive in red, and the three neutral variants in --ring, which is the same focus color the input uses. The ring appears on :focus-visible only, so a mouse click never leaves one behind. Hold the button for the press ring: the same color, a tighter 2px, gone the moment you let go. A loading button shows no press ring — there is nothing to press. The two field sizes draw neither ring: they sit inside a field box that already draws its own focus ring, where an offset ring would cross the box’s edge, so keyboard focus paints the same fill as hover instead.

Light
Dark

Motion

Hover color, the press ring, and the focus ring are CSS transitions at --motion-fast. The loading slot and the width are the one morph: motion's layout prop with spring-bounce at --motion-base. The spinner runs its own 800ms turn on CSS keyframes. That is all three engines in one component, which is why the rest of the system copies this file.

Accessibility

A loading button sets aria-busy and never the disabled attribute. It keeps its focus, stays in the tab order, and ignores clicks, Enter, and Space — including form submission. Its spinner is aria-hidden, so the wait is announced once through aria-busy and the label stays the accessible name. Every variant meets WCAG AA for text in both modes, hover states included.