Garage

Button starts an action when pressed.

variant

solid | soft | ghost | outline

size

micro | tiny | small | medium | large

color

accent | blue | red | amber | black

shape

sharp | square | round

loading

boolean

feedback

success | error

disabled

boolean

fullWidth

boolean

<Button>Compose</Button>

Component Design

Most Button APIs give you two knobs: variant and size. The main action on a page usually ends up as variant="primary":

That holds up until the primary button is too loud for where it sits. It should still read as the main action, but the variant has already chosen the colour for you.

So you reach for variant="secondary", which in most libraries means grey and quiet:

This Button splits the decision into three props: variant, color and size.

Variant only sets the appearance, never the colour. So one primary colour can show up four ways, each suited to a different kind of interface.

With five colours, accent, red, blue, amber and black, the combinations add up quickly.

Feature Showcase

Loading slides in instead of swapping the label out

The spinner animates margin-left on an inner element, so the button's width follows along with no ResizeObserver

-2072

Prefix and suffix slots, with room for pieces like Kbd and Tooltip

Swap the rendered element with the render prop

<Button render={<Link href="/" />}>
  Home
</Button>

On light surfaces Button darkens when pressed. On dark surfaces and inverted variants it lightens instead.

Every variant has its own sound. It stays off on touch devices, where a UI sound can pause whatever music is already playing.

Layouts

The same Button has to work in places other than a lone call to action. These are the layouts it gets dropped into most often.

Icon-only buttons drop the visible label, but never the name. The aria-labelis what a screen reader says instead, and in development the button warns in the console when it's missing.

what a screen reader hears
Hover or tab through them

Groups join neighbouring buttons into one shape. Inner corners go square, borders overlap by a pixel so they don't double up, and the arrow keys move along the group like a toolbar.

This week
Tab into the group, then use the arrow keys

A split button pairs the main action with a menu of related ones. The trigger has its own label and aria-expanded, the menu takes focus when it opens, and Escape hands focus back.

On a phone, the main action stretches to the full width so it's easy to reach with a thumb. fullWidth does that; from sm up you can override with a width class.

Full width on a phone, natural width from sm up
<Button fullWidth className="sm:w-auto">
  Continue
</Button>

Force Press

Safari on macOS fires a non-standard webkitmouseforcedown event when a trackpad click pushes past a pressure threshold.

When it fires, the button sinks one step further, from 0.97 to 0.94.

1.00
Force press works in Safari on macOS

It needs a pressure-sensitive Apple trackpad, so everyone else simply gets the normal press.

Dynamic Spinner

Instead of one fixed loop, the spinner picks a new arc length and speed after every turn, so it never looks like a GIF on repeat.

After the Press

Loading covers the wait. It doesn't say what happened when the wait ended. feedback="success" or feedback="error" paints the button, swaps the spinner for a check or a cross, and announces the outcome. Clear it when the next press should be allowed.

Press Save or Fail — loading, then the outcome

Pair it with loading for the full trip: press → wait → done. feedbackLabel sets what a screen reader hears, the same way loadingLabel does for the wait.

Disabled States

Buttons have two states that often get bundled together: loading and disabled. Plenty of libraries switch both on while a request is in flight.

goes grey while loading

I keep them apart. A loading button should stop responding to clicks, but it doesn't need to look disabled.

Holding on to the colour avoids a jarring flash from blue to grey. The only change is the not-allowed cursor.

keeps its colour while loading

A spinner isn't enough for keyboard and screen reader users, so the button gives them a stronger signal: it keeps focus, reports itself as busy, and says what it's doing out loud. More on that in Accessibility.

Accessibility

A press should feel the same whether it comes from a trackpad, a keyboard or a screen reader. Most of the work here is making sure nothing important only happens for the mouse.

Holding Space or Entersinks the button exactly like a click does. Key repeats are ignored, so holding a key down doesn't fire the sound over and over.

Tab to the button, then hold Space or Enter

Loading and disabled use aria-busy and aria-disabled instead of the native disabledattribute. The button stays in the tab order, so focus doesn't get thrown back to the top of the page the moment a request starts.

The spinner and feedback icons are decorative, so a polite live region says what's happening instead. The wording is a prop: loadingLabel="Sending", and the same idea for feedbackLabel after the press.

what a screen reader hears
Press it and watch what gets announced

In Windows high contrast mode, every variant falls back to a system border, so ghost buttons don't vanish when the background colours are stripped out.

The smaller sizes are easy to miss with a thumb, so every button has an invisible tap area of at least 44px. It sits outside the visible edge, so a 26px button still looks like a 26px button.

Reduced Motion

Some people switch animations off at the system level because movement makes them feel unwell, or just gets in the way. The button reads prefers-reduced-motion and calms itself down to match.

The press stops scaling and only the colour changes. The spinner keeps a steady pace instead of picking a new rhythm every turn, the loading slot fades in instead of sliding, and the sounds stay off.

