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>}

Split button#

Keep the main action visible and put its alternatives in the attached menu. The two segments stay independent: clicking the label runs the primary action, while the caret opens the menu.

Choose an action

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.

Kept for compatibility; controls no longer scale on press.

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. At the other end, xs (28px) is the dense-UI tier, covered below.

Extra small, for dense UI

xs is 28px tall: a table row's actions, the close on a floating card, a toolbar packed into a panel. The label drops to text-xs and a glyph to 14px so both keep their proportion to the smaller box, and with iconOnly it is a 28 by 28 square. The visual shrinks, the target does not: an invisible pseudo-element keeps the hit area at 40px (44px on touch), the same extender sm uses. Like xl it is button-only; a field under a container that imposes it resolves it to sm. It works with every variant and tone, and on a ButtonGroup.

Unit 4-Adam · Patrol

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 xs and 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.

Its own tooltip

An iconOnly button shows its aria-label (or tooltip) as a hint on hover and keyboard focus. tooltip={false} turns it off. Wrap the button in your own Tooltip, directly or through an asChild trigger like PopoverTrigger, and the built-in hint stands down: one bubble, the one you wrote.

<Tooltip content={<>Search <Kbd size="sm">⌘K</Kbd></>}>  <Button iconOnly aria-label="Search">    <MagnifyingGlass />  </Button></Tooltip>

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.

API reference#

Button

A single <button> (or its child with asChild). Forwards all native <button> props; accepts className, merged last.

PropTypeDefaultDescription
variant"primary" | "secondary" | "neutral" | "outline" | "ghost" | "overlay" | "destructive" | "destructiveGhost" | "link""primary"The fill. primary is the brand action; destructive is a soft red tint, destructiveGhost its chrome-less twin.
size"xs" | "sm" | "md" | "lg" | "xl""md"28, 32, 36, 40 or 44px tall (xs is the dense-UI tier: row actions, a floating card's close). Unset, it follows density, then the container's tier, then md.
density"comfortable" | "compact"-Picks the default size only (compact is sm, comfortable is md). A Button ignores an ambient DensityProvider.
iconOnlybooleanfalseA square that tracks the size, auto-wrapped in a Tooltip from aria-label. Warns in development without an accessible name.
tone"default" | "onMedia" | "onInverse""default"Re-points fills and focus ring for a brand band or photo (onMedia, fixed white) or a bg-foreground band (onInverse).
loadingbooleanfalseA centered spinner, aria-busy and disabled, with the label's width kept. Ignored with asChild.
swapKeyReact.Key-Identifies the label on screen; change it with the children and the label rolls (AnimatedLabel). Ignored with asChild or while loading.
tooltipReact.ReactNode | false-Tooltip content for an iconOnly button. Defaults to aria-label; false opts out.
tooltipPlacementTooltipProps["placement"]"top"Where that tooltip sits.
staticbooleanfalseKept for compatibility; controls no longer scale on press.
asChildbooleanfalseRenders its single child as the button (Radix Slot), e.g. a Next.js <Link>.
Data attributeDescription
data-icon-onlyPresent on an iconOnly button.
data-loadingPresent while loading.

SocialButton

An OAuth "Continue with" button with a real brand logo. Wraps Button, so every Button prop applies except variant.

PropTypeDefaultDescription
provider*"google" | "discord" | "github" | "apple" | "x" | "threads" | "microsoft" | "figma" | "facebook" | "linkedin" | "spotify" | "slack"-Picks the logo, the label and the brand color.
appearance"outline" | "solid""outline"A neutral outline with the full-color logo, or the brand's own fill with a white logo.
childrenReact.ReactNode"Continue with {label}"Replaces the label. Ignored when iconOnly.
iconOnlybooleanfalseJust the logo; aria-label defaults to "Continue with {label}".

SplitButton

A primary Button with a separate trailing DropdownMenu trigger. Button appearance props apply to both segments.

PropTypeDefaultDescription
children*React.ReactNode-The visible label for the primary action.
menu*React.ReactNode-Dropdown menu content, usually DropdownMenuItem and DropdownMenuSeparator.
menuLabelstring"More options"Accessible name for the menu trigger.
menuAlign"start" | "center" | "end""end"Alignment of the menu relative to its trigger.
menuClassNamestring-Classes for the menu content, such as a fixed width.
asChildbooleanfalseRender the primary action on its child, such as a Next.js Link.
variantButtonProps["variant"]"primary"Shared button appearance.
sizeButtonProps["size"]"md"Shared button size.
disabledbooleanfalseDisables both segments.

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.