Design system/Fundamentals
Accessibility
Every component works with a keyboard, tells screen readers what it is, is sized for fingers, and respects a preference for reduced motion. These are built into the components themselves so no page has to remember them.
Focus
The focus ring is never removed. Controls that draw their own surface, like selection chips, color swatches, and the time column, get a 2px orange ring with a small offset from the edge on focus-visible; every .btn and every .toggle switch has the same ring, in both themes. The rest keep the browser's own ring. The ring shows only for keyboard use, not on every click. Fields also show focus with the color of the line beneath them. After you pick an option in a menu, focus returns to the button that opened it so the keyboard doesn't lose its place. A dialog also moves focus inside itself when it opens, keeps Tab cycling within it, and returns focus to the button that opened it when it closes.
The first thing Tab reaches on every public page is the "Skip to main content" link.
Press Tab and watch the focus ring on each control.
Name and role
- A button with only an icon gets aria-label and title.
- A button that opens a menu or list has aria-haspopup="listbox" and aria-expanded; the sidebar button also has aria-controls.
- The current page link in navigation gets aria-current="page".
- Toasts appear in a region with role="status" and aria-live="polite".
- The upgrade prompt has role="note", and the six-digit verification code is read with its group label.
Touch target size
On phones, primary controls are at least 40px: btn-sm grows to 40, menu rows in a sheet are 48px, stepper buttons are 40, and fields on touch screens are 48px with a 16px font, which stops iOS from zooming the page on focus. The on/off switch keeps its own look, and an invisible ring enlarges its touch area.
36 → 40pxbtn-sm40px.seat-step48px.sheet-menu52pxbtn-lgHover is a mouse state
On iOS and Android, hover sticks to the last element after a tap. That's why row hover color is defined under @media (hover: hover), and on touch, :active gives the same feel only while the finger is on the screen. No information lives behind hover alone.
Reduced motion
When your system asks for prefers-reduced-motion, a global rule brings all animations and transitions close to zero. Motion built in JavaScript, like the ripple on a field's underline and the mark's wave, checks the same thing before starting. Instead of sliding up from the bottom, dialogs simply fade.
Your device: ·
@media (prefers-reduced-motion: reduce)Contrast and color
Color is never the only carrier of meaning: a red counter has a number, a field error has a sentence, and a toast has full text next to its colored dot. Primary text on the background has full contrast in both themes; tertiary text is only for details that don't need to be read.
Token contrast
Ratios are calculated from the hex values in globals.css. AA requires 4.5 for normal text and 3 for large text (24px, or 18.66px bold); AAA requires 7 and 4.5 respectively. For control lines and borders (non-text) the threshold is 3.
14.94 : 1 · 15.45 : 1Light · dark; AAA16.28 : 1 · 14.17 : 1AAA5.14 : 1 · 5.80 : 1AA in both4.72 : 1 · 6.32 : 1AA in both4.64 : 1 · 5.33 : 1AA; .pill and .badge-soft2.16 : 1 · 2.99 : 1Fails in both; non-essential details only12.94 : 1 · 14.62 : 1AAA5.19 : 1 · 5.19 : 1AA; btn-accent and badge-accent5.30 : 1 · 6.19 : 1AA; orange text4.87 : 1 · 6.75 : 1AA3.63 : 1 · 5.04 : 1Non-text; meets the 3 threshold. Surfaces and graphics, not text3.63 : 1 · 3.37 : 1Large text only; avatars and icons, not labels5.14 : 1 · 4.55 : 1AA in both4.72 : 1 · 4.96 : 1AA in both5.14 : 1 · 5.54 : 1AA; btn-danger and badge-count5.40 : 1 · 6.43 : 1AA in both1.32 : 1 · 1.38 : 1Below 3; the line under a field at restFocus rings, precisely
The :where() rule has zero specificity, so any control that defines its own ring wins. The floating field has no ring because the whole field changes on focus: an orange underline, an orange icon scaled 1.35 times, and an orange label above the edge.
2px solid var(--accent) · offset 2px:where() on .icon-btn, .dropdown-option, .dropdown-toggle, .side-item, .dock-item, .assist-trigger, and more2px solid var(--accent) · offset 2pxDisabled: opacity 0.452px solid var(--accent) · offset 1px2px solid var(--accent) · offset 2px2px solid var(--accent) · offset 2px.btn:focus-visible; both themesoutline: noneInstead, an orange underline, icon, and label.skip-link · top 8px · 12.5pxSlides down from translateY(-250%) on focusRoles the components actually set
- OptionList: role="listbox" with aria-activedescendant; each row has role="option" and aria-selected, plus aria-disabled when disabled.
- DropdownField and SelectMenu: aria-haspopup="listbox", aria-expanded, and aria-label equal to the label.
- Floating on phones: role="dialog", aria-modal="true", and aria-label equal to the sheet title.
- ActionMenu: role="menu" with aria-label set to the title; rows are role="menuitem", or role="menuitemradio" with aria-checked when they have active, and the divider is role="separator". The trigger button automatically gets aria-haspopup="menu" and aria-expanded.
- Dialog and DialogShell: role="dialog", aria-modal="true", and aria-labelledby pointing to the title; ConfirmDialog has the same with role="alertdialog". All three use useDialogA11y to keep focus inside and return it to the trigger on close.
- Toaster: role="status" and aria-live="polite"; the region is created with the first toast.
- SkeletonRows: aria-busy="true" and aria-label “Loading...”.
- CodeInput: role="group" with aria-label, and each box has its number as its name.
- PlanGate: role="note"; the icon is aria-hidden.
- Sidebar: aria-label “Navigation”, aria-current="page" on the current destination, and aria-expanded on the expand and collapse button.
Keyboard
↑ ↓ · PageUp/PageDown (8) · Home/End · Enter/Space · typeaheadHome, End, Space, and type-ahead (600ms) only while focus is on the list↑ / ↓ on the buttonEscapeTop panel onlyEscape · clicking the backdrop · ✕data-autofocus → first focusable → dialogTab and Shift+Tab cycle inside; on close, focus returns to the trigger↑ ↓ (wrap) · Home/End · typeahead · Enter/Space · Escape · TabEscape returns focus to the button; Tab closes← → · Backspace · pasteEye button tabIndex=-1Not in the Tab orderRelated pages