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.
| Pattern | Implementation |
|---|---|
| Interactive non-button elements | Add tabIndex={0} and handle onKeyDown |
| Disabled elements | Use tabIndex={-1} and aria-disabled="true" |
| Modal dialogs | Radix Dialog traps focus automatically |
| Dropdowns, menus | Radix 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.
| Scenario | Implementation |
|---|---|
| Decorative icon | aria-hidden="true" |
| Icon-only button | aria-label="Delete task" on the button |
| Image with content | alt="Descriptive text" |
| Purely decorative image | alt="" |
| Visually hidden label | className="sr-only" on a span |
| Loading state | aria-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 token | On bg-background | Minimum use case |
|---|---|---|
text-foreground | Passes AAA | Primary body copy, headings |
text-foreground-light | Passes AA | Secondary body copy |
text-foreground-lighter | Passes AA (large text) | Nav links, metadata at 14px+ |
text-foreground-muted | Below AA | Decorative labels only — never the primary information carrier |
Rule:
text-foreground-mutedmust 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.