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
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.
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.
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.
<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.
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.
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.
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.
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.
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.
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.
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.
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.
| Prop | Type | Default | Description |
|---|---|---|---|
| variant | solid | soft | outline | ghost | solid | Appearance only. Never changes the colour. |
| color | accent | blue | red | amber | black | accent | Hue for every variant. |
| size | micro | tiny | small | medium | large | medium | Height from 26px to 40px. |
| shape | sharp | square | round | square | Sharp is 0 radius. Round makes it a pill. |
| theme | dark | light | dark | Surface it sits on. Light surfaces darken on press. |
| loading | boolean | false | Shows the spinner and blocks clicks without greying out. |
| loadingLabel | string | Loading | Announced to screen readers when loading starts. |
| feedback | success | error | null | null | Post-press check or cross. Holds the button until you clear it. |
| feedbackLabel | string | – | Announced when feedback flips on. |
| disabled | boolean | false | Greys out and blocks clicks. Stays in the tab order. |
| disabledReason | string | – | Explains a disabled button via title and aria-describedby. |
| fullWidth | boolean | false | Stretch to the parent width. |
| prefix / suffix | ReactNode | – | Slots before and after the label, for icons or a Kbd. |
| icon | boolean | false | Square icon-only button. Needs aria-label. |
| aria-label | string | – | Accessible name. Required with icon. |
| render | ReactElement | <button> | Element to render instead, such as a Link. |
| sound | boolean | false | Click sound on desktop, short vibration on Android. |
| dynamicSpinner | boolean | true | Spinner changes arc and speed every turn. |
| reducedMotion | boolean | system | Overrides prefers-reduced-motion. |
| pressed | boolean | – | 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.
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.
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.
| Environment | Status | Notes |
|---|---|---|
| Chrome on macOS | Checked | Where it was built and tuned. |
| Forced colours | Emulated | Chrome DevTools forced-colors: active. Borders hold on every variant. |
| Reduced motion | Emulated | Chrome DevTools prefers-reduced-motion: reduce. |
| Right-to-left | Checked | dir="rtl" in Chrome, shown above. |
| 44px tap area | Checked | Measured in Chrome. A tap 9px above a 26px button still lands on it. |