Default
Reduced motion

The reducedMotion prop overrides the system setting, which is how this demo shows both versions side by side.

Touch

The press was tuned on a trackpad, where the pointer sits beside the button and you can watch it sink. On a phone your thumb covers the button, and a quick tap is often over before a single frame of the press is visible.

So touch presses go one step deeper, to 0.95, and hold for at least 110ms even when the tap is shorter. The release is the part you actually see as your thumb lifts, so it needs something to come back from.

Mouse0.97 · 60ms tap · click sound
Touch0.95 · held 110ms · vibrate(8)

If the press turns into a scroll, the browser fires pointercanceland the button lets go straight away, so swiping past a column of buttons doesn't flash every one of them.

Sound stays off on phones, but on Android a short navigator.vibrate(8)takes its place. iOS doesn't expose vibration to the web, so iPhones get the visual press on its own.

touch-action: manipulation removes the double-tap-to-zoom delay, and the grey tap highlight is switched off because the button already has a pressed state of its own.

Props

Everything the Buttonaccepts. Every snippet on this page has a copy button, and the playground at the top writes out the code for whatever you've set. The full source is right below.

PropTypeDefaultDescription
variantsolid | soft | outline | ghostsolidAppearance only. Never changes the colour.
coloraccent | blue | red | amber | blackaccentHue for every variant.
sizemicro | tiny | small | medium | largemediumHeight from 26px to 40px.
shapesharp | square | roundsquareSharp is 0 radius. Round makes it a pill.
themedark | lightdarkSurface it sits on. Light surfaces darken on press.
loadingbooleanfalseShows the spinner and blocks clicks without greying out.
loadingLabelstringLoadingAnnounced to screen readers when loading starts.
feedbacksuccess | error | nullnullPost-press check or cross. Holds the button until you clear it.
feedbackLabelstring–Announced when feedback flips on.
disabledbooleanfalseGreys out and blocks clicks. Stays in the tab order.
disabledReasonstring–Explains a disabled button via title and aria-describedby.
fullWidthbooleanfalseStretch to the parent width.
prefix / suffixReactNode–Slots before and after the label, for icons or a Kbd.
iconbooleanfalseSquare icon-only button. Needs aria-label.
aria-labelstring–Accessible name. Required with icon.
renderReactElement<button>Element to render instead, such as a Link.
soundbooleanfalseClick sound on desktop, short vibration on Android.
dynamicSpinnerbooleantrueSpinner changes arc and speed every turn.
reducedMotionbooleansystemOverrides prefers-reduced-motion.
pressedboolean–Forces the pressed look, for demos.

Source

The whole component, as three files. It only depends on React and framer-motion. Update the @/ import paths to match your project.

"use client";

import { motion } from "framer-motion";
import {
  cloneElement,
  isValidElement,
  useEffect,
  useId,
  useState,
  type CSSProperties,
  type KeyboardEvent,
  type MouseEventHandler,
  type ReactElement,
  type ReactNode,
  type Ref,
} from "react";
import { playButtonSound } from "@/components/garage/button/button-sounds";
import { useReducedMotion } from "@/hooks/use-reduced-motion";

export const BUTTON_VARIANTS = ["solid", "soft", "ghost", "outline"] as const;
export const BUTTON_SIZES = ["micro", "tiny", "small", "medium", "large"] as const;
export const BUTTON_COLORS = ["accent", "blue", "red", "amber", "black"] as const;
export const BUTTON_SHAPES = ["sharp", "square", "round"] as const;
export const BUTTON_FEEDBACKS = ["success", "error"] as const;

Usage

The props allow plenty of combinations that technically work and still read badly. Three rules cover most of them.

DoOne solid button per area. Everything else steps down to soft, outline or ghost.
Don’tThree solid buttons compete, so nothing reads as the main action.
DoRed only for actions that destroy something, ideally named in the label.
Don’tRed on a safe action teaches people to ignore red when it matters.
Add an email address first
DodisabledReason explains the grey-out for hover and screen readers.
Don’tA disabled button on its own leaves people guessing what they missed.

Testing

Right-to-left layouts flip on their own. The spinner slot uses margin-inline-start instead of margin-left, so it slides in from the correct side, and prefix and suffix swap ends with the text.

dir="ltr"
dir="rtl"

Here's where it has been checked. Emulated means a browser's developer tools, not the real thing. Anything that turns up in another browser or screen reader gets fixed here.

EnvironmentStatusNotes
Chrome on macOSCheckedWhere it was built and tuned.
Forced coloursEmulatedChrome DevTools forced-colors: active. Borders hold on every variant.
Reduced motionEmulatedChrome DevTools prefers-reduced-motion: reduce.
Right-to-leftCheckeddir="rtl" in Chrome, shown above.
44px tap areaCheckedMeasured in Chrome. A tap 9px above a 26px button still lands on it.