Accessibility
WCAG 2.1 AA is the floor, not the ceiling. Every component below ships with focus, keyboard, and screen-reader behavior baked in — you shouldn't need to layer accessibility on top of a finished component.
Color contrast
Body text must hit 4.5:1 against its background; large text (≥ 18 px or 14 px bold) must hit 3:1. Status pills and inline labels at ≥ 11 px are body text — they need 4.5:1 too.
Focus + keyboard
- Every focusable element gets the same ring:
--accent-glow (3 px primary-blue at 8% opacity).
- Use
:focus-visible, not :focus. Mouse clicks don't show the ring; keyboard focus does.
- Tab moves forward through focusable elements; Shift + Tab moves back. Enter and Space activate buttons; Space toggles checkboxes; Esc closes any overlay.
- 32 px minimum hit target for any interactive element, even when visual size is smaller.
Reduced motion
Respect @media (prefers-reduced-motion: reduce): drop all non-essential transitions to 0.01ms. Surface fades and slides become instant; state changes still work — they just don't animate.
Screen readers
- Every icon-only button carries
aria-label or title.
- Live regions for toasts:
aria-live="polite" for confirmations, "assertive" for errors.
- Status changes are announced via
aria-live, not just visual color shifts.
Interaction states
Universal state vocabulary: hover, active, focus, selected, checked, disabled, loading, error. Every interactive component implements as many of these as apply — same visual language across the whole library.
State priority
When multiple states are present, they layer in this order (top wins visually): disabled → error → loading → selected/checked → focus → active → hover → resting.
Per-component application
- Buttons. All eight states; hover lift is a 1 px shadow on top of color shift; loading uses a
::after spinner.
- Inputs. Hover none, focus required, selected n/a, disabled dims to 50 px text-help.
- List rows. Hover + selected only; selected is the canonical
--row-selected-bg tint.
- Tabs. Active + hover only. Disabled tabs are an anti-pattern — remove the tab instead.
- Checkboxes & toggles. Use focus, checked, disabled. State is communicated by the control's geometry, not just color.
Voice & tone
How the system talks. Short, direct, second-person. Like a co-worker who knows what you're doing and helps you finish it — not a marketing site, not a chatbot.
Buttons
- Verbs, not nouns. "Create workspace" beats "Workspace". "Approve" beats "Approval".
- Most-specific verb you can use. "Save changes" beats "Save". "Mark read" beats "Update". "Confirm delete" beats "Confirm".
- Pair primary with cancel, not OK. Cancel is a real "discard"; OK is a vague "I acknowledge". Consistency wins over cleverness.
- Sentence case. "Create workspace", not "Create Workspace". We save title case for headings.
Error messages
- State the problem, then the fix. "We couldn't reach the server. Check your connection and try again." Not "Error 503".
- Avoid blame. "You entered an invalid email" → "That doesn't look like a valid email." The user is the friend; the system owns the failure.
- Fail primary when you can. "Request timed out with status 502" is a log line. The user gets "Something went wrong. We're trying again."
Empty states
- Explain what would be here. "No workspaces yet" beats "Nothing to show".
- Offer the next step. An empty inbox almost always shows a "+ New message" button next to the empty-state copy. If a user can't see what to do next, that's a design bug.
Microcopy patterns
- Confirm destructive action. "Are you sure?" → "Delete this workspace? You can't undo this."
- Submit / save / publish. Use the most specific verb. "Send" for messages, "Publish" for posts, "Save" for drafts, "Submit" for requests.
- Async in flight. "Saving..." beats "Loading...". Be specific about what's happening.
- No results. Always say what was searched and offer a clearing affordance ("No results for 'foo'. Clear filters?").
- Permissions denied. "You don't have access to this workspace. Ask the admin to add you."