Button

Triggers an action or event. The reference single-element component: one tv recipe, Radix Slot for asChild, semantic tokens only.

Installation#

# One-time setup (tokens + lib helpers)npx koalaui-cli@latest init# Add this component (its dependencies come along)npx koalaui-cli@latest add button

Usage#

usage.tsxTSX
import { Button } from "@/components/ui/button"export function Example() {  return <Button>Click me</Button>}

Playground#

Every knob here is a prop, because a Button is one element with nothing to compose. variant, size and iconOnly are read straight off the component’s own recipe, so they can never drift from it.

Playground
Props

How much weight the action carries.

Height comes from size alone, never from density.

Collapses to a square that tracks the height.

Spinner in, label out, width held.

Opt out of the press scale.

Parts

A leading glyph beside the label.

button.tsxTSX
<Button>Continue</Button>

Variants#

primary carries the brand and is the one action a screen leads with. neutral is the achromatic solid: an ink fill with the page ground as its label, black on the light themes and near-white on the dark ones. Reach for it when a strong action shares the screen with a brand CTA and must not compete with it ("See all components" under a brand "Buy now"), where secondary would be too quiet.

On a filled band#

A button sitting on a color band or over a photo has the wrong ground: the brand fill of a primary action disappears into a brand band, and accent hovers resolve against the page, not the band. tone says which ground it is on, and every variant answers for itself.

onMedia is a fixed white family, for a brand band, a photo, or a dark scrim. The lead action becomes a white chip and the second stays a white outline, which is how GOV.UK, Spectrum and Tailwind Plus handle a colored band. The chip's label is near-black ink, never the brand color: the band already carries the accent, and black on white clears AA on every accent. onInverse is its token-flipping twin, for a bg-foreground band: that band inverts with the theme, so the button inverts with it. Both also re-point the focus ring, which is otherwise an invisible brand halo on a brand fill - so reach for the prop rather than a className, which would fix the fill and leave the ring broken.

Over a picture#

tone="onMedia" trusts the ground to be dark or saturated. An action placed straight on a picture can't: a thumbnail's corner button may land on a white frame as easily as on a dark one, and a white ghost glyph vanishes there. variant="overlay" brings its own ground, a dark translucent chip with a faint white edge (the same recipe as Badge's overlay), so it reads over any frame. Its focus ring is white on a transparent offset, like the on-media tones.

Sizes#

Three control sizes on a 4px grid - sm (32px), md (36px, the default), and lg (40px). The label stays text-sm across them; only height, padding, and the icon gap step up - so a larger button reads as the same control with more presence, not bigger text. Then xl (44px), the marketing CTA tier: the one step where the label grows to 16px and a glyph to 20px, because beside a 60-72px display title a 14px label reads as fine print. It is button-only (no form wants a 44px field), and a hero's CTA row imposes it by default.

Density#

Height always comes from size, never from density. The default button is md (36px) everywhere: a compact dashboard under DensityProvider and a comfortable landing render the same unsized button, so the product has one default. A density prop on the button itself still picks its default (the two below: comfortable md, compact sm), and a container that imposes a tier (InputGroup, a data-table toolbar, Form, hero actions) keeps its buttons level with the fields beside them. An explicit size always wins; a pseudo-element holds sm at a hit target of ≥40px. See Density.

Icons#

Icons are composed as children, not props - the recipe sizes any svg to size-4 and a gap spaces it from the label. Put the icon before or after the text to place it left or right. A button carrying an icon trims its horizontal padding one step (has-[>svg]:px-*) - an icon reads lighter than a text edge, so this keeps it optically balanced. For an icon-only button set iconOnly - it collapses to a square at any size - and always pass an aria-label, since there is no visible text. Koala warns in development if an iconOnly button has no accessible name.

Left or right: a UX rule

Placement is not decorative. In left-to-right reading the eye lands on the start of the button and leaves at the end, so each side has a different job: a leading icon identifies the action, a trailing icon signals where it goes.

Leading - identifies the action

Read before the label, the icon says what kind of action this is and doubles as a scannable anchor. The default, and right for actions that operate on something: add, download, delete, search.

Trailing - signals what comes next

Sitting where the eye exits the label, a trailing icon should point onward rather than name the action. Reserve it for forward navigation, disclosure chevrons, and links that leave the page.

Don't - an icon on both sides

Flanking the label with two icons splits the focal point: the leading glyph names the action, the trailing one points elsewhere, and the button reads as two competing ideas. Pick the single side that matches the intent.

One nuance overrides the default: keep the icon on the side that matches its meaning. A back-arrow belongs on the left because it points the way the action travels, even though most icons lead. When unsure, lead with the icon.

Attached to the label, not the edge

The icon rides directly beside the label, and the pair centers together as one unit. This matters most on full-width buttons - form submits, social sign-in, mobile CTAs - where pinning the icon to the button's edge opens a gap that strands the mark away from the text it belongs to.

Attached - reads as one unit

The mark sits next to the label and the group centers together, so the icon stays glued to the action even when the button stretches full-width.

Don't - pinned to the edge

Anchoring the icon to the far edge while the label centers splits the two apart: a gap opens across the button and the mark looks stranded, disconnected from what it labels.

Social buttons#

SocialButton is a sign-in button carrying a real brand logo - the authentic marks, not icon-style approximations. It wraps Button, so every size, density, the press scale, loading, and the icon-only tooltip all carry over. Pass a provider and it supplies the logo and the default Continue with… label.

Solid

appearance="solid" fills the button with the provider's official color and flattens the logo to white - for a single dominant choice or a brand-forward row.

Icon only

Set iconOnly for a square logo button - the provider name becomes the aria-label and the hover tooltip. Every supported provider:

Disabled#

Loading#

loading shows a spinner and marks the button busy (aria-busy) while an action is in flight. The label is hidden but its space is reserved, so the button never reflows; it's also disabled so it can't be re-triggered. The spinner respects prefers-reduced-motion.

Label swap#

When a button's label changes with its state - Next → Done, Copy → Copied, Follow → Following - give it a swapKey that changes with it. The outgoing label lifts up and out while the new one rises from below, and the button eases between the two label widths instead of snapping to the new one. It respects prefers-reduced-motion, where the label simply swaps.

As child#

Use asChild to render the button styles on a different element - e.g. a Next.js Link - via Radix Slot, with no extra wrapper.

FAQ#

So the dangerous action is unmistakable without being the loudest thing on the screen. It paints the destructive hue at /10 behind the darker `-strong` label, the same tinted recipe as a destructive Badge or a negative Stat chip, so it reads as a dim maroon pill in the dark themes and a pale pink one on light. Both the rest and hover (/20) steps clear AA against the label in all four themes, and a `primary` CTA beside it still reads as the default choice. Use destructiveGhost for the chrome-less version, a remove-line trash in a list or cart, and a `className` if you genuinely need the solid fill.