Case Study Storytelling
A handbook on writing case studies that show thinking, not process.
Case study / UXPective design system
Orbit is the design system behind UXPective: the foundations, components and rules every UXPective screen is built from. It is built to WCAG 2.1 AA for text, keyboard, focus and screen readers, with a lighter brand palette where the look calls for it.
Interactive version, with every specimen live: work.shanicaroy.com/work/orbit-design-system
| Measure | Before Orbit | With Orbit |
|---|---|---|
| Blues shipping across screens | 3 | 1 |
| Text field versions in use | 3 | 1 |
| Days to compose a new screen from existing parts | 5 | 1 |
| Components with an accessibility checklist | None | 22 of 22 |
| Contrast pairs at WCAG 2.1 AA (the rest documented as exceptions) | Unchecked | 28 of 34 |
Part 1 of 5
On one screen the brand gradient ran blue to purple to orange. On the next it ran orange to purple to blue. Same three colors, reversed, three weeks apart, both designed by me.
That was the symptom that stopped feeling like a one-off. Once I started looking, it kept happening. Three near-identical blues were drifting across screens. Form fields were rebuilt every time a new screen needed one, with different padding, different focus rings and three different ideas of what “required” looked like. The logo’s three colors were being pulled apart and used as flat palettes, as if the gradient between them had never existed.
Evidence: the title gradient, three runs, three weeks apart, all designed by me.
Three blues, drifting (illustrative values). None is wrong on its own. Side by side, they read as a brand losing its grip.
The fracture wasn’t in any single decision. It was in re-deciding the same thing every time I opened the file. A trustworthy brand is one where the small decisions have been made once. Orbit is where those decisions live, so they aren’t made again on every screen.
Most small projects don’t earn one. A design system has to be built, maintained and documented, and that only pays back once the cost of not having one crosses a threshold. Three signals made the threshold clear.
Up to three or four, I could hold the whole UI in my head. Past that, every new screen started from a screenshot of the last one and inherited every uncorrected error in it.
Roughly one a week. At that pace, ad hoc consistency is a fiction: either there is a source to refer to, or there are forty slightly different button paddings.
Half of every new screen was rebuilding the obvious: fields, button states, section padding. A new screen took five days, and about four of them went to things that already existed.
Threshold. It stopped being optional the moment I rebuilt a text field for the third time and couldn’t remember which of the previous two was the right one. If the honest answer to “which version do I copy?” is “I don’t know”, the system is already necessary.
Orbit holds foundations (the decisions every screen depends on) and components (patterns that recur). Everything else stays local to its screen.
Why 22 components. Each one either met the entry criteria, or exists because another component is built on it: Radio group sits under Segmented control, and Menu holds the data table’s row actions. Patterns that don’t meet the criteria yet are tracked in the component backlog.
One token source feeds two consumers, and names match across all three. If the code button has variant="primary", the Figma component’s variant is Primary. A Figma-only variant for a single mock is the first step of drift, so new variants follow the same contribution process in both places.
| Layer | Lives in | Who changes it |
|---|---|---|
| Token source | One JSON file, primitives and semantics | Owner |
| Design library | Figma variables, text styles and components | Owner and contributors, against the acceptance criteria |
| Code library | CSS custom properties and components | Owner and contributors, against the acceptance criteria |
| Surfaces | UXPective website, cohort landing pages, cohort application form | Anyone building a UXPective screen |
| Documentation | This case study and the internal docs page, in the same order | Whoever ships the change |
Part 2 of 5
Tokens, accessibility, color, typography, spacing, layout and direction, shape, elevation and layers, motion, icons and writing. Every component is composed from these, so a change here reaches every component.
Foundation 1 of 11
Every visual decision in Orbit is a token, and every token sits in one of three tiers. Components and screens only ever reach the middle one.
| Tier | What it holds | Example | Who uses it |
|---|---|---|---|
| Primitive | Raw values with no opinion | uxp-blue-700, uxp-space-4 | Semantic tokens only |
| Semantic | A value with a job | uxp-color-text-link, uxp-color-border-input | Components and screens |
| Component | A component diverging from its semantics | None yet | Added only when a second theme needs it |
Every token reads uxp-{category}-{role}-{variant}-{state}, dropping parts that don’t apply. Scales that are steps use numbers (space, elevation, color stops). Sets of discrete choices use sizes (radius, motion durations, breakpoints, UI text). Every decision has exactly one name; the only aliases are the section spacing tokens, and they say so.
| Category | Pattern | Example |
|---|---|---|
| Color, semantic | uxp-color-{role}-{variant}-{state} | uxp-color-text-link-hover |
| Color, status | uxp-color-status-{status}-{use} | uxp-color-status-error-fg |
| Gradient | uxp-gradient-{job} | uxp-gradient-action |
| Text style | uxp-text-{role}-{size} | uxp-text-ui-button-md |
| Spacing | uxp-space-{step} | uxp-space-6 |
| Shape | uxp-radius-{size}, uxp-border-{px} | uxp-radius-sm |
| Elevation and layers | uxp-elevation-{step}, uxp-layer-{surface} | uxp-layer-modal |
| Motion | uxp-motion-{property}-{size} | uxp-motion-duration-md |
| Accessibility | uxp-focus-{part}, uxp-target-{size} | uxp-focus-halo |
| Category | Tokens | Includes |
|---|---|---|
| Color primitives | 55 | Brand ramps, secondaries, neutrals, alpha neutrals, status ramps |
| Color semantics | 48 | Text, surfaces, borders, actions, controls, status |
| Gradients | 3 | Brand, text, action |
| Accessibility | 7 | Focus ring and halo, target sizes |
| Spacing | 14 | Eleven steps, three section tokens |
| Shape | 7 | Five radii, three border widths |
| Elevation and layers | 12 | Five elevations, seven layers |
| Motion | 7 | Four durations, three easings |
| Type families | 3 | Display, body, mono |
| Masks | 2 | Scroll-edge fade, select chevron |
| Text styles | 19 | Editorial and interface sets |
| Breakpoints | 5 | xs to xl |
| Total | 182 |
| Rule | Fails on |
|---|---|
| Components consume semantics | Any primitive token in component code, including hover, active and dark-surface states |
| No raw colors | A hex, rgb or named color anywhere in component code |
| Semantics reference primitives | A semantic token whose value is anything other than a primitive |
| Spacing stays on the scale | A padding, margin or gap that isn’t built from space, border or focus tokens |
| Names exist | A reference to a token that isn’t defined |
Fixed dimensions (control heights, icon and checkbox boxes, maximum widths) are part of each component’s spec rather than the spacing scale. In Figma, detached styles are reported per file. When this page is built, its own stylesheet and every specimen go through the same lint, and passes: 298 component rules, no primitives, no raw colors, no off-scale spacing.
Foundation 2 of 11
Orbit is built to WCAG 2.1 AA for text, keyboard operation, focus, names and announcements. A small set of brand colors (the interface blue, field edges and placeholders) is deliberately lighter than AA for a cleaner look; each is listed below as a documented exception. Where AAA is easy (44px targets, reduced motion), Orbit takes it by default.
| Rule | Token or behavior | Criterion |
|---|---|---|
| Body text, labels, helper text and errors at least 4.5:1 | Text tokens resolve to ink-500 or darker, or to status 700 stops | 1.4.3 |
| Large text at least 3:1 (24px, or 18.66px bold) | Display text uses 600 stops or darker; uxp-gradient-text is built from them | 1.4.3 |
| Focus and checked states at least 3:1 | uxp-focus-ring-color and checked fills use blue-600 | 1.4.11 |
| Focus always visible | 2px outline at 2px offset on :focus-visible; fields turn their edge into a 2px blue line with a soft uxp-focus-halo; a solid or transparent outline is always present, so forced-colors mode keeps focus visible | 2.4.7 |
| Targets | uxp-target-md 44px default (meets AAA 2.5.5); uxp-target-sm 32px for desktop dense areas only | beyond AA |
| Text resilience | min-height, never height; rem units; content survives 200% zoom, 320px width and text-spacing overrides | 1.4.4, 1.4.10, 1.4.12 |
| One announcer per page | A polite live region owned by the system; components call it instead of building their own | 4.1.3 |
| Motion | Durations collapse under prefers-reduced-motion; only short opacity changes remain | beyond AA (2.3.3) |
| Visible edges on fields and small controls | Fields and checkboxes keep a light edge (a documented exception to 3:1); buttons and chips are identified by their text | 1.4.11 |
| Color is never the only cue | Underlined links, icon shapes per status, weight on selected tabs, a bar on the current nav item, thumb position on switches | 1.4.1 |
| Single-key shortcuts | Not used. Shortcuts include a modifier (Ctrl K, Cmd K) | 2.1.4 |
Every component page in Part 4 ends with the same seven rows, in the same order: contrast; keyboard; focus; name, role and value; announcements; reflow and text spacing; and one criterion specific to that component. A row is never deleted for being inconvenient. If it doesn’t apply, it says “Not applicable” and why.
A spec can be correct and still ship broken, so every component is checked on the built version with the same protocol: an automated scan, a keyboard-only pass, NVDA with Firefox and Chrome, VoiceOver with Safari on macOS and iOS, 200% zoom, a 320px viewport, a text-spacing override, and Windows forced-colors mode. Automated scans catch roughly a third of issues; the rest need a person.
Example. While these docs were built, the automated pass flagged links sitting on 50 tints, inside banners and selected table rows. A blue that works on white loses contrast on every tint, so links on tints use uxp-color-text-link-on-tint (blue-700), at 4.9:1 or better on every tint.
These pairs sit below their WCAG 2.1 AA requirement on purpose. The build fails if any other pair falls short, so the list below is always complete.
| Pair | Ratio | Requirement | Why it’s lighter |
|---|---|---|---|
| ink-400 on white | 3.2:1 | 4.5:1 | Placeholders are lighter so they never read as typed values. They’re examples only: every field also has a visible label. |
| blue-600 on white | 4.0:1 | 4.5:1 | A brighter interface blue keeps actions light. Links are also underlined, so they never rely on color. |
| blue-600 on ink-50 | 3.7:1 | 4.5:1 | The same interface blue on muted footers and button bars, where it sits slightly lower than on white. |
| white on blue-600 | 4.0:1 | 4.5:1 | Same blue, carrying white labels. Labels are 15px semibold, and the pair still meets the 3:1 AA level for large text. |
| ink-200 on white | 1.4:1 | 3.0:1 | Fields keep a near-white edge for a lighter form. Each field has a visible label above it, and focus turns the edge blue. |
| ink-300 on white | 1.7:1 | 3.0:1 | Small controls get a slightly stronger edge than fields. Checked and on states switch to a blue fill that meets 3:1. |
Foundation 3 of 11
Three primaries from the brand mark, three decorative secondaries, eight cool neutrals, four status families and three gradients. The palette expresses personality; the status layer communicates state; they stay separate on purpose. Every stop has a job, which is what makes contrast predictable.
| Stop | Used for | Notes |
|---|---|---|
| 500 | Brand mark, decorative fills, illustration | The brand colors. Logotypes are exempt from contrast, so the mark keeps them exactly. |
| 600 | Interface actions and display text: buttons, links, focus, checked and selected states | Tuned bright rather than dark. White on blue-600 is 4.0:1, a documented exception for text. |
| 700 | Hover and pressed states, text on tints | 5.5:1 on white and at least 4.9:1 on every tint. |
| 800 to 900 | Deep accents, used sparingly | Saturated rather than darkened, so they never read as muddy. |
| 50 to 200 | Tints, hover fills, selected rows | Backgrounds only, never text. |
uxp-blue 50 to 900
uxp-purple 50 to 900
uxp-orange 50 to 900
500 is the brand color in each ramp. 600 carries the interface; the darker stops stay saturated instead of turning brown or navy.
One secondary per composition, never as text, never as state, never next to purple-500.
The last rule exists because light purple and pink sit close to purple-500; used together they would read as three near-identical purples.
Soft cool neutrals, from a near-white muted surface to near-black text. Fields use ink-200 for their edge and small controls use ink-300, both lighter than 3:1 by choice.
One alpha primitive completes the neutrals: uxp-ink-900-a40, for the modal backdrop. It is the only translucent value in the system, and the backdrop’s semantic token points at it like any other primitive, so no semantic token ever holds a raw value.
Status colors are kept separate from the brand palette, so a warning always looks like a warning rather than a brand moment. Warning is yellow rather than amber: amber sits 6° from brand orange, is lighter than it, and the two measure 1.1:1 against each other. Yellow sits 17° away, and every warning also carries a triangle.
| Status | Ramp | 500 | Text on white | Text on tint |
|---|---|---|---|---|
| Success | green | #22B35E fills, large marks | #15803D 5.0:1 | #15803D on #EAF8EF 4.5:1 |
| Error | red | #F04438 fills, large marks | #DC2626 4.8:1 | #B91C1C on #FEF1F1 5.8:1 |
| Warning | yellow | #F5B300 fills, large marks | #A36400 4.7:1 | #A36400 on #FFFAEB 4.5:1 |
| Info | blue (shared) | #00A7FF fills, large marks | #0068C8 5.5:1 | #0068C8 on #EFF6FF 5.0:1 |
Components and screens only consume semantics. When a primitive shifts, semantics keep their names and everything downstream survives.
| Token | Resolves to | Used for |
|---|---|---|
uxp-color-text-primary | ink-900 | Headings, body, values |
uxp-color-text-secondary | ink-500 | Helper text, captions, metadata |
uxp-color-text-placeholder | ink-400 | Example text in inputs (documented exception) |
uxp-color-text-disabled | ink-400 on bg-disabled | Disabled controls |
uxp-color-text-link, -hover, -visited | blue-600, blue-700, purple-700 | Links, always underlined |
uxp-color-text-link-on-tint | blue-700 | Links on any 50 tint: banners, selected rows |
uxp-color-text-inverse | ink-0 | Text on blue fills and tooltips |
uxp-color-bg-surface, -muted, -subtle | ink-0, ink-50, ink-100 | Page, footers and wells, chips |
uxp-color-bg-selected | blue-600 | Current page, selected segment, active suggestion |
uxp-color-bg-selected-subtle | blue-50 | Selected rows, the current nav item, the bulk bar |
uxp-color-bg-inverse | ink-700 | Tooltips |
uxp-color-bg-disabled | ink-50 | Disabled control fills |
uxp-color-bg-overlay | ink-900-a40 (ink-900 at 40%) | Modal backdrop |
uxp-color-border-subtle | ink-100 | Hairlines and dividers |
uxp-color-border-input, -hover | ink-200, ink-300 | Field edges (documented exception) |
uxp-color-border-control, -hover | ink-300, ink-400 | Checkbox, radio and switch edges (documented exception) |
uxp-color-border-strong | ink-900 | Emphasis outlines |
uxp-color-action-primary, -hover | blue-600, blue-700 | Primary buttons, checked controls, focus |
uxp-color-action-secondary-bg, -bg-hover | blue-50, blue-100 | Secondary (tonal) button fill |
uxp-color-action-secondary-text | blue-700 | Secondary button text, current nav item, bulk-bar actions |
uxp-color-action-on-primary | ink-0 | Labels on action fills |
uxp-color-action-destructive, -hover | red-700, red-800 | Destructive buttons and menu items |
uxp-color-control-thumb | ink-400 | Switch thumb when off |
uxp-color-status-*-fg | status 700 | Status text and icons on white |
uxp-color-status-*-tint | status 50 | Banner and badge backgrounds |
uxp-color-status-*-on-tint | status 700, or red 800 | Text and icons on a tint |
uxp-color-status-neutral-tint, -on-tint | ink-100, ink-700 | Neutral badges |
uxp-gradient-brand: 500 stops at 90°. The mark, full-bleed bands, decoration. Never behind text, never as text.uxp-gradient-text: blue-600, purple-500, orange-600. Display text only; no point along it drops below 3.2:1 on white.uxp-gradient-action: blue-600 to purple-600. The selected tab indicator, at 4.0:1 or more on white. Buttons stay flat.Every ratio is calculated from the hex values when the page is built, so this table can’t drift from the tokens.
| Pair | Used for | Ratio | WCAG 2.1 AA | Result |
|---|---|---|---|---|
| ink-900 on white | Headings, body, values | 16.4:1 | 4.5:1 | Pass |
| ink-500 on white | Secondary text and helper text | 5.0:1 | 4.5:1 | Pass |
| ink-500 on ink-50 | Secondary text on muted surfaces | 4.7:1 | 4.5:1 | Pass |
| ink-500 on blue-50 | Secondary text on selected rows | 4.6:1 | 4.5:1 | Pass |
| ink-400 on white | Placeholder text | 3.2:1 | 4.5:1 | Exception |
| blue-600 on white | Links and tertiary buttons | 4.0:1 | 4.5:1 | Exception |
| blue-600 on ink-50 | Tertiary buttons and links on muted surfaces | 3.7:1 | 4.5:1 | Exception |
| white on blue-600 | Primary button, current page, selected segment | 4.0:1 | 4.5:1 | Exception |
| white on blue-700 | Primary button hover | 5.5:1 | 4.5:1 | Pass |
| blue-700 on white | Link hover, info text | 5.5:1 | 4.5:1 | Pass |
| blue-700 on ink-50 | Tertiary and page-link hover on muted fills | 5.1:1 | 4.5:1 | Pass |
| blue-700 on red-50 | Links on tints (worst case across all 50 tints) | 4.9:1 | 4.5:1 | Pass |
| blue-700 on blue-50 | Secondary button, current nav item, info badge | 5.0:1 | 4.5:1 | Pass |
| blue-700 on blue-100 | Secondary button hover | 4.6:1 | 4.5:1 | Pass |
| purple-700 on white | Visited links | 6.3:1 | 4.5:1 | Pass |
| white on red-700 | Destructive button | 4.8:1 | 4.5:1 | Pass |
| red-700 on white | Error text and icons, destructive menu items | 4.8:1 | 4.5:1 | Pass |
| white on ink-700 | Tooltips | 9.0:1 | 4.5:1 | Pass |
| ink-900 on ink-100 | Filter chips | 14.1:1 | 4.5:1 | Pass |
| green-700 on white | Success text and icons | 5.0:1 | 4.5:1 | Pass |
| yellow-700 on white | Warning text and icons | 4.7:1 | 4.5:1 | Pass |
| green-700 on green-50 | Success badge and banner icon | 4.5:1 | 4.5:1 | Pass |
| red-800 on red-50 | Error badge and banner icon | 5.8:1 | 4.5:1 | Pass |
| yellow-700 on yellow-50 | Warning badge and banner icon | 4.5:1 | 4.5:1 | Pass |
| ink-700 on ink-100 | Neutral badge | 7.8:1 | 4.5:1 | Pass |
| ink-900 on yellow-500 | Text on a warning fill | 8.8:1 | 4.5:1 | Pass |
| blue-600 on white | Display text, gradient-text start | 4.0:1 | 3.0:1 | Pass |
| purple-500 on white | Display text, gradient-text middle | 3.8:1 | 3.0:1 | Pass |
| orange-600 on white | Display text, gradient-text end | 3.2:1 | 3.0:1 | Pass |
| blue-600 on white | Focus ring, checked controls, current nav bar | 4.0:1 | 3.0:1 | Pass |
| blue-600 on blue-100 | Field focus edge against its halo | 3.3:1 | 3.0:1 | Pass |
| ink-400 on white | Switch thumb when off | 3.2:1 | 3.0:1 | Pass |
| ink-200 on white | Field edges | 1.4:1 | 3.0:1 | Exception |
| ink-300 on white | Checkbox, radio and switch edges | 1.7:1 | 3.0:1 | Exception |
| blue-500 on white | Brand mark and decoration only | 2.6:1 | Logotype | Exempt |
| orange-500 on white | Brand mark and decoration only | 2.2:1 | Logotype | Exempt |
Foundation 4 of 11
Three typefaces, each with one job. Satoshi does the talking, Inter does the working, JetBrains Mono does the labeling. Two faces would leave eyebrows, tokens and data nowhere to go but italics or all caps. Four would make someone choose between faces for buttons, and the system would lose its authority.
Display and headings. Loaded cuts: 500, 700, 900.
Body and every piece of interface text. Loaded: 400, 500, 600, 700.
Eyebrows, token names, data. Loaded: 400, 500.
Nine font files. Every loaded weight is used by at least one text style, and nothing is synthesized.
| Style | Face | Weight | Size / line height (px) | Tracking | Role |
|---|---|---|---|---|---|
| display-xl | Satoshi | 700 | 60 to 132 fluid / 0.98 | -0.025em | Cover title |
| display-lg | Satoshi | 700 | 48 to 72 fluid / 1.0 | -0.02em | Section title |
| heading-1 | Satoshi | 700 | 36 to 48 / 1.1 | -0.02em | Page heading |
| heading-2 | Satoshi | 700 | 28 to 36 / 1.15 | -0.015em | Section heading |
| heading-3 | Satoshi | 500 | 22 to 28 / 1.2 | -0.01em | Sub-section, card title |
| heading-4 | Inter | 700 | 20 / 1.3 | -0.01em | Component heading |
| heading-5 | Inter | 600 | 18 / 1.35 | -0.01em | Group heading |
| heading-6 | Inter | 600 | 16 / 1.4 | 0 | Inline heading |
| body-lg | Inter | 400 | 18 / 1.55 | 0 | Lead paragraph |
| body-md | Inter | 400 | 16 / 1.6 | 0 | Body |
| body-sm | Inter | 400 | 14 / 1.55 | 0 | Helper, error, caption text |
| caption | Inter | 500 | 12 / 1.5 | 0.01em | Metadata |
| overline | JetBrains Mono | 500 | 12 / 1.4 | 0.04em | Eyebrows, sentence case |
| ui-button-sm | Inter | 600 | 13 / 16 | 0 | Small buttons |
| ui-button-md | Inter | 600 | 15 / 20 | 0 | Buttons, tabs |
| ui-button-lg | Inter | 600 | 17 / 24 | 0 | Large buttons |
| ui-label | Inter | 500 | 14 / 20 | 0 | Field and control labels |
| ui-input | Inter | 400 | 16 / 24 | 0 | Typed values |
| mono-data | JetBrains Mono | 400 | 13 / 20, tabular | 0 | Numbers, tokens, code in tables |
UI text styles. Interface text needs sizes editorial text doesn’t: a 15px button label, a 14px field label, a 16px input. Rather than approximate them with body styles, they’re tokens, so every component uses a size the scale actually contains. Inputs are 16px, which stops iOS Safari zooming in on focus. Labels are 14px, because labels are the most-read text on a form.
Display once per page. Heading 1 once per page. Headings 2 and 3 structure sections. Headings 4 to 6 switch to Inter, which makes the hand-off from editorial to interface visible. Body-lg is for lead paragraphs only; it gets tiring after two. Overline is mono in sentence case and always sits above something: all caps at small sizes is the least legible text a system can ship.
design system.
uxp-text-style-title-gradient: Satoshi 900, uxp-gradient-text, background-clipped, with a solid ink-900 fallback and a forced-colors fallback.
The title gradient is built from 600 stops, not the 500 brand stops. Large text still needs 3:1, and the 500 blue and orange measure 2.6:1 and 2.2:1 on white.
Foundation 5 of 11
A 4px base. Eleven steps for components and three for section rhythm. Every padding, margin and gap uses a step from the scale, so designers choose a step rather than a pixel value.
| Token | px | Scale |
|---|---|---|
uxp-space-1 | 4 | |
uxp-space-2 | 8 | |
uxp-space-3 | 12 | |
uxp-space-4 | 16 | |
uxp-space-5 | 20 | |
uxp-space-6 | 24 | |
uxp-space-8 | 32 | |
uxp-space-10 | 40 | |
uxp-space-12 | 48 | |
uxp-space-16 | 64 | |
uxp-space-20 | 80 | |
uxp-space-section-sm | 64 | Alias of space-16. Between sub-blocks inside a section. |
uxp-space-section-md | 96 | Between sections on mobile and tablet. |
uxp-space-section-lg | 128 | Between sections on desktop. |
Padding inside containers: the step that lets content breathe without crowding the border. Margin between flow elements: the smallest step that still separates them. Gap in flex and grid groups: usually the same step as the padding around the group.
| Where | Rule |
|---|---|
| Button padding | sm space-2 by space-4, md space-3 by space-6, lg space-3 by space-8 |
| Card padding | space-6 standard, space-8 feature (stat, quote) |
| Field stacking | space-2 between label, input and helper; space-6 between fields |
| Modal | space-6 around header and body, space-4 by space-6 in the footer |
| Menu and list items | space-2 by space-3 inside a 44px row |
| Section rhythm | section-md on tablet and mobile, section-lg on desktop |
Foundation 6 of 11
A 12-column grid that becomes 8 on tablet and 4 on mobile. Same logic on every surface, 1280px maximum content width. Past 1440px the page stops growing and the margins absorb the rest.
| Token | Range (px) | Columns | Margin | Gutter |
|---|---|---|---|---|
uxp-bp-xs | 0 to 479 | 4 | 16 | 12 |
uxp-bp-sm | 480 to 767 | 4 | 24 | 16 |
uxp-bp-md | 768 to 1023 | 8 | 32 | 20 |
uxp-bp-lg | 1024 to 1439 | 12 | 64 | 24 |
uxp-bp-xl | 1440 and up | 12 | auto | 24 |
The grid at lg: 12 columns, 24px gutters, 64px margins. At mobile widths, 4.
Three sanctioned cases: full-bleed brand bands, display type that extends into the outer margin at lg and up, and full-bleed photography. Anything else stays on the grid.
Every layout works at 320px wide (400% zoom on a 1280px screen) without the page scrolling sideways. The only things allowed to scroll horizontally are two-dimensional content inside their own region: data tables and code.
The Language switcher offers Arabic, so direction is a foundation, not a component detail. Layout uses logical properties only (margin-inline-start, never margin-left). Directional icons mirror: arrows, left and right chevrons, send. Checkmarks, clocks, media controls and anything depicting a real object never mirror. Numbers and code stay left to right inside right-to-left text.
Foundation 7 of 11
One firm radius for every control and container: 4px. Inner marks inside a control use 2px, and full rounding is kept for shapes that are round by nature. Hierarchy comes from elevation and weight, not from corners.
| Token | Value | Used for |
|---|---|---|
uxp-radius-none | 0 | Full-bleed bands, tables inside scroll regions |
uxp-radius-xs | 2px | Marks inside controls: menu and list rows, the selected segment, keyboard hints |
uxp-radius-sm | 4px | Everything else: buttons, fields, checkboxes, badges, chips, cards, menus, modals, toasts, banners |
uxp-radius-full | 999px | Switch tracks |
uxp-border-1 | 1px | Hairlines, input edges, outlines |
uxp-border-2 | 2px | Focus rings, checkbox, radio and switch edges, error state |
uxp-border-3 | 3px | Selected tab indicator, current nav bar |
Foundation 8 of 11
Five elevations, each describing how a surface relates to the page. Used sparingly, so elevation keeps its meaning.
Default is elevation-0 with a hairline. Elevation-1 is for surfaces that are genuinely interactive. Elevation-3 is for things that float: menus, the language list, search suggestions, tooltips. Shadows aren’t used on dark backgrounds or gradient bands, and cards that can’t be clicked don’t lift on hover. Shadows also disappear in forced-colors mode, so every floating surface carries a 1px transparent border that becomes visible there.
| Token | Value | Used for |
|---|---|---|
uxp-layer-base | 0 | Page content |
uxp-layer-sticky | 100 | Sticky headers, sticky table columns |
uxp-layer-dropdown | 200 | Menus, language list, search suggestions |
uxp-layer-overlay | 300 | Non-modal overlays |
uxp-layer-modal | 400 | Dialogs (native dialogs also use the top layer) |
uxp-layer-toast | 500 | Toast region |
uxp-layer-tooltip | 600 | Tooltips, always on top |
Foundation 9 of 11
Motion in Orbit answers something a person did: opening, expanding, confirming, switching. Nothing moves on its own.
| Token | Value | Used for |
|---|---|---|
uxp-motion-duration-xs | 100ms | Opacity changes; the only duration kept under reduced motion |
uxp-motion-duration-sm | 160ms | Hover, color and switch transitions |
uxp-motion-duration-md | 280ms | Accordion, modal, toast, primary hover crossfade |
uxp-motion-duration-lg | 400ms | Large surfaces entering |
uxp-motion-ease-standard | cubic-bezier(0.2, 0, 0, 1) | Most transitions |
uxp-motion-ease-out-expo | cubic-bezier(0.22, 1, 0.36, 1) | Things arriving: panels opening, toasts entering |
uxp-motion-ease-in | cubic-bezier(0.4, 0, 1, 1) | Things leaving |
Under prefers-reduced-motion the sm, md and lg durations drop to zero, so changes are instant; xs survives for short fades that don’t move anything. Spinners slow down rather than stop, so loading is still visible.
Foundation 10 of 11
One set, drawn on a 24-unit grid with a 1.5px stroke and 2 units of inner padding. Functional, never decorative. Strokes stay 1.5px at every size (non-scaling stroke), so a 16px icon doesn’t thin out to a hairline.
Sizes: 16 inline, 20 UI, 24 navigation, 32 emphasis, 48 feature.
An icon next to a text label is decorative: aria-hidden="true" and focusable="false". An icon that is the only label of a control is named on the control (“Close”), not on the icon. A standalone icon that conveys information by itself gets role="img" and a label. Status icons are filled and have distinct shapes, so they read at a glance and never rely on hue.
icon-{noun} for outline, icon-{noun}-filled for filled. Direction is part of the noun: icon-arrow-right. A glyph enters the library after three shipped uses, the same entry criteria as components.
The 36-glyph set. Arrows, left and right chevrons, and send mirror in right-to-left layouts.
Foundation 11 of 11
Interfaces are read before they’re used, so words are part of the system. Orbit’s voice is UXPective’s: direct and outcome-driven. Every element of text does one job, and every component’s examples on this page follow these rules.
| Element | Write | Not |
|---|---|---|
| Buttons | The verb for what happens: “Save changes”, “Submit application” | “Submit”, “OK”, “Click here” |
| Flows | The same verb all the way through: “Deprecate” produces “Token deprecated” | “Deprecate” producing “Item removed” |
| Labels | The thing being asked for: “Work email” | “Enter your work email address below” |
| Placeholders | An example: “name@company.com” | The label, or an instruction |
| Helper text | Why it’s asked, or the format: “We only use this to confirm your application.” | A repeat of the label |
| Errors | What happened and how to fix it: “Enter an email address with an @, like name@company.com.” | “Invalid input”, “Oops!”, an apology |
| Empty states | What will appear and how to get it there | “Nothing here” |
| Links | The destination: “Read the contribution model” | “Read more”, “Click here” |
| Alt text | What the image shows and why it’s there | “Image of”, or the file name |
| Directions | The thing itself: “Select Save changes” | Position or color: “the button on the right”, “the red field” |
Sentence case everywhere, including headings, buttons and tabs. No exclamation marks in system messages. Curly quotes and apostrophes in text people read; straight ones only in code. No em dashes: split the sentence, or use a colon. Dates are written day, month, year in full (21 October 2026). Numbers are numerals, with ranges written as “1 to 4” in sentences and “1-4” in tight spaces. Plain words: short sentences and no idioms, because UXPective’s audience reads in more than one language.
Part 3 of 5
Every page below reads the same way: purpose, a live specimen, the spec, when to use it and when not to, the seven-row accessibility checklist, and the tokens it consumes. Every specimen is the real component, built from the tokens on this page, and every one can be used with a keyboard and a screen reader.
| Group | Components |
|---|---|
| Actions | Button, Link, Menu |
| Navigation | Tabs, Side nav, Pagination, Language switcher |
| Inputs | Text field, Select, Checkbox, Radio group, Switch, Segmented control, Search |
| Feedback | Banner, Toast, Badge, Tooltip |
| Containers | Card, Accordion, Modal |
| Data | Data table |
Actions
Starts an action on the current page: saving, submitting, opening, deleting. Four variants and three sizes.
Press Save changes: the label stays put, a spinner appears, and a screen reader hears “Saving changes” then “Changes saved”. Publish is disabled but still focusable, so its reason can be read.Save changes keeps its label while saving: a spinner appears and a screen reader hears “Saving changes”, then “Changes saved”. Publish is disabled but still focusable, so its reason can be read.
uxp-text-ui-button-md), optional trailing icon (16px), container (uxp-radius-sm, min-height not height).uxp-color-action-primary with a white label. Secondary: tonal, a uxp-color-action-secondary-bg fill with uxp-color-action-secondary-text and no border. Tertiary: text only. Destructive: red-700 fill. One primary per view.uxp-color-action-primary-hover; secondary’s fill deepens to uxp-color-action-secondary-bg-hover; tertiary gains a muted fill), active, focus-visible, disabled (aria-disabled), loading (spinner plus announcement, label unchanged).<a class="o-btn">).| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Primary: white on blue-600 4.0:1, a documented exception, on its hover 5.5:1. Destructive: white on red-700 4.8:1. Secondary: blue-700 on blue-50 5.0:1, on its hover 4.6:1. Tertiary: blue-600 on white 4.0:1, a documented exception. |
| Keyboard2.1.1 | Native <button>: Enter and Space activate. Link-styled buttons are real links and activate on Enter only. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. On dark or gradient bands use uxp-focus-ring-color-inverse. |
| Name, role, value4.1.2, 2.5.3 | The visible label is the accessible name. Icon-only buttons take an aria-label that is a verb (“Copy token name”, not “Copy icon”). Disabled uses aria-disabled="true", stays focusable, blocks activation in script and points to its reason with aria-describedby. |
| Announcements4.1.3 | Loading and result are announced through the page’s polite live region. aria-busy is not relied on, because most screen readers do not announce it on a button. |
| Reflow and text spacing1.4.10, 1.4.12 | min-height and padding, never a fixed height, so labels wrap instead of clipping under text-spacing overrides. Button bars stack full width below 480px. |
| Target sizebeyond AA | 44px at md and up meets AAA 2.5.5. WCAG 2.1 AA sets no target size, so sm at 32px is allowed, but only on desktop dense areas. |
uxp-color-action-primaryuxp-color-action-primary-hoveruxp-color-action-secondary-bguxp-color-action-secondary-bg-hoveruxp-color-action-secondary-textuxp-color-action-on-primaryuxp-color-action-destructiveuxp-color-bg-disableduxp-color-text-disableduxp-focus-ring-coloruxp-target-mduxp-target-smuxp-radius-smuxp-space-3uxp-space-6uxp-text-ui-button-mdActions
Takes someone to another URL or another place on the page. Two forms: inline, inside a sentence, and standalone, on its own line.
The token reference lives in the typography foundation, and every contrast pair is listed in the color foundation.
Inline links are always underlined. The external link’s “opens in a new tab” is visually hidden text appended to the name, not an aria-label that replaces it.
icon-external-link plus hidden text.| Check | Implementation |
|---|---|
| Contrast1.4.3 | blue-600 on white 4.0:1, a documented exception, hover blue-700 5.5:1, visited purple-700 6.3:1. On a 50 tint, links switch to blue-700 (4.9:1 or better). Inline links are always underlined, so they never depend on color. |
| Keyboard2.1.1 | Native <a href>, activates on Enter. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2, 2.5.3, 2.4.4 | Link text names the destination. External links append “(opens in a new tab)” as visually hidden text, so the accessible name starts with the visible words. |
| Announcements4.1.3 | Not applicable. Links navigate; the destination announces itself. |
| Reflow and text spacing1.4.10, 1.4.12 | Inline links wrap with their sentence. Standalone links use min-height 44px. |
| Use of color1.4.1 | Underlines, not color, identify inline links. Color alone would fail for people who can’t see the blue against ink-900 body text. |
uxp-color-text-linkuxp-color-text-link-hoveruxp-color-text-link-visiteduxp-focus-ring-coloruxp-target-mdNavigation
Switches between views of the same thing without leaving the page. Three to seven tabs, horizontal only: a vertical list of destinations is Side nav.
Overview panel. Who the cohort is for and how it runs.
Curriculum panel. Week by week, in one list.
Mentors panel. Who teaches and reviews.
Pricing panel. What it costs and what’s included.
Questions panel. The answers people ask for before applying.
Tab into the row, then use the arrow keys, Home and End. Tab again moves into the panel. Below 768px the row scrolls with a fade on the trailing edge.Arrow keys move between tabs, Home and End jump to the ends, and Tab moves into the panel. Below 768px the row scrolls with a fade on the trailing edge.
role="tablist"), tabs (buttons), 3px active indicator in uxp-gradient-action, panels.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Unselected text ink-500 5.0:1, selected ink-900 16.4:1. Indicator gradient never drops below 4.0:1 on white, above the 3:1 a state indicator needs. |
| Keyboard2.1.1 | Tab enters the list at the selected tab, arrows move between tabs, Home and End jump, Tab leaves to the panel. |
| Focus2.4.3, 2.4.7 | Inset ring on the tab so it isn’t clipped by the scrolling row. Panels are focusable (tabindex="0") when they hold no focusable content. |
| Name, role, value4.1.2 | role="tab" with aria-selected and aria-controls; panels are role="tabpanel" with aria-labelledby; the list has an aria-label. |
| Announcements4.1.3 | Not needed. Screen readers announce the newly selected tab from its role and state. |
| Reflow and text spacing1.4.10, 1.4.12 | The row scrolls inside its own container at 320px rather than wrapping or pushing the page sideways. Tabs use min-height. |
| Use of color1.4.1 | Selected state is weight plus indicator, not hue. |
uxp-gradient-actionuxp-color-text-primaryuxp-color-text-secondaryuxp-color-border-subtleuxp-border-3uxp-target-mduxp-text-ui-button-mdNavigation
Moves through a long, ordered set. Two patterns: numbered and load more. There is no infinite scroll.
Showing 3 of 9
Numbered updates aria-current and announces “Page 3 of 12”. Load more keeps focus on the button and announces how many arrived; once everything is shown, focus moves to the first new item.Changing page updates aria-current and announces “Page 3 of 12”. Load more keeps focus on the button and announces how many arrived; when the last batch lands, focus moves to the first new item.
<nav aria-label="Pagination">, previous and next links, page links, gap marker, status line.?page=3. Load more for lists with a clear end. Below 480px numbered collapses to previous, “3 of 12”, next.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Page links blue-600 4.0:1, a documented exception, blue-700 on the muted hover fill 5.1:1. Current page white on blue-600 4.0:1, a documented exception. Status ink-500 5.0:1. |
| Keyboard2.1.1 | All links and buttons. Load more never traps focus and the footer stays reachable. |
| Focus2.4.3, 2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. Load more keeps focus on itself while items remain; when the last batch lands, focus moves to the first new item. |
| Name, role, value4.1.2, 2.4.4, 1.3.1 | Previous and next carry hidden text (“Previous page”). Page links read “Page 3”. The current page has aria-current="page". The gap is aria-hidden. |
| Announcements4.1.3 | “Page 3 of 12” and “3 more loaded, showing 6 of 9” go through the polite live region. |
| Reflow and text spacing1.4.10, 1.4.12 | Compact pattern below 480px; 44px targets at every size. |
| Consistent identification3.2.4 | Previous and next always sit at the ends with the same icons. |
uxp-color-text-linkuxp-color-action-primary-hoveruxp-color-bg-selecteduxp-color-text-inverseuxp-color-text-secondaryuxp-color-bg-muteduxp-target-mduxp-radius-smNavigation
Opens the same page in another language. A disclosure button with a list of links, because changing language is navigation, and navigation is a link.
Each option is a link with lang and hreflang, so a screen reader switches voice per option. Esc closes and returns focus to the button. In this demo, choosing a language shows a toast instead of navigating.Shown open. Each option is a link with lang and hreflang, so a screen reader switches voice per option. Esc closes the list and returns focus to the button.
aria-current), focus-visible.dir="rtl". The destination page sets lang and dir on <html>, so no extra announcement is needed.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Trigger follows secondary Button. Options ink-900 16.4:1, codes ink-500 5.0:1, and both hold on the muted hover fill (codes 4.7:1). |
| Keyboard2.1.1, 2.1.2 | Enter or Space opens; Tab moves through options; Esc closes and returns focus to the trigger; clicking outside closes. |
| Focus2.4.3, 2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2, 2.5.3 | Trigger name “Language: English” contains its visible text. aria-expanded and aria-controls on the trigger. The current option has aria-current="true". |
| Announcements4.1.3 | Not needed. The new page announces its own language through <html lang>. |
| Reflow and text spacing1.4.10, 1.4.12 | List min-width 240px and capped at the viewport; long names wrap. |
| Language of parts3.1.2 | Every option carries lang, so “العربية” is read with an Arabic voice. Switching happens only when a link is followed, never while arrowing (3.2.2). |
uxp-color-bg-surfaceuxp-color-border-subtleuxp-elevation-3uxp-layer-dropdownuxp-target-mduxp-color-text-secondaryInputs
Collects free text: names, emails, URLs, longer answers. The component that was rebuilt three times before Orbit existed. One component, one validation pattern, every form.
Leave a required field empty, or type an email without an @, then press Tab: the error appears on blur, never mid-keystroke. Submit with errors and focus moves to a summary that links to each field. Bottom row: disabled, read-only, and the one-time-code configuration.Errors appear on blur, never mid-keystroke. Submitting with errors moves focus to a summary that links to each field. Bottom row: disabled, read-only, and the one-time-code configuration.
uxp-text-ui-label, 14/500, ink-900), “(required)” or “(optional)” in ink-500, input (uxp-text-ui-input, 16/400), optional prefix, helper (body-sm), error (icon plus body-sm in red-700), character count.type="date" until a date picker earns its place. One-time code: inputmode="numeric" and autocomplete="one-time-code".| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Value ink-900 16.4:1, helper ink-500 5.0:1, error red-700 4.8:1. Placeholder ink-400 3.2:1, a documented exception. Edge ink-200 1.4:1, a documented exception. |
| Keyboard2.1.1 | Native inputs. Error summary links move focus to their field. |
| Focus2.4.3, 2.4.7 | Focus turns the edge into a 2px blue-600 line (4.0:1) with a soft uxp-focus-halo around it, and a transparent outline that becomes solid in forced-colors mode. On submit with errors, focus moves to the summary. |
| Name, role, value4.1.2, 1.3.1 | <label for> on every field. Helper and error joined through aria-describedby. Invalid fields set aria-invalid="true". Required fields use the required attribute plus visible “(required)” text. |
| Announcements4.1.3 | The character count is announced through the polite live region at 50, 20 and 0 characters left, not on every keystroke. |
| Reflow and text spacing1.4.10, 1.4.12 | Fields are full width up to 420px and never set a fixed height. |
| Input purpose and errors1.3.5, 3.3.1, 3.3.2, 3.3.3 | autocomplete on every personal field (name, email, url, one-time-code). Errors are identified in text, not just a red border, and each message suggests the fix. |
uxp-color-border-inputuxp-color-border-input-hoveruxp-focus-halouxp-color-text-placeholderuxp-color-status-error-fguxp-color-bg-disableduxp-color-bg-muteduxp-text-ui-labeluxp-text-ui-inputuxp-text-body-smuxp-target-mduxp-radius-smuxp-space-2uxp-space-4uxp-border-2Inputs
Picks one value from a list too long for radios.
Used to show prices in your currency.
A styled native select. The open list is the platform’s own, which is the most reliable listbox there is on phones and with screen readers.
<select> in a wrapper that draws the chevron (an icon mask filled with uxp-color-text-primary), helper, error. Same label, helper and error rules as Text field.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Text ink-900 16.4:1; edge ink-200 1.4:1, a documented exception; chevron uxp-color-text-primary 16.4:1. |
| Keyboard2.1.1 | Native: arrows, type-ahead, Enter, Esc. |
| Focus2.4.7 | Focus turns the edge into a 2px blue-600 line (4.0:1) with a soft uxp-focus-halo around it, and a transparent outline that becomes solid in forced-colors mode. |
| Name, role, value4.1.2, 1.3.1 | <label for>; helper and error via aria-describedby; aria-invalid on error. |
| Announcements4.1.3 | Not needed; the platform announces the value. |
| Reflow and text spacing1.4.10, 1.4.12 | Full width to 420px; min-height. |
| On input3.2.2 | Changing the value never submits a form or changes the page. Some browsers fire change while arrowing through a closed select, which is exactly why a select must never navigate. |
uxp-color-border-inputuxp-color-text-primaryuxp-focus-halouxp-text-ui-inputuxp-target-mduxp-radius-smInputs
Turns one or more independent options on or off.
Select all shows the mixed state when some topics are on. Clear every topic to see the group error, which is attached to the fieldset.Select all shows the mixed state while some topics are on. Clearing every topic shows the group error, which is attached to the fieldset.
uxp-radius-sm), check or mixed bar, label. The whole row is the hit area, at least 44px tall.<fieldset> with a <legend>. The rule (“Pick at least one”) sits above the options; the error sits below them.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Box edge ink-300 1.7:1, a documented exception; checked fill blue-600 4.0:1; white mark on blue-600 4.0:1; labels ink-900 16.4:1. |
| Keyboard2.1.1 | Native: Tab to each box, Space toggles. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2, 1.3.1 | Wrapping <label>; mixed state via indeterminate (read as “mixed” or “half checked”); fieldset and legend name the group; rule and error joined to the fieldset through aria-describedby. |
| Announcements4.1.3 | The group error is announced through the polite live region when it appears. |
| Reflow and text spacing1.4.10, 1.4.12 | Rows wrap; the label grows, the box stays 20px. |
| Error identification3.3.1, 3.3.2 | The rule is stated before anyone can break it, and the error says what to do. |
uxp-color-border-inputuxp-color-action-primaryuxp-color-action-on-primaryuxp-color-status-error-fguxp-color-bg-muteduxp-target-mduxp-border-2uxp-radius-smInputs
Picks exactly one option from a short list where every option should stay visible. Segmented control is built on it.
Tab enters the group at the selected option; arrow keys move the selection.Tab enters the group at the selected option; arrow keys move the selection.
| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Circle edge ink-300 1.7:1, a documented exception; dot blue-600 4.0:1; labels ink-900 16.4:1. |
| Keyboard2.1.1 | Native: Tab into the group, arrows change the selection. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2, 1.3.1 | Native radios sharing a name; fieldset and legend; rule joined with aria-describedby. |
| Announcements4.1.3 | Not needed; selection is announced natively. |
| Reflow and text spacing1.4.10, 1.4.12 | Stacked rows, labels wrap. |
| Labels3.3.2 | The legend asks the question in full. |
uxp-color-border-inputuxp-color-action-primaryuxp-target-mduxp-border-2Inputs
Turns a single setting on or off, and the change takes effect immediately. If it needs a Save button, it’s a Checkbox.
Tab to a switch and press Space. A screen reader hears “Captions on recordings, switch, off”, then “on”. The change is confirmed through the live region because it takes effect immediately.Space toggles a switch, and a screen reader hears “switch, on” or “off”. The change applies immediately and is confirmed through the live region.
uxp-radius-full, 2px border), thumb (16px). The label sits at the inline start; the switch at the inline end.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Off: ink-300 edge 1.7:1, a documented exception and ink-400 thumb 3.2:1 against white. On: blue-600 track 4.0:1 against the page, white thumb 4.0:1 against the track. Labels ink-900. |
| Keyboard2.1.1 | A native checkbox underneath: Tab to it, Space toggles. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. The ring wraps the track. |
| Name, role, value4.1.2 | <input type="checkbox" role="switch"> inside its label, read as “switch, on” or “off”. Hints and disabled reasons are joined with aria-describedby. |
| Announcements4.1.3 | Because the change applies immediately, the result (“Captions turned on”) is confirmed through the polite live region; a failure is announced and shown inline. |
| Reflow and text spacing1.4.10, 1.4.12 | Labels wrap; the switch keeps its size at the inline end; rows use min-height. |
| Use of color1.4.1 | State is the thumb’s position as well as the fill. Forced-colors mode falls back to the native checkbox. |
uxp-color-action-primaryuxp-color-action-on-primaryuxp-color-border-inputuxp-color-control-thumbuxp-color-bg-disableduxp-radius-fulluxp-border-2uxp-motion-duration-smuxp-target-mdInputs
Switches between two to four mutually exclusive modes of one view, like table density.
A radio group underneath, so arrow keys move the selection and screen readers announce “Past, radio button, 2 of 3”.A radio group underneath, so arrow keys move the selection and a screen reader announces “Past, radio button, 2 of 3”.
uxp-radius-sm), segments (native radios, visually replaced), selected segment (blue-600 fill, white text).| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Labels ink-900 16.4:1; selected white on blue-600 4.0:1, a documented exception; the selected fill itself 4.0:1 against the track. |
| Keyboard2.1.1 | Native radio behavior: Tab in, arrows move. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. The ring is drawn on the visible segment. |
| Name, role, value4.1.2 | Native radios in a fieldset with a legend; the selected state is native checked. |
| Announcements4.1.3 | If the change updates content elsewhere (a table), announce the result (“Showing compact rows”). |
| Reflow and text spacing1.4.10, 1.4.12 | The track wraps at 320px. |
| Use of color1.4.1 | Selected is a filled segment, a change in both fill and text color, not hue alone. |
uxp-color-bg-selecteduxp-color-text-inverseuxp-color-border-inputuxp-target-mduxp-radius-smInputs
Finds things by typing, with suggestions and filters. The input is a combobox; the rest is a contract about what happens as you type.
This search works on this page. Ctrl K (or Cmd K) focuses it from anywhere. Arrows move through suggestions, Enter jumps to the section, Esc closes the list, a second Esc clears. Each filter chip is its own button.Ctrl K or Cmd K focuses search from anywhere. Arrows move through suggestions, Enter jumps to the section, Esc closes the list and a second Esc clears it. Each filter chip is its own button.
role="search" landmark on the form, visible label, input with leading icon and shortcut hint, suggestion listbox, filter chips, result status.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Input as Text field. Active suggestion white on blue-600 4.0:1, a documented exception. Chips are identified by their text, ink-900 on ink-100 (14.1:1), so they need no edge. |
| Keyboard2.1.1, 2.1.2 | Everything above works without a pointer. Chips are focusable buttons. |
| Focus2.4.3, 2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. Focus stays in the input while aria-activedescendant tracks the highlighted suggestion. |
| Name, role, value4.1.2 | Form is the search landmark. Input is role="combobox" with aria-expanded, aria-controls, aria-autocomplete="list" and aria-activedescendant. Suggestions are role="option" with aria-selected. Chips read “Components, remove filter”. |
| Announcements4.1.3 | Result counts go to the polite live region, debounced to once every 400ms so typing isn’t interrupted. |
| Reflow and text spacing1.4.10, 1.4.12 | Full width to 520px; suggestions wrap; chips wrap. |
| Character key shortcuts2.1.4 | No single-key shortcuts. Ctrl K and Cmd K include a modifier, so they can’t fire while someone dictates or types elsewhere. |
uxp-color-bg-selecteduxp-color-border-inputuxp-elevation-3uxp-layer-dropdownuxp-radius-fulluxp-target-mduxp-target-smFeedback
An inline message about the page or section it sits in. It stays until the situation changes or someone dismisses it.
Each status has its own icon shape, so the message never depends on hue. Dismissing the first banner moves focus to the control that replaces it, instead of dropping focus on the page body.
uxp-radius-sm. No border and no stripe: the icon and title carry the status.uxp-color-text-link-on-tint and stay underlined.role="status" (info, success) or role="alert" (error only).| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Text ink-900 on every tint (14.9:1 or better). Links use uxp-color-text-link-on-tint, blue-700 (4.9:1 or better). Icons use the status -on-tint tokens: success 4.5:1, error 5.8:1, warning 4.5:1, info 5.0:1. |
| Keyboard2.1.1 | Links and dismiss are native controls. |
| Focus2.4.3, 2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. On dismiss, focus moves to the next logical control, never to <body>. |
| Name, role, value4.1.2, 1.1.1 | Dismiss is named for what it dismisses (“Dismiss: Export failed”). Status icons are decorative because the title states the status in words. |
| Announcements4.1.3 | Inserted banners use role="status", or role="alert" for errors that block progress. |
| Reflow and text spacing1.4.10, 1.4.12 | Grid layout; text wraps; no fixed height. |
| Use of color1.4.1 | Status is carried by icon shape and title, not by tint. Warning is yellow (48°), well away from brand orange (31°), and always shows the triangle. |
uxp-color-status-info-tintuxp-color-status-info-on-tintuxp-color-status-success-tintuxp-color-status-warning-tintuxp-color-status-error-tintuxp-color-text-link-on-tintuxp-radius-smFeedback
A short confirmation of something someone just did. It never carries the only copy of important information.
A toast without an action stays at least 6 seconds and pauses while hovered or focused. A toast with Undo stays until it is used or dismissed. Neither steals focus.Shown: a toast with Undo, which stays until it is used or dismissed. Toasts without an action stay at least 6 seconds and pause while hovered or focused. Neither takes focus.
uxp-radius-sm, inline-end bottom corner, uxp-layer-toast.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Message ink-900 16.4:1 on white. Undo is a tertiary button, blue-600 4.0:1, a documented exception. The surface is lifted by elevation, not by a dark fill. |
| Keyboard2.1.1 | Undo and dismiss are buttons, reachable with Tab. The toast region sits at the end of the page in DOM order. |
| Focus2.4.3, 2.4.7 | Focus is never moved to a toast. Its buttons use the standard focus ring. |
| Name, role, value4.1.2 | Dismiss reads “Dismiss notification”. Undo reads “Undo”, followed by the message through the live region. |
| Announcements4.1.3 | The message is announced through the system’s polite live region, which exists on page load, so it is read reliably. The buttons stay out of the announcement. |
| Reflow and text spacing1.4.10, 1.4.12 | Max width 400px, full width minus margins below 480px. |
| Timing2.2.1 | Nothing essential auto-dismisses. Anything with an action waits for the person; anything without one pauses while they’re looking at it. |
uxp-color-bg-surfaceuxp-color-text-primaryuxp-color-border-subtleuxp-elevation-3uxp-layer-toastuxp-radius-smuxp-motion-duration-mduxp-motion-ease-out-expoFeedback
A short, static label for status or category.
Text on a tint uses the status -on-tint token, which is why Beta reads at 5.0:1.
uxp-radius-sm. No dot: the word carries the status.uxp-color-status-neutral-tint and -on-tint), info, success, warning, error (status tint and -on-tint).| Check | Implementation |
|---|---|
| Contrast1.4.3 | Text on tint: success 4.5:1, error 5.8:1, warning 4.5:1, info 5.0:1, neutral ink-700 on ink-100 7.8:1. |
| Keyboard2.1.1 | Not applicable. Badges are static text and are not focusable. |
| Focus2.4.7 | Not applicable. |
| Name, role, value4.1.2, 1.3.1 | Plain text. The dot is aria-hidden. In tables, the column header gives the badge its context. |
| Announcements4.1.3 | If a badge changes after load (Draft to Stable), announce the change through the live region. |
| Reflow and text spacing1.4.10, 1.4.12 | No fixed width; padding, not height. |
| Use of color1.4.1 | Every badge has a text label. Color is a second cue, never the only one. |
uxp-color-status-neutral-tintuxp-color-status-neutral-on-tintuxp-color-status-success-tintuxp-color-status-success-on-tintuxp-radius-fulluxp-space-1uxp-space-3Feedback
A short text label or description that appears on hover and focus. Never interactive, never the only place information lives.
Hover or Tab to each button. The first tooltip is the icon button’s name; the second adds a description. Esc hides a tooltip without moving focus, and the pointer can move onto it without it vanishing.Both tooltips shown. The first is the icon button’s name; the second adds a description. Esc hides a tooltip without moving focus, and the pointer can move onto it without it disappearing.
uxp-radius-sm), 8px gap with an invisible bridge so the pointer can cross it.aria-labelledby). Description: adds a short note to a labelled control (aria-describedby).| Check | Implementation |
|---|---|
| Contrast1.4.3 | White on ink-700 9.0:1. |
| Keyboard2.1.1 | Appears on keyboard focus, not just hover. |
| Focus2.4.7 | The trigger keeps its own focus ring; the tooltip never takes focus. |
| Name, role, value4.1.2 | role="tooltip", connected with aria-labelledby (label use) or aria-describedby (description use). |
| Announcements4.1.3 | Not needed; the relationship attribute does the work. |
| Reflow and text spacing1.4.10, 1.4.12 | Bubble wraps at 240px when space is tight. |
| Content on hover or focus1.4.13 | Dismissible (Esc), hoverable (pointer can move onto it), persistent (stays until hover or focus leaves). |
uxp-color-bg-inverseuxp-color-text-inverseuxp-layer-tooltipuxp-elevation-3uxp-radius-smContainers
Groups one item’s content so it can sit in a collection. Four variants, because the structure inside genuinely differs.
A handbook on writing case studies that show thinking, not process.
Image-first card. Crops at 16:9.
182
Values, text styles and breakpoints.
“Decisions are made once in the system, so they aren’t made again on every screen.”
The first card is clickable: its title is the link, stretched over the card. Nothing else on the card is hidden from screen readers.
uxp-radius-sm), optional media (16:9), title (<h4> or the level that fits the page), body, meta.uxp-space-6 (24px). Feature: uxp-space-8 (32px). Media image: none, full bleed.::after stretches over the card, so the whole card is the hit area while the name stays the title. Focus ring draws on the stretched area. Resting elevation-1, hover elevation-2. Cards never nest.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Title ink-900 16.4:1, body and meta ink-500 5.0:1. The ink-100 hairline is decorative: the card’s identity comes from its title, so 1.4.11 does not require 3:1 here. |
| Keyboard2.1.1 | One tab stop per clickable card (the title link). Cards with several actions are not clickable as a whole. |
| Focus2.4.7 | Ring on the stretched link area, 2px blue-600 at 2px offset. |
| Name, role, value4.1.2, 1.3.1 | Cards are <article>; quotes are <figure>, <blockquote> and <figcaption>. Nothing is aria-hidden except decoration. |
| Announcements4.1.3 | Not applicable. |
| Reflow and text spacing1.4.10, 1.4.12 | Grid of minmax(240px, 1fr) collapses to one column; cards grow with content. |
| Non-text content1.1.1 | Media that carries meaning gets alt text; decorative media gets empty alt. The media card above describes what the image shows. |
uxp-color-bg-surfaceuxp-color-border-subtleuxp-elevation-1uxp-elevation-2uxp-space-6uxp-space-8uxp-radius-smuxp-focus-ring-colorContainers
Shows and hides sections of long, parallel content. Built on the heading-plus-button pattern so each section is a real heading.
When the content earns the visual weight of the page around it. A card with eight items in it is a section that hasn’t noticed it is a section.
Eyebrows, token names and data had nowhere to go but italics or all caps. A mono accent gives them their own voice without a fourth face.
When the honest answer to “which version of this do I copy?” is “I don’t know”.
Single-expand. Each question is a heading, so screen reader users can jump between them with the H key. Animation uses the motion tokens and collapses to an instant change under reduced motion.Single-expand. Each question is a heading, so screen reader users can jump between them. Motion uses the motion tokens and becomes instant under reduced motion.
grid-template-rows from 0fr to 1fr over uxp-motion-duration-md (280ms) on uxp-motion-ease-out-expo; the chevron shares the curve.<summary> loses its heading role in several screen readers. Eyebrow labels never go inside the button, where they would be read as part of every name.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Questions ink-900 16.4:1, answers ink-500 5.0:1, chevron ink-900. |
| Keyboard2.1.1 | Each header is a native button: Tab moves between them, Enter or Space toggles. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2, 1.3.1 | Heading wraps a button with aria-expanded and aria-controls. Panels are labelled regions and are hidden when closed, so their content leaves the reading order. |
| Announcements4.1.3 | Not needed; expanded state is announced from aria-expanded. |
| Reflow and text spacing1.4.10, 1.4.12 | The grid-rows technique never measures height, so enlarged text can’t be clipped by a stale pixel value. |
| Motionbeyond AA | Reduced motion makes the change instant (2.3.3 is AAA; Orbit does it by default). |
uxp-motion-duration-mduxp-motion-ease-out-expouxp-color-border-subtleuxp-target-mduxp-color-text-secondaryContainers
Interrupts to ask for a decision or a short task, then gets out of the way. Built on the native <dialog> element.
The delete confirmation is an alertdialog and opens on “Keep editing”, the safe choice. Focus returns to the button that opened it. The background is inert while either is open.Shown: the delete confirmation, an alertdialog that opens on “Keep editing”, the safe choice. Focus returns to the button that opened it, and the page behind is inert while it is open.
uxp-color-bg-overlay), container (uxp-radius-sm, elevation-4, 1px transparent border that becomes visible in forced-colors mode), header with title and close, body (max 60ch), footer (ink-50, actions at the inline end).showModal(), which makes the rest of the page inert. Esc closes. Backdrop click closes sm and md only. Body scrolls inside, the page doesn’t. Focus returns to the trigger, or to a sensible fallback if the trigger is gone.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Title ink-900, body ink-900, footer actions per Button. Backdrop is decorative. |
| Keyboard2.1.1, 2.1.2 | Tab cycles within the dialog; Esc always closes, so focus is contained but never trapped. |
| Focus2.4.3, 2.4.7 | Initial focus by the rules above; focus returns to the trigger on close. 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. |
| Name, role, value4.1.2 | <dialog> named by its title (aria-labelledby) and described by its body for confirmations (aria-describedby). Destructive confirmations use role="alertdialog". |
| Announcements4.1.3 | Opening is announced from the role and name. The result of the action (“Draft deleted”) goes through the live region after close. |
| Reflow and text spacing1.4.10, 1.4.12 | Width capped at the viewport minus 32px; height capped with internal scroll; full-screen sheet for lg below 480px. |
| Error prevention3.3.4 | Irreversible deletion always asks first and names what will be lost. |
uxp-color-bg-overlayuxp-elevation-4uxp-layer-modaluxp-color-bg-muteduxp-radius-smuxp-space-6uxp-motion-duration-mdData
Compares rows of structured data. Orbit standardizes the layout and behavior; each screen supplies its own column definitions.
1 selected
| Value | Actions | ||||
|---|---|---|---|---|---|
uxp-color-action-primary | Semantic | #0068C8 | Stable | View uxp-color-action-primary | |
uxp-color-border-input | Semantic | #8E8E93 | Stable | View uxp-color-border-input | |
uxp-gradient-text | Semantic | 600 / 500 / 600 | Stable | View uxp-gradient-text | |
uxp-motion-duration-md | Primitive | 280ms | Stable | View uxp-motion-duration-md | |
uxp-color-control-thumb | Semantic | #6E6E73 | Beta | View uxp-color-control-thumb | |
uxp-color-status-neutral-on-tint | Semantic | #48484D | Draft | View uxp-color-status-neutral-on-tint |
Sort by Token, Tier or Status (announced, and reflected in aria-sort). Select rows to reveal the bulk bar; Deprecate is undoable from its toast. The scroll region is focusable so keyboard users can scroll it.Token, Tier and Status sort, announced and reflected in aria-sort. Selecting rows reveals the bulk bar; Deprecate can be undone from its toast. The scroll region is focusable so keyboard users can scroll it.
<table> with a caption, header row, row headers, cells.uxp-text-mono-data (13/400 tabular). Status: Badge. Action: text link at the row end, named with the row (“View token”). Selection: checkbox column, 52px.display:block; several browsers then drop the table semantics.| Check | Implementation |
|---|---|
| Contrast1.4.3, 1.4.11 | Cells ink-900 16.4:1, headers ink-500 on ink-50 4.7:1. View links blue-600 4.0:1, a documented exception. Selected rows sit on uxp-color-bg-selected-subtle, where ink-900 holds at 15.1:1 and row links switch to blue-700 (5.0:1). Badges per Badge. |
| Keyboard2.1.1 | Sort buttons, checkboxes, links and the scroll region are all reachable with Tab. |
| Focus2.4.7 | 2px uxp-focus-ring-color (blue-600, 4.0:1) outline at 2px offset on :focus-visible. Outline, not box-shadow, so it survives forced-colors mode. The scroll region has its own ring. |
| Name, role, value4.1.2, 1.3.1 | Native <table>, <caption>, scope="col" and scope="row". Sorted column carries aria-sort. Each row checkbox is named “Select token”. Select-all shows mixed when partial. |
| Announcements4.1.3 | “Sorted by Token, descending”, “2 rows selected” and bulk results go through the live region. |
| Reflow and text spacing1.4.10, 1.4.12 | Two-dimensional data is exempt from reflow, so horizontal scroll inside the region is allowed; the page itself never scrolls sideways. |
| Error prevention3.3.4 | Bulk Deprecate is reversible: its toast offers Undo and stays until used or dismissed. |
uxp-text-mono-datauxp-color-bg-muteduxp-color-bg-selected-subtleuxp-color-text-link-on-tintuxp-color-border-subtleuxp-color-action-secondary-textuxp-radius-smuxp-target-smPart 4 of 5
Orbit has a single owner today. As UXPective moves toward senior contributors running parts of the platform, this model lets contributors propose, build and review components while the system stays consistent.
| Role | Today | Responsible for |
|---|---|---|
| Owner | Shanica | Foundations, token values, final decisions on proposals |
| Contributor | Senior contributors, as they join | Proposing, building, documenting and testing components against the acceptance criteria |
| Accessibility reviewer | A contributor who didn’t build the component | Signing off the AA checklist and the test protocol. Authors never certify their own work. |
| Consumer | Anyone building a UXPective screen | Using the library, reporting gaps, proposing patterns with evidence |
| Change | Who decides | Review |
|---|---|---|
| Foundation or token value | Owner | One contributor |
| New component or variant | Owner | One contributor and the accessibility reviewer |
| Fix that changes no value or behavior | Any contributor | One other contributor |
| Documentation | Any contributor | Owner reviews afterwards |
Every component and token carries one of four states, shown as a badge on its docs page.
| State | Means | Can screens use it? |
|---|---|---|
| Draft | Being built against the acceptance criteria | No |
| Beta | Meets the acceptance criteria, not yet proven on shipped screens; details may change | Yes, with the owner informed |
| Stable | Proven on shipped screens; changes go through review | Yes |
| Deprecated | Being replaced; points to its replacement | No new uses |
Deprecation is never a surprise. A deprecated component or token stays available for at least 90 days, shows a banner on its docs page, logs a warning in code, and names exactly what replaces it. It is removed only once no shipped screen uses it.
Patterns that have been proposed but don’t meet the entry criteria yet, with what each one needs.
| Pattern | Covered today by | Needed to proceed |
|---|---|---|
| Date picker | The native date input (a Text field configuration) | A range-selection use case on a shipped screen |
| Popover | Tooltip for text, Menu for actions | Interactive content that has to float and isn’t a list of actions |
| Stepper | One page per step, each with its own URL | A multi-step flow where people need to see every step at once |
| Breadcrumb | Side nav and back links | A surface more than two levels deep |
These signals show whether screens are built from Orbit or drifting back to one-off decisions.
| Signal | Measured by |
|---|---|
| Token coverage | Share of color, space and type values in code and Figma that resolve to a token, from the lint and Figma’s detached-style count |
| One-offs | Local components living outside the library |
| Detached instances | Figma instances detached from the library |
| Checklist coverage | Components with all seven accessibility rows complete and the protocol passed (22 of 22) |
| Time to compose a screen | One day for a screen built from existing components |
Part 5 of 5
How Orbit moved from a library to the default way UXPective screens are built.
The internal docs page reads in the same order as Part 4. Writing it is what surfaces the holes: the moment a usage rule can’t be described cleanly, the component isn’t done.
Same component, prop and variant names. They drift the moment a Figma-only variant appears for one mock, so variants follow the contribution process in both places.
Screens moved onto Orbit one at a time, starting with the hardest to maintain. The slowest were the ones with the most one-off layout.
A new screen takes a day, down from about five. “A day” means a screen composed from existing components with new copy and imagery; a screen with a new interactive module still takes most of the week, and that’s the right number. There is one blue, one text field, and no wrong version of the gradient.
Accessibility moved into the foundations. Every component has an accessibility checklist, every contrast pair is calculated from the tokens, and the few colors that are lighter than AA on purpose are listed as documented exceptions rather than left implicit.
New component ideas still come up on most screens. The entry criteria keep the library small, and proposals that don’t meet them go to the backlog.
Focus, contrast and announcements touch every component. Built into tokens and system behavior, every component inherits them instead of each one being retrofitted.
I started with 60 icons and 22 were used. The rest made the set harder to scan, so the library now holds 36.
Cards and modals get used where they don’t belong unless “when not to use” is written first, so it is part of the acceptance criteria.
Typed ratios drift from the tokens they describe. Every ratio on this page is calculated when it’s built, and exceptions are listed explicitly.
A remap of the semantic layer, not a redesign, because components never touch primitives. Every gradient stop re-tuned for near-black, every status family re-balanced to stay equal in weight.
Cohort documents use a sibling style (Roboto, maroon headings, a gradient-line header) that isn’t in Orbit yet. Until it is, the one-identity claim covers digital screens only. Next, documents join as a theme on the same primitives.
The token lint and detached-instance reports run automatically on every change, before a reviewer looks at it.
Orbit is maintained as a living system: foundations change through the contribution process, and components move through the lifecycle above.