[§] /docs/rules/components-and-states
Components and states
Every state an interactive element needs, the touch target sizes, and one API shape for every component.
TL;DR
- Design every state: default, hover, active, focus-visible, disabled, loading, error, empty.
- Hover lives behind
@media (hover: hover). Focus uses:focus-visible. - Touch targets 44 × 44px, never under 24 × 24px.
- Visual options are string unions:
variant="ghost", notisGhost. - Controlled props are always
value,defaultValue,onValueChange.
A component is not done until every state is designed. The default state is the easy tenth of the work. Hover, focus, disabled, loading, error and empty are where interfaces feel finished or broken.
This page lists the states, how each is triggered and styled, the target sizes, and the prop names every component in a project should share.
See them
Press Tab to reach it: the ring only appears for keyboard focus, never on click.
Every state, named
Every state, named: table (10 rows)
| State | Trigger or selector | Visual change | ARIA or attribute |
|---|---|---|---|
| Default | none | The resting design | none |
| Hover | :hover inside @media (hover: hover) | One step darker or lighter background, or an underline. 100 to 150ms | none |
| Active (pressed) | :active | One more step, or scale(0.97). Instant on press | none |
| Focus-visible | :focus-visible | 2px outline in --color-focus, 2px offset | none |
| Selected / on | a prop or route | Filled or accented, plus a non-colour cue (tick, weight, bar) | aria-pressed, aria-selected, aria-current="page" |
| Open | a prop | Chevron rotates 180°, panel shows | aria-expanded="true", data-state="open" |
| Disabled | disabled prop | 50% opacity, cursor: not-allowed, no hover change | disabled, or aria-disabled="true" |
| Loading | a prop | Spinner in place of the icon or label, width unchanged | aria-busy="true", clicks ignored |
| Error | validation | Danger border, icon and a message under the field | aria-invalid="true", aria-describedby |
| Empty | no data | A heading, one sentence and one action | none |
- MUST design every row that applies before a component is marked done. Missing states go in the pull request as open items.
- MUST put hover styles behind
@media (hover: hover). Tailwind v4'shover:variant already does this; hand-written CSS must add it, or touch screens keep a sticky hover after a tap. - MUST use
:focus-visible, not:focus, for the focus ring, so mouse clicks do not flash it and keyboards always see it. - MUST keep the focus ring at 3:1 contrast against the background next to it (WCAG 2.2, 1.4.11).
- MUST NOT show state by colour alone. Pair it with an icon, text, weight or position (WCAG 2.2, 1.4.1).
The order of state styles
When several states apply at once, the later rule must win. Write them in this order:
.button { /* default */ }
.button:hover { }
.button:focus-visible { }
.button:active { }
.button[aria-busy='true'] { }
.button:disabled,
.button[aria-disabled='true'] { } /* last: disabled beats everything */Focus ring
One ring for the whole product. Copy it once, reference it everywhere.
:root { --color-focus: var(--color-accent); }
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
outline: 2px solid var(--color-focus);
outline-offset: 2px;
}- MUST NOT write
outline: nonewithout a replacement focus style in the same rule. - SHOULD use
outline, notbox-shadow, for focus. Outlines stay visible in Windows high contrast mode.
Disabled, loading, error, empty
- Disabled. Use the
disabledattribute when the control does nothing and needs no explanation. Usearia-disabled="true"when it must stay focusable to show a tooltip explaining why. SHOULD prefer explaining over disabling: a submit button that stays enabled and then points at the missing field teaches more than a grey one. - Loading. MUST keep the button the same width, so the layout does not jump. MUST ignore repeat clicks while pending. SHOULD wait about 300ms before showing a spinner, so fast responses never flash one, and keep the label readable to screen readers (
aria-busy="true"plus visually hidden "Saving…"). - Error. MUST say what went wrong and how to fix it, next to the field, in text. MUST keep what the person typed. MUST move focus to the first invalid field on submit.
- Empty. MUST tell three cases apart: first use ("No projects yet"), no results ("No projects match 'rivr'"), and cleared ("All caught up"). Each gets a heading, one sentence and at most one action.
Touch targets
Touch targets: table (4 rows)
| Standard | Minimum target | Level |
|---|---|---|
| WCAG 2.2, 2.5.8 Target Size (Minimum) | 24 × 24 CSS px, or 24px spacing around a smaller target | AA |
| WCAG 2.2, 2.5.5 Target Size (Enhanced) | 44 × 44 CSS px | AAA |
| Apple Human Interface Guidelines | 44 × 44 pt | Platform guidance |
| Material Design | 48 × 48 dp | Platform guidance |
- MUST give every control a hit area of at least 44 × 44px on touch screens. This is the house rule in the rules.
- MUST never go below 24 × 24px anywhere, including dense desktop tables.
- SHOULD leave at least 8px between neighbouring targets.
- SHOULD grow the hit area, not the visual, when the design needs a small control:
Touch targets: copy the css (9 lines)
.icon-button { position: relative; width: 32px; height: 32px; }
.icon-button::after {
content: '';
position: absolute;
inset: 50% auto auto 50%;
width: 44px;
height: 44px;
translate: -50% -50%;
}One API for every component
Every component takes the same prop names for the same ideas. Learn one, know them all.
One API for every component: table (6 rows)
| Prop | Type | Values | Default |
|---|---|---|---|
size | string union | 'sm' | 'md' | 'lg' | 'md' |
variant | string union | 'primary' | 'secondary' | 'ghost' | 'destructive' | 'secondary' |
disabled | boolean | native attribute name | false |
loading | boolean | shows spinner, sets aria-busy | false |
asChild | boolean | render as the child element (Radix Slot) | false |
className | string | merged last with cn() | none |
Sizes map to fixed heights, so buttons, inputs and selects line up in a row:
One API for every component: table (3 rows)
size | Height | Padding x | Text | Icon |
|---|---|---|---|---|
sm | 32px | 12px | 14px | 16px |
md | 40px | 16px | 14px or 16px | 20px |
lg | 48px | 20px | 16px | 20px |
sm is below 44px, so on touch screens it needs the hit-area pattern above.
One API for every component: copy the tsx (40 lines)
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/cn';
const button = cva(
'inline-flex items-center justify-center gap-2 rounded-md font-medium transition-colors duration-150 disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
primary: 'bg-accent text-white hover:bg-accent-hover',
secondary: 'border border-border bg-surface-raised hover:bg-surface',
ghost: 'hover:bg-surface-raised',
destructive: 'bg-danger text-white hover:bg-danger-hover',
},
size: {
sm: 'h-8 px-3 text-sm',
md: 'h-10 px-4 text-sm',
lg: 'h-12 px-5 text-base',
},
},
defaultVariants: { variant: 'secondary', size: 'md' },
},
);
type ButtonProps = React.ComponentProps<'button'> & VariantProps<typeof button> & { loading?: boolean };
export function Button({ variant, size, loading = false, disabled, className, children, onClick, ...props }: ButtonProps) {
return (
<button
type="button"
className={cn(button({ variant, size }), className)}
disabled={disabled}
aria-busy={loading || undefined}
onClick={loading ? (e) => e.preventDefault() : onClick}
data-size={size ?? 'md'}
{...props}
>
{children}
</button>
);
}- MUST use string unions for visual options:
variant="ghost", never a boolean likeghostorisGhost. Booleans multiply into impossible combinations. - MUST spread remaining props onto the root element and accept
ref, so native attributes, test IDs andaria-*just work. In React 19,refis an ordinary prop. - MUST default
type="button"on buttons, so a button inside a form does not submit it by accident. - MUST use the native attribute name when one exists:
disabled,required,open,checked. - SHOULD expose state as data attributes (
data-state="open",data-size="md") so callers can style it without new props. - SHOULD keep
variantto four values or fewer. A fifth variant usually means a second component. - SHOULD NOT add a prop for every style tweak (
rounded,shadow,color). That is whatclassNameis for.
Controlled and uncontrolled
A stateful component, such as a tabs set, a dialog, a switch or a text field, SHOULD work both ways. Uncontrolled for quick use, controlled when the parent needs the value.
if no, the component can own it
→ uncontrolled: defaultValue / defaultOpen
if yes, it syncs with URL, form or server
→ controlled: value + onValueChange
if the parent only needs to know it changed
→ uncontrolled + onValueChange
if it must reset from outside
→ controlled, or change its key
Controlled and uncontrolled: table (4 rows)
| Value | Uncontrolled prop | Controlled prop | Change event |
|---|---|---|---|
| Text, select, slider | defaultValue | value | onValueChange |
| Checkbox, switch | defaultChecked | checked | onCheckedChange |
| Dialog, popover, accordion item | defaultOpen | open | onOpenChange |
| Tabs | defaultValue | value | onValueChange |
Controlled and uncontrolled: copy the tsx (12 lines)
import { useState } from 'react';
export function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (v: T) => void) {
const [inner, setInner] = useState(defaultValue);
const controlled = value !== undefined;
const current = controlled ? value : inner;
const set = (next: T) => {
if (!controlled) setInner(next);
onChange?.(next);
};
return [current, set] as const;
}- MUST name the trio
value,defaultValue,onValueChange, and the same foropenandchecked. NeverinitialValue,isOpenorsetOpen. - MUST pass the new value as the first argument of the change event, not the DOM event.
- MUST NOT switch a component between controlled and uncontrolled during its life. React warns, and state goes out of sync.
Why it works
States are where people decide whether software can be trusted: a button that does nothing visible on
press, or a form that eats their input on error, feels broken even when it works. Shared prop names
make a component library feel like one product, because once someone knows how Dialog opens, they
already know how Popover and Accordion do.