Getting Started

Accessibility

Make the interface work for everyone. Accessibility is not an afterthought — it is built into the component primitives. Radix UI handles the structural requirements; your job is to wire up the right attributes and follow the patterns below consistently.

Keyboard navigation

All interactive elements must be reachable and operable via keyboard. The browser's default tab order is generally correct when the DOM order matches the visual order. Avoid setting tabIndex values greater than zero — they create unpredictable tab sequences.

PatternImplementation
Interactive non-button elementsAdd tabIndex={0} and handle onKeyDown
Disabled elementsUse tabIndex={-1} and aria-disabled="true"
Modal dialogsRadix Dialog traps focus automatically
Dropdowns, menusRadix DropdownMenu handles arrow key navigation

Focus rings

Every focusable element must display a visible focus indicator. We use the brand green ring pattern across all components. The ring is set to 2px with a 40% opacity brand fill so it is visible without overwhelming the design.

Apply the following classes to any interactive element that is not already using a Radix primitive:

// Standard focus ring — applied to buttons, links, inputs
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40 focus-visible:ring-offset-1 focus-visible:ring-offset-background"

// Inset focus — for elements where an outset ring would clip (table rows, list items)
className="focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/40"

Never remove focus styles without providing an equivalent replacement. Using focus:outline-none without focus-visiblealternatives is an accessibility violation.

Interactive elements

Use <button> for actions and <a> for navigation. When a non-semantic element must be interactive (such as a table row that navigates on click), add both keyboard support and the appropriate role.

// Table row with click-to-navigate behavior
<tr
  role="button"
  tabIndex={0}
  onClick={() => router.push(`/tasks/${task.id}`)}
  onKeyDown={(e) => {
    if (e.key === "Enter" || e.key === " ") {
      e.preventDefault();
      router.push(`/tasks/${task.id}`);
    }
  }}
  className="cursor-pointer hover:bg-surface-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-brand/40"
>
  <td>{task.title}</td>
</tr>

Screen readers

Provide text alternatives for all non-text content. Icons that convey meaning need labels; decorative icons should be hidden from the accessibility tree.

ScenarioImplementation
Decorative iconaria-hidden="true"
Icon-only buttonaria-label="Delete task" on the button
Image with contentalt="Descriptive text"
Purely decorative imagealt=""
Visually hidden labelclassName="sr-only" on a span
Loading statearia-busy="true" on the container
Live region (toast)role="status" or aria-live="polite"
// Icon-only delete button
<button
  onClick={() => deleteTask(id)}
  aria-label="Delete task"
  className="rounded-md p-1.5 text-foreground-muted hover:bg-surface-200 hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-brand/40"
>
  <Trash2 size={14} aria-hidden="true" />
</button>

// Visually hidden text alongside an icon
<button className="flex items-center gap-2">
  <Plus size={14} aria-hidden="true" />
  <span>New task</span>
</button>

Color contrast

Never use color as the only way to convey information. Status badges, for example, must pair a color with a text label or icon. The foreground scale is calibrated to meet WCAG AA contrast ratios against the dark surface tokens:

Foreground tokenOn bg-backgroundMinimum use case
text-foregroundPasses AAAPrimary body copy, headings
text-foreground-lightPasses AASecondary body copy
text-foreground-lighterPasses AA (large text)Nav links, metadata at 14px+
text-foreground-mutedBelow AADecorative labels only — never the primary information carrier

Rule: text-foreground-muted must never be the only indicator of state or importance. Always pair it with a shape, position, or icon that conveys the same information independently.

Semantic HTML

Correct element choice reduces the amount of ARIA you need to write. Prefer semantic elements and only add ARIA when the default semantics are insufficient.

  • Use <nav> for navigation landmarks, not <div role="nav">.
  • Use <main> once per page for the primary content region.
  • Use <h1> through <h6> in logical order — do not skip heading levels for styling purposes.
  • Use <ul> / <ol> for genuine lists, not for layout.
  • Use <table> with <th scope> for tabular data, not for layout.