fdb/docs

[§] /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", not isGhost.
  • 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

Defaultresting
Hover150ms in
Pressed80ms, scale .97
Focus-visible0ms, keyboard only
Disabledno hover, not-allowed
Loadingwidth locked

Press Tab to reach it: the ring only appears for keyboard focus, never on click.

Six states every button needs. Hover and press should be quick (under 200ms); focus should be instant. Drag the slider to 600ms and feel how sluggish the live one gets.

Every state, named

Every state, named: table (10 rows)
StateTrigger or selectorVisual changeARIA or attribute
DefaultnoneThe resting designnone
Hover:hover inside @media (hover: hover)One step darker or lighter background, or an underline. 100 to 150msnone
Active (pressed):activeOne more step, or scale(0.97). Instant on pressnone
Focus-visible:focus-visible2px outline in --color-focus, 2px offsetnone
Selected / ona prop or routeFilled or accented, plus a non-colour cue (tick, weight, bar)aria-pressed, aria-selected, aria-current="page"
Opena propChevron rotates 180°, panel showsaria-expanded="true", data-state="open"
Disableddisabled prop50% opacity, cursor: not-allowed, no hover changedisabled, or aria-disabled="true"
Loadinga propSpinner in place of the icon or label, width unchangedaria-busy="true", clicks ignored
ErrorvalidationDanger border, icon and a message under the fieldaria-invalid="true", aria-describedby
Emptyno dataA heading, one sentence and one actionnone
  • 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's hover: 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: none without a replacement focus style in the same rule.
  • SHOULD use outline, not box-shadow, for focus. Outlines stay visible in Windows high contrast mode.

Disabled, loading, error, empty

  • Disabled. Use the disabled attribute when the control does nothing and needs no explanation. Use aria-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)
StandardMinimum targetLevel
WCAG 2.2, 2.5.8 Target Size (Minimum)24 × 24 CSS px, or 24px spacing around a smaller targetAA
WCAG 2.2, 2.5.5 Target Size (Enhanced)44 × 44 CSS pxAAA
Apple Human Interface Guidelines44 × 44 ptPlatform guidance
Material Design48 × 48 dpPlatform 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)
PropTypeValuesDefault
sizestring union'sm' | 'md' | 'lg''md'
variantstring union'primary' | 'secondary' | 'ghost' | 'destructive''secondary'
disabledbooleannative attribute namefalse
loadingbooleanshows spinner, sets aria-busyfalse
asChildbooleanrender as the child element (Radix Slot)false
classNamestringmerged 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)
sizeHeightPadding xTextIcon
sm32px12px14px16px
md40px16px14px or 16px20px
lg48px20px16px20px

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 like ghost or isGhost. Booleans multiply into impossible combinations.
  • MUST spread remaining props onto the root element and accept ref, so native attributes, test IDs and aria-* just work. In React 19, ref is 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 variant to 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 what className is 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.

Does the parent need to read or set this 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

Offer both modes with the same three prop names. Radix, React Aria and native inputs all use this shape.
Controlled and uncontrolled: table (4 rows)
ValueUncontrolled propControlled propChange event
Text, select, sliderdefaultValuevalueonValueChange
Checkbox, switchdefaultCheckedcheckedonCheckedChange
Dialog, popover, accordion itemdefaultOpenopenonOpenChange
TabsdefaultValuevalueonValueChange
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 for open and checked. Never initialValue, isOpen or setOpen.
  • 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.

Sources

3 sources

On this page