Foundations

The decisions everything else inherits. Components reference only the semantic layer, which is why switching themes is a token remap rather than a second design pass.

Color #

Two layers. Raw ramps hold the palette and never change between modes; semantic aliases name a job — --bg-surface, --danger-text — and re-point per mode. Components reference only the second layer, which is what makes dark mode a token remap instead of a second design pass.

Never reference a ramp step from a component. --gray-400 is correct in exactly one place — the light-mode value of --text-disabled — and wrong everywhere else, because it cannot know which mode it is in.

The same aliases, both themes, at once
light pinned — ignores the page toggle
canvas
surface
raised
subtle
muted
inverse
Cohort 12-B · interim analysis text-primary
Screening complete, 1,204 subjects enrolled. text-secondary
Last synced 08:41 UTC text-tertiary
Export locked until sign-off text-disabled
Run analysis Cancel
SuccessWarningDangerInfo
dark pinned — ignores the page toggle
canvas
surface
raised
subtle
muted
inverse
Cohort 12-B · interim analysis text-primary
Screening complete, 1,204 subjects enrolled. text-secondary
Last synced 08:41 UTC text-tertiary
Export locked until sign-off text-disabled
Run analysis Cancel
SuccessWarningDangerInfo

Both panels render identical markup. Nothing inside either one names a colour: every fill is var(--bg-surface), var(--accent), var(--danger-text). Only the data-theme attribute on the wrapper differs. Because [data-theme] matches any element rather than only body, a subtree can be pinned to one mode while the rest of the page follows the toggle in the header.

Layer 1 — raw scales
Gray --gray-* Every surface, border and text colour in both modes.
  • 0
  • 25
  • 50
  • 100
  • 200
  • 250
  • 300
  • 400
  • 500
  • 600
  • 700
  • 800
  • 850
  • 900
  • 925
  • 950
  • 975
Blue --blue-* Primary action in light mode. Links in both modes.
  • 50
  • 100
  • 200
  • 300
  • 400
  • 450 ·
  • 500
  • 600
  • 700
  • 750 ·
  • 800
  • 900
  • 950
Lime --lime-* Primary action in dark mode. Never used in light.
  • 50
  • 100
  • 200
  • 300
  • 350 ·
  • 400
  • 450 ·
  • 500
  • 600
  • 700
  • 800
  • 900
  • 950
Green --green-* Success. Run completed, sample passed QC, changes saved.
  • 50
  • 100
  • 200
  • 300
  • 400
  • 500
  • 600
  • 700
  • 800
  • 900
  • 950
Amber --amber-* Warning. Recoverable, but the user should look.
  • 50
  • 100
  • 200
  • 300
  • 400
  • 500
  • 600
  • 700
  • 800
  • 900
  • 950
Red --red-* Danger. Destructive actions and blocking errors.
  • 50
  • 100
  • 200
  • 300
  • 400
  • 500
  • 600
  • 700
  • 800
  • 900
  • 950
Sky --sky-* Informational. A separate hue from blue so an info banner never reads as clickable.
  • 50
  • 100
  • 200
  • 300
  • 400
  • 500
  • 600
  • 700
  • 800
  • 900
  • 950

The gray ramp is finer-grained at the dark end — 850, 900, 925, 950, 975 — and coarser at the light end. Dark UIs build elevation by getting lighter, and the usable distance between “canvas” and “raised surface” is only a few RGB steps before two surfaces stop reading as distinct. Light mode has the whole range up to white, so it needs fewer stops. The four steps marked with a dot (--blue-450, --blue-750, --lime-350, --lime-450) are hand-tuned for one job each — a dark-mode link that clears AA on near-black, and pressed states one step past hover — rather than generated for scale symmetry.

Layer 2 — semantic aliases
Alias Job Light Dark
--bg-canvas The page itself. Nothing sits behind it. --gray-25 --gray-975
--bg-surface The default panel: cards, tables, every preview frame on this page. --gray-0 --gray-950
--bg-surface-raised A surface floating above another surface — menus, popovers, modals. --gray-0 --gray-900
--bg-subtle Recessed fill: hovered rows, code blocks, table headers. --gray-100 --gray-925
--bg-muted One step further down: disabled fills, slider and progress tracks. --gray-250 --gray-900
--bg-inverse A block deliberately opposite the page — tooltips, inverse callouts. --gray-950 --gray-50
--border-subtle Divider inside a component, where structure rather than separation is the point. --gray-100 --gray-900
--border-default The standard hairline around a surface. --gray-200 --gray-850
--border-strong A border that has to be seen: input outlines, ghost buttons. --gray-300 --gray-800
--text-primary Headings and anything carrying the main message. --gray-950 --gray-50
--text-secondary Body copy and supporting prose. --gray-700 --gray-400
--text-tertiary Labels, captions, metadata. Present, not competing. --gray-600 --gray-500
--text-disabled Text in a control the user cannot act on. Below AA on purpose — WCAG exempts inactive controls, and the low contrast is the signal. --gray-400 --gray-600
--text-on-primary Text and icons sitting on an accent fill. --gray-0 --gray-975
--text-on-inverse Text sitting on --bg-inverse. --gray-50 --gray-950
--text-link Inline links. Stays blue in dark mode so lime is reserved for actions. --blue-600 --blue-450
--accent Primary action fill. The one colour a screen is allowed to compete for. --blue-600 --lime-400
--accent-hover Accent under the pointer. --blue-700 --lime-350
--accent-press Accent while the pointer is down. --blue-750 --lime-450
--accent-fg Label on the accent fill. Flips to near-black in dark because lime is a bright fill. --gray-0 --gray-975
--accent-muted Low-alpha accent wash: text selection, active nav item, tinted focus fill. rgba(37, 99, 235, 0.1) rgba(163, 230, 53, 0.14)
--accent-glow Outer bloom on a hovered primary button. rgba(37, 99, 235, 0.3) rgba(163, 230, 53, 0.35)
--overlay Scrim behind a modal or drawer. rgba(22, 24, 29, 0.5) rgba(0, 0, 0, 0.65)
--focus-ring The 2px focus outline. Points at --accent so no component invents its own focus colour. --accent --accent
Status families
Success --success-*

QC passed · 384/384 wells

Warning --warning-*

Plate 7 flagged for re-read

Danger --danger-*

Import failed · 12 rows rejected

Info --info-*

Cohort definition updated 2h ago

Role What it is for
-solid Fill for a filled control — a destructive button, a status dot.
-fg Text and icons that sit on that fill.
-bg Tint behind a banner, badge or table row.
-border The hairline that pairs with the tint.
-text The status colour as text on the page canvas. AA-checked.

Three deliberate exceptions, all of them contrast decisions rather than taste. Info is sky, not blue: blue is the light-mode action colour, so an info banner painted blue reads as interactive. --warning-fg is near-black in both modes — amber is too luminous to carry white text at any usable step, so it is the one family that breaks the white-on-fill pattern. And --danger-solid is red-700 rather than the more common red-600: white on red-600 measures 4.31:1 and misses AA for normal text, while one step darker clears it at 5.75:1. A destructive button is the last place to ship a near-miss.

Do

Reach for the alias that describes the job — --bg-surface-raised for a popover, --danger-text for an error message. If no alias fits, the gap is in theme.css and belongs there, where both modes get fixed at once.

Don't

Don't hard-code a hex or a ramp step in a component, and don't patch a colour with a [data-theme='dark'] override. Both work in the mode you are looking at and quietly break the other one.

Typography #

One sans family for everything a person reads and one mono family for everything a machine produced — token names, sample IDs, counts, code. Eight sizes, three weights, and the root is 10px so 1rem = 10px throughout.

Do not introduce a size between two steps because a heading is two pixels too tall in one layout. Eight steps is already generous; a ninth exists only to serve one screen and then spreads.

Scale
h1
A design system you can click
h2
Cohort builder
h3
Eligibility criteria
h4
Inclusion rules
h5
Biomarker thresholds
h6
Assay reference

The samples above are divs carrying the heading styles, not real h1h6 elements. A documentation page that demonstrates a type scale with live headings ships a broken outline: eight h1s and headings out of order. The visual and the semantics are separable, so they are separated.

Text roles
body-lg

A lede sits directly under a title and carries the one sentence a reader will take away if they read nothing else.

body

Default body copy at 15px with a 1.6 line-height. Long-form paragraphs are capped around 68 characters, because a line longer than that loses the reader on the return sweep.

small

Secondary copy: helper text under a field, a table caption, the second line of a list row.

caption

Assay: IL-6 ELISA · Plate 07 · Read 450nm

label Cohort name
overline Foundations
code Sample SPL-1204-B7 failed the min_reads threshold.
link See the colour foundations for how link colour is chosen per mode.
Truncation
3-line clamp

Cohort 12-B was defined from the screening registry on 14 March and narrowed by two inclusion rules: an eGFR above 45 and at least one IL-6 reading above the assay's reference range within the previous ninety days. Subjects with an active oncology diagnosis were excluded at intake rather than filtered afterwards, which is why the enrolled count differs from the screened count by more than the withdrawal log explains.

Clamps at three lines, then ellipses. Full text stays in the DOM.
Token Value Computed Line-height Used for
--fs-h1 clamp(4rem, 3rem + 3vw, 6.4rem) 40 → 64px 1.1 Page title. One per page.
--fs-h2 clamp(2.8rem, 2.24rem + 1.4vw, 4rem) 28 → 40px 1.1 Section titles.
--fs-h3 2.4rem 24px 1.1 Sub-section titles.
--fs-h4 2rem 20px 1.1 Card titles, stat values.
--fs-body-lg 1.7rem 17px 1.6 Lede paragraph. Also the size h5 renders at.
--fs-body 1.5rem 15px 1.6 Default body copy. Also the size h6 renders at.
--fs-small 1.3rem 13px 1.55 Buttons, table cells, secondary copy.
--fs-micro 1.2rem 12px 1.4 Overlines, column headers, captions.
Property Value Notes
Family — UI and prose --font-sans Geist, then the system stack.
Family — values --font-mono Geist Mono. Token names, IDs, counts, code.
Weight — body 400 Paragraphs and table cells.
Weight — labels 500 Form labels, buttons, nav, active states.
Weight — headings 600 h1–h6. There is no 700 in the system.
Tracking — headings --track-tight (-0.02em) Large type sets loose by default; this pulls it back.
Tracking — mono caps --track-mono (0.08em) Uppercase mono is unreadable without it.

Truncation needs three properties together — display: -webkit-box, -webkit-box-orient: vertical and -webkit-line-clamp — plus overflow: hidden. It is still the only cross-browser way to clamp by line count, and it is safe here because the full string stays in the DOM: screen readers and page search both see all of it.

Do

Pick the step by role — a card title is --fs-h4 whether it is 20px or not. When the design needs emphasis, change weight or colour before reaching for a larger size.

Don't

Don't set a bare px font-size. The root is 10px, so a stray `font-size: 14px` sits between two steps forever and drifts out of sync the moment the scale is retuned.

Spacing & grid #

A 4px base unit with ten steps. The scale goes linear at the small end, where a 4px difference is visible, and geometric at the large end, where it is not. Layout sits on a 1120px container and a 12-column grid.

Not every gap needs a token from the top of the scale. If a layout needs --space-9 between two related controls, the problem is the grouping, not the spacing.

Scale
  • --space-1 4px Icon-to-label inside a dense control.
  • --space-2 8px Label to field. Gap inside a button.
  • --space-3 12px Table cell padding. Chip padding.
  • --space-4 16px Padding inside a small card. Gap in a row of controls.
  • --space-5 24px Grid gutter. Padding inside a standard card.
  • --space-6 32px Preview stage padding. Gap between related blocks.
  • --space-7 48px Space between documented sections.
  • --space-8 64px Column gap in the page layout.
  • --space-9 96px Major break between unrelated content.
  • --space-10 128px Top-level page rhythm.
12-column grid
123456789101112
span 6 span 6
span 8 span 4
3 3 3 3
Layout token Value Job
--container 1120px Max content width. Centred, with gutters outside it.
--container-pad 24px Horizontal gutter at every viewport.
--section-pad clamp(64px, 10vh, 128px) Vertical rhythm between page sections.
--header-h 64px Fixed header height. Anchor offsets are derived from it.
Breakpoint Name What changes
(min-width: 640px) sm Do/don’t guidance goes two-up. State matrices gain a column. Side-by-side theme panels split.
(min-width: 1024px) lg The mobile bar is replaced by the fixed header, and this page becomes a sticky TOC rail plus content column.

Two breakpoints, both min-width, both written as native nested @media blocks inside the rule they modify rather than in a slab at the bottom of the file. Everything between them is fluid: the container is max-width, the grid is 1fr columns, and the two type steps that need to scale do it with clamp() and no query at all.

Do

Compose padding and gaps from the scale so vertical rhythm survives contact with real content. Grid gutters are --space-5 (24px), matching --container-pad, so the first column lines up with the page edge.

Don't

Don't split the difference with a 10px or 18px value. One off-scale gap is invisible; twenty of them are why a screen feels unfinished without anyone being able to say which part is wrong.

Elevation #

Three levels, expressed as --elevation-1/2/3. Use them for anything that has to read as floating above the page — cards, menus, modals, toasts.

Elevation is not a hierarchy label. A card is not more important because it is higher; it is higher because something can appear underneath it.

The same three levels, both themes
light
--elevation-1 Resting card, input, badge.
--elevation-2 Dropdown, popover, hovered card.
--elevation-3 Modal, drawer, toast.
dark
--elevation-1 Resting card, input, badge.
--elevation-2 Dropdown, popover, hovered card.
--elevation-3 Modal, drawer, toast.

Look at the two panels at the same time. In light mode the cards are separated from the canvas by shadow alone. On a near-black canvas that shadow is doing almost nothing — there is no light for it to remove — so dark mode carries depth two other ways: the surface steps lighter (--bg-surface to --bg-surface-raised) and --elevation-* adds a hairline via an inset-free 0 0 0 1px shadow layer. That is the whole difference between --elevation-* and --shadow-*: the shadow tokens are the raw ambient darkening, and the elevation tokens are the mode-aware recipe a component should actually reach for.

Token Light resolves to Dark resolves to Used for
--elevation-1 shadow-sm only hairline + shadow-sm Resting card, input, badge.
--elevation-2 shadow-md only hairline + shadow-md Dropdown, popover, hovered card.
--elevation-3 shadow-lg only strong hairline + shadow-lg Modal, drawer, toast.
Do

Use --elevation-* on components and let it resolve per mode. Pair a level with the matching surface: a popover is --bg-surface-raised at --elevation-2, not --bg-surface with a bigger shadow.

Don't

Don't reach for --shadow-lg directly on a dark surface and conclude elevation is broken. A shadow on a #0d0f13 canvas is invisible by construction; that is the problem --elevation-* exists to solve.

Radius #

Four values. Radius is how a component announces its size class: the smaller the element, the tighter the corner, so a 32px chip and a 480px modal do not share a curve.

Do not mix radii inside one component. A card at --radius-lg containing a button at --radius-lg reads as two cards; the button belongs at --radius-sm.

Scale
--radius-sm 6px
--radius-md 8px
--radius-lg 12px
--radius-full 999px
Token Value Applied to
--radius-sm 6px Buttons, inputs, selects, inline code, TOC links.
--radius-md 8px Guidance boxes, nested panels, menu items, alerts.
--radius-lg 12px Cards, preview frames, modals, drawers.
--radius-full 999px Pills, badges, avatars, switch tracks, jump-bar chips.

--radius-full is 999px rather than 50%. On a non-square element a percentage radius produces an ellipse that changes shape with the content; a large absolute value clamps to a true pill at any width and still renders a circle when the element is square.

Iconography #

Inline 24×24 SVGs on a 24-unit grid, stroked at 1.5 with round caps and joins, no fills. Three sizes. Every icon is inlined in the markup rather than loaded as a font or a sprite, so it inherits colour and can be tree-shaken with the component that uses it.

An icon on its own is not a label. Icon-only controls carry an aria-label; decorative icons next to visible text carry aria-hidden so a screen reader does not announce the same thing twice.

Sizes
--icon-sm 16px
Inside dense controls: chips, table cells, small buttons.
--icon-md 20px
Default. Buttons, nav items, form affordances.
--icon-lg 24px
Standalone: empty states, section markers, status headers.
Colour
Inherits text
Search cohorts
success
Run completed
warning
Needs review
danger
Import failed
info
Definition changed

The colour rule is narrow on purpose. An icon takes currentColor — whatever text colour surrounds it — unless the icon is the status: the tick in a success banner, the triangle on a flagged plate. A blue download arrow next to black text is decoration, and it costs the reader a moment deciding whether the colour means something.

Two implementation details make that work here. Stroke weight is set once in CSS from --icon-stroke rather than a stroke-width attribute per file, so the whole set can be re-weighted in one edit. And .ds-icon declares color: inherit — without it the universal selector in base.css assigns every svg its own colour and “inherits the surrounding text” stops being true.

Do

Keep the 24×24 viewBox at every size and scale with width/height from --icon-sm/md/lg. Give icon-only controls an aria-label that says the action, not the picture: 'Export cohort', not 'Download icon'.

Don't

Don't colour an icon to decorate it, and don't ship an icon-only control without a label. A magnifier with no accessible name is a button that only sighted users can find.

Motion #

Four durations and three easing curves. Motion here confirms a change — a menu opening, a card lifting, a theme flipping — and never carries information on its own.

Nothing waits on an animation. If a transition has to finish before the interface is usable, the duration is doing a job that belongs to a loading state.

Durations — same easing, four speeds
  • --dur-fast 150ms Colour changes on hover and focus.
  • --dur-base 200ms Theme switch, card lift, border transitions.
  • --dur-reveal 400ms Scroll-in reveals, overlay entrances.
  • --dur-slow 600ms Large travel: drawers, full-height panels.
Easings — same duration, three curves
  • --ease-out cubic-bezier(0.25, 0.46, 0.45, 0.94) The default. Anything responding to a click or hover.
  • --ease-out-expo cubic-bezier(0.16, 1, 0.3, 1) Entrances. Fast off the line, long settle — reads as confident rather than slow.
  • --ease-in-out cubic-bezier(0.4, 0, 0.2, 1) Symmetric movement: something that travels and comes back.

Both demos run all their rows at once, because a duration is only legible next to another duration. The buttons are toggles, not one-shots — they carry aria-pressed, so the runners' position is a state a screen reader can read rather than an event it missed.

Reduced motion is handled once, at the token layer. Under prefers-reduced-motion: reduce, theme.css redefines all four duration tokens to 0.01ms. Every transition in the codebase already reads its duration from those tokens, so the whole site goes still without a single component knowing the preference exists — and a component that hard-codes 0.3s is the only way to opt back out. Doing it per component instead means every new transition is a fresh chance to forget.

Token Value Used for
--dur-fast 150ms Colour changes on hover and focus.
--dur-base 200ms Theme switch, card lift, border transitions.
--dur-reveal 400ms Scroll-in reveals, overlay entrances.
--dur-slow 600ms Large travel: drawers, full-height panels.
--ease-out cubic-bezier(0.25, 0.46, 0.45, 0.94) The default. Anything responding to a click or hover.
--ease-out-expo cubic-bezier(0.16, 1, 0.3, 1) Entrances. Fast off the line, long settle — reads as confident rather than slow.
--ease-in-out cubic-bezier(0.4, 0, 0.2, 1) Symmetric movement: something that travels and comes back.
Do

Transition named properties — transform, opacity, background-color, box-shadow — with a duration and an easing token. Anything a user triggers should be at --dur-fast or --dur-base; longer belongs to entrances.

Don't

Don't write `transition: all`, and don't animate width, height or top/left when transform will do. Both hand the browser layout work on every frame, which is where dropped frames on a mid-range laptop come from.

Z-index #

Eight named layers in one place, spaced 100 apart so a value can be slotted between two without renumbering. Everything that overlaps — headers, menus, drawers, modals, toasts, tooltips — draws its stacking order from here.

A component should never set a bare z-index. The number that fixes it today is the number that traps a tooltip behind a modal six months from now.

Stacking order
  • --z-base 0 Page content
  • --z-raised 10 Sticky table header
  • --z-dropdown 100 Menus, popovers
  • --z-sticky 200 Site header, jump bar
  • --z-drawer 300 Drawer and scrim
  • --z-modal 400 Modal and scrim
  • --z-toast 500 Toasts — clear modals
  • --z-tooltip 600 Tooltips — always top

Every panel above takes its z-index straight from the scale — z-index: var(--z-drawer) and nothing else — so the diagram is the scale rather than a drawing of it. The order encodes decisions that are obvious one at a time and contradictory under pressure: a toast has to clear a modal, because a save failure that renders behind the dialog that caused it is a bug the user cannot even report; a tooltip has to clear a toast, because a tooltip is attached to whatever the user is pointing at right now.

The reason to centralise is arithmetic. Stacking is only comparable inside a stacking context, and a component that picks z-index: 9999 to win a specific fight is unarguable: the next component picks 10000. Eight names and a single scale make the question “which layer is this?” instead of “what number beats the other number?”

Do

Give every new overlay one of the eight names. If a genuinely new layer is needed, add it to the scale in theme.css with a value between its neighbours, and the whole system stays orderable.

Don't

Don't invent a local z-index, and don't create a stacking context — transform, filter, opacity below 1, will-change — on an ancestor of an overlay. The scale is correct and the overlay is still trapped inside its parent.

Actions

Everything that commits a user to something. One primary action per view — if two things compete for primary, the screen has not decided what it is for.

Buttons #

Commits the user to something: submitting a form, exporting a file, approving or destroying a record. One primary per view — if two controls compete for primary, the screen has not decided what it is for. Everything else steps down to secondary, or to tertiary when it should stay quiet until it is needed.

Not for navigation. If the control changes the URL or opens a document it is a link: assistive technology announces the two differently, and only a link supports middle-click, copy-address and browser history. Never reach for a button because it was the easier thing to style.

Variants
Variant × size
SmallMediumLarge Primary Secondary Tertiary Success Danger

Sizes are 32 / 40 / 48px. Medium is the default and needs no modifier. Small is a desktop-density control for table rows and toolbars — at 32px it is under the 44px pointer target touch needs, so it is the wrong default on a phone.

With icons
States
Default
Hover
Focus visible
Pressed
Disabled
Loading — click to run
Ready

The loading state does not change the button's width. The label keeps its box and only turns color: transparent, so the intrinsic width is locked and the spinner is centred on top of it — a button that grows or shrinks the moment it is pressed drags every neighbouring control out from under the cursor. Keeping the text in flow also keeps it in the accessibility tree, so the button still announces its own name alongside aria-busy. Interaction is blocked with aria-disabled rather than the disabled attribute, which would drop the button out of the tab order mid-task and silently move the user's focus.

Do

Give the primary action the most specific verb available — 'Export cohort', not 'Submit'. It is the string a user re-reads before committing, and it should name the outcome.

Don't

Don't put two primary buttons in one view, and don't use the danger variant for anything reversible. The moment red stops meaning 'this cannot be undone', it stops working everywhere else.

Icon button #

A button whose only label is an icon. It earns its place in dense toolbars, table rows and card headers, where a text label would push the content being acted on off the screen — and only for actions with a settled visual convention: close, search, download, delete. It is the same .ds-btn as above with its padding removed, so it inherits every variant and every state rather than re-deriving them.

The accessible name is not optional, and the choice between forms is about recognition: a bare aria-label is enough for a glyph a user already knows in a row that repeats (kebab, close, copy), while any icon whose meaning has to be guessed needs the visible tooltip on hover and focus too. If the action is primary or destructive, give it a text label instead — an icon is a poor place to learn what 'archive' does to a run.

Sizes
Variants
Named by tooltip, or by aria-label alone

The tooltip is marked aria-hidden. It repeats the button's aria-label verbatim, so exposing both would have a screen reader read the name twice. Keeping the two strings identical is the rule: the moment the visible name and the spoken name disagree, voice-control users can no longer say what they can see. The tooltip opens on :focus-visible as well as hover — one that only answers the mouse leaves a keyboard user with an unlabelled row of glyphs.

States
Default
Hover
Focus visible
Pressed
Disabled
Loading
Do

Keep the tooltip text and the aria-label byte-identical, and give every icon button one — an unnamed icon button is an unlabelled control, which is a WCAG 4.1.2 failure, not a polish item.

Don't

Don't ship the 32px size on a touch surface. It is below the 44px minimum pointer target, and it is the size most often copied straight out of a desktop toolbar into a mobile view.

Button group #

Two or more related controls presented as one unit. The connected form keeps separate actions on the same object together — run, schedule, more — sharing a hairline so they read as one block. The segmented form is a single exclusive choice between views of the same data, and behaves like a radio group: one tab stop for the whole control, arrow keys inside it.

A segmented control is not a tab set and not a filter. Keep it to three to five short options of equal weight that change how something is displayed, not what is queried — past five, or as soon as a label wraps, it should be a select. Never put a destructive action into a connected group: a shared border makes an accidental hit far too cheap.

Connected group
Segmented control — click, or Tab in and use the arrow keys

Result density: Compact

One tab stop, not one per option. The group uses a roving tabindex: the checked option is the only focusable one, and the arrow keys move focus and selection together, as the radio-group pattern requires. Disabled options are skipped rather than parked on. Selection is styled from [aria-checked="true"] rather than a class, so what is painted and what a screen reader announces read from the same attribute and cannot drift apart.

States
Default
Hover
Focus visible
Disabled option
Group: hover
Group: focus
Do

Label the group itself. role='group' or role='radiogroup' with an aria-label states what the buttons have in common, which is the only thing a screen reader cannot infer from reading them one at a time.

Don't

Don't let a segmented control run an action. It selects a view; if picking an option writes to the server or starts a job, those are buttons, and they belong in a connected group instead.

Forms & inputs

The densest surface in most clinical and scientific tools, and the one where state coverage matters most: a field that looks fine empty and breaks on error is not finished.

Text input #

The default control for a short value the user types rather than picks — a sample ID, participant initials, a site code. Reach for it when the set of valid answers is open-ended but the answer is short.

Not for a value drawn from a known set (select or combobox), not for anything that runs past one line (textarea), and not for a number people will nudge rather than enter (stepper or slider).

States
Default

Format: SPL-YYYY-NNNN.

Hover

Format: SPL-YYYY-NNNN.

Focus

Format: SPL-YYYY-NNNN.

Filled

Format: SPL-YYYY-NNNN.

Disabled

Locked after accessioning.

Error

Sample ID must match SPL-YYYY-NNNN.

Affixes, clear button, counter

A leading icon is decoration — the label still carries the meaning.

The unit is repeated in the label text for screen readers: volume in millilitres.

The clear button appears only when there is something to clear.

Printed on the 25 mm cryo label.

0 / 32
Sizes
Size Height Type Where
.ds-input--sm 32px --fs-small Inline table filters, toolbars, dense grids
.ds-input 40px --fs-body Everything else. If you are unsure, this one.
.ds-input--lg 48px --fs-body-lg Single-field steps and touch-first flows

Counter without maxlength. A native max length silently swallows the 33rd keystroke, so the user finds out by noticing text missing. The counter here lets the value go over, turns red, and links a real error — the limit is explained rather than enforced in silence. The counter is not a live region: announcing it on every keystroke would drown out the typing.

Do

Say what the format is before the user types it. 'Format: SPL-YYYY-NNNN' as helper text prevents the error state; the same sentence shown after submit only explains it.

Don't

Don't use the placeholder as the label. It vanishes on the first keystroke, it fails contrast at most placeholder greys, and it leaves screen-reader users with an unnamed box.

Textarea #

Multi-line free text where the length is genuinely unpredictable: protocol deviation notes, reviewer comments, the reason a sample was rejected.

Not for structured content that needs per-line validation — that is a repeatable field group or a small table editor. And not as a dumping ground for data you will later have to parse.

States
Default

Visible to the monitor at the next visit.

Hover

Visible to the monitor at the next visit.

Focus

Visible to the monitor at the next visit.

Filled

Visible to the monitor at the next visit.

Disabled

Read-only once the deviation is signed.

Error

A deviation note needs at least 20 characters.

Auto-resize — type into it

Grows to 260px, then scrolls rather than pushing the page around.

0 / 280

Why auto-resize is worth the JS. A fixed three-row box turns a 200-word deviation note into a scrolling window inside a scrolling page — the writer cannot re-read the paragraph they just wrote. The field grows with the content up to max-height: 260px, and only then hands overflow back to a scrollbar, so the submit button never runs away down the screen.

Do

Let the field grow with the content up to a sensible cap, and keep the resize handle on non-growing textareas so people can make room themselves.

Don't

Don't cap the height at three lines and remove the handle. A long note typed through a 60px window is how detail gets left out of a record someone else has to audit.

Select #

One value from a short, known, mutually exclusive list — specimen type, cohort, review outcome. Best up to about a dozen options, where people recognise the right answer rather than recall it.

Past roughly fifteen options, switch to a combobox so the list can be typed down. For two to four short options, radio buttons or a segmented control show every choice without a click.

Live — click, or focus and use the arrow keys
Specimen type

Must match the specimen on the accession form.

States
Closed
Specimen type
Plasma (EDTA)

Set at accessioning.

Hover
Specimen type
Plasma (EDTA)

Set at accessioning.

Focus
Specimen type
Plasma (EDTA)

Set at accessioning.

No selection
Specimen type
Select specimen type

Set at accessioning.

Disabled
Specimen type
Plasma (EDTA)

Set at accessioning.

Error
Specimen type
Select specimen type

Specimen type is required before accessioning.

Open — option states

Custom listbox, not a styled <select>. The native control renders its popup in the OS layer, so option padding, the selected tick, a disabled option's explanation and the dark-mode surface are all beyond CSS and differ on every platform. This is a button plus a real role="listbox", which means the keyboard model is ours to get right: HomeEnd move, first-letter typing jumps, Enter selects, Esc closes, and focus never leaves the trigger — the active option is pointed at with aria-activedescendant. The panel above it is a static rendering of the open state, marked aria-hidden so assistive tech is not offered a second, fake control. The trade is real: on a native select, a mobile OS gives you its own wheel picker for free.

Do

Keep the options in a stable, meaningful order — frequency, then alphabetical — and explain any disabled option in place, so nobody hunts for why they cannot pick it.

Don't

Don't hide the current value behind a placeholder that reads like a choice. 'Select specimen type' is an instruction; if it can also be an answer, people will pick it.

Combobox #

A text input backed by a filtered list: the user types a few characters to cut hundreds of options down to the one they want. The multi-select variant is the right control for 'any number of sites' or 'any number of assays'.

Not for a list short enough to scan at a glance — a select is fewer keystrokes and no reading. And not when arbitrary text is genuinely allowed; if any value is valid, that is a text input with suggestions, and the difference has to be obvious before the user commits.

Single select — type to filter

Turnaround time is shown on the right of each option.

Multi select — chips removable by click or Backspace
Site 012 — Brightmoor General Site 019 — Lakeside Oncology

Backspace on an empty field highlights the last site; press it again to remove it.

Backspace arms, then removes. Deleting a selection the user cannot see their cursor in is a destructive keypress, so the first Backspace on an empty field marks the last chip and the second one removes it. Every removal — click or key — is announced through a polite live region, because a chip disappearing silently is invisible to anyone not watching that corner of the screen. Each chip's remove button is a real button in the tab order, so nothing here depends on knowing the Backspace trick.

Do

Keep the typed query and the list in sync as one thing, announce the result count, and make Escape a reliable way back to the unfiltered list.

Don't

Don't silently discard a half-typed query on blur. Reverting the field to empty without a word reads as a bug and loses the user's work.

Search input #

Query-as-you-type over a set the user is scanning — samples, participants, files. It filters what is already on screen; it does not submit.

Not for a global search that navigates to a results page: that is a form with a submit button and it should look like one. And never the only route to content — search complements navigation, it does not replace it.

Idle & focused
Idle
Focused
With results & no results — both live, edit either one
With results
No results

The panel follows the query, not focus. Results stay open while the field has a value, so clicking into the list — or reading it on a screenshot — does not make it vanish. Esc clears the query and closes it in one press; walk the results and Enter takes the highlighted one. The matched substring is wrapped in <mark> repainted with --accent-muted, because the browser default is a fixed yellow that ignores the theme entirely.

Do

Write a no-results state that repeats what was searched for and offers the next move. 'No samples match' plus a way back to the full list beats an empty box.

Don't

Don't bury search behind an icon-only expander on a screen where searching is the main job, and don't debounce so hard that the list lags a fast typist.

Password input #

Credential entry — masked by default, with a show/hide toggle so people can check what they typed before they commit to it.

Not a general-purpose secret field for API keys or tokens the user is meant to copy back out; those want a copy button and a reveal you can audit, not a convenience toggle.

Live — the toggle is real

At least 12 characters. Paste is allowed and encouraged.

States
Default

At least 12 characters.

Filled — masked

At least 12 characters.

Focus

At least 12 characters.

Revealed

At least 12 characters.

Disabled

At least 12 characters.

Error

Use at least 12 characters.

The toggle is a button, and its name changes. It carries aria-pressed so its on/off nature is exposed, and the accessible name flips between "Show passphrase" and "Hide passphrase" — an icon-only control whose name never changes tells a screen-reader user nothing about what state they are in. Focus stays on the button after a press, so the toggle can be operated repeatedly without losing the caret. The masked value is set in --font-mono because proportional dots are almost impossible to count.

Do

Allow paste, state the rules before the field rather than after the failure, and keep the reveal available right up to submit.

Don't

Don't block paste or silently truncate at a maximum length. Both push people toward shorter, more memorable, weaker passwords — and neither prevents anything.

Date picker #

A date the user picks in relation to other dates — a collection date inside a study window, a monitoring window spanning two months. The calendar is what makes 'which Monday' answerable.

Not for a date the user already knows exactly, like a date of birth: a typed field with a stated format beats sixty clicks back through a calendar. Offer both where you can, and never make the calendar the only route.

Single, range, and out of range — all live
Single date
Collection date

Must fall inside the study window (16 Feb – 10 Apr 2026).

Date range
Monitoring window

Pick the first day, then the last. Picking earlier than the start restarts the range.

Out of range
Collection date

Study window: 16 Feb – 10 Apr 2026.

A fixed three months, and a real grid. February to April 2026 are rendered at build time from hard-coded values — never from new Date() — so the demo shows the same weekdays in a year's time as it does today, and month navigation is a panel swap rather than date arithmetic. The calendar is a role="grid" whose cells are the gridcells: move a day and cross the month boundary, move a week, Home/End jump to the start and end of the week, PageUp/PageDown change month, Enter selects and Esc closes and hands focus back to the trigger. Days outside the study window stay rendered, keep their place in the roving tab order and carry aria-disabled — moving onto one says so rather than skipping it silently.

Do

Constrain to the valid window inside the calendar and keep out-of-range days visible but unselectable, so the boundary of the study is discoverable rather than guessed at.

Don't

Don't leave range validation until submit. Someone who picks a date three weeks outside the window and hears about it two screens later has to redo the entire path.

File upload #

Bringing a file in from the user's machine — assay exports, sample manifests. Drag-and-drop plus a real file input, because dragging is not available to everyone.

Not for very large transfers or folder syncs, and not the place for heavy client-side parsing. Past a few hundred megabytes, or when a dropped connection must be resumable, this pattern needs a chunked uploader behind it.

Live — drag a file onto it, or choose one

Drop an assay export here

FCS, CSV or TSV · up to 25 MB

One file at a time. Rejected formats are named in the error, not just refused.

States
Idle

Drop an assay export here

FCS, CSV or TSV · up to 25 MB

Choose file
Drag over

Drop an assay export here

FCS, CSV or TSV · up to 25 MB

Choose file
Uploading
panel-a-run-2026-03-11.fcs
8.4 MB of 13.6 MB · 62%
Success
panel-a-run-2026-03-11.fcs
13.6 MB · uploaded 2 min ago
Error

Drop an assay export here

FCS, CSV or TSV · up to 25 MB

Choose file

run-2026-03-11.zip is not a supported format. Upload FCS, CSV or TSV.

The dropzone is the enhancement; the input is the control. A real <input type="file"> sits inside the zone, visually clipped but still focusable and still what a screen reader announces — its focus ring is forwarded to the visible "Choose file" label, since a clipped element's own ring is invisible. Drag handlers use a counter for dragenter/dragleave, because those events fire again for every child element the pointer crosses and a naive handler flickers. Progress here is a timer standing in for a real upload; the role="progressbar" and its aria-valuenow are wired the way they would be against a real one.

Do

State the accepted formats and the size cap before the user picks anything, then name the specific reason in the error — which extension, which limit, and by how much.

Don't

Don't make drag-and-drop the only way in. Keyboard users, most touch users and anyone working from a file picker dialog cannot drag anything anywhere.

Checkbox #

Independent yes/no choices, or a single opt-in that is staged until the form is submitted. Reach for a checkbox whenever more than one option in a group can be true at the same time, and whenever the change should not land before the user says so.

Not for a setting that applies the instant it is flipped — that is a switch. Not for two mutually exclusive options either: someone who unchecks a lone box has no way of knowing what the opposite state commits them to.

States
Unchecked
Hover
Focus
Checked
Indeterminate
Disabled
Checked · disabled
Error
Helper text and single opt-in
Select all — a real indeterminate parent

Indeterminate is a DOM property, not an attribute. There is no indeterminate markup to write — it can only be set as el.indeterminate = true, which is why the parent above is wired in script rather than in the template. Setting it is also what makes a screen reader announce the box as "mixed"; a dash painted with CSS alone announces as unchecked and quietly lies.

Do

Wrap the input inside its <label> so the whole line is a hit target. On touch that is the difference between a comfortable form and a fiddly one, and it costs nothing but nesting.

Don't

Don't paint a third visual state onto a plain checkbox to fake 'some selected'. Set el.indeterminate so the control, the pixels and the announcement all agree.

Radio #

One choice from a small, mutually exclusive set — typically two to five options — where seeing every option at once is worth the vertical space. The card variant adds a title plus a line of consequence copy, for choices a user cannot evaluate from a bare label.

Past roughly six options, use a select. And never ship a group with nothing preselected unless 'no answer' is a legitimate outcome: a native radio group cannot be cleared once a user has picked, so a stray click becomes permanent for the life of that form.

States
Unselected
Hover
Focus
Selected
Disabled
Selected · disabled
Group — fieldset, legend, one name
Specimen type

Determines which assays the site can be assigned. One per participant.

Radio card
Data access tier

Each tier changes what leaves the secure enclave. Pick the narrowest one that answers the question.

The card is a label, not a div. The whole surface is clickable because a <label> wraps a real <input type="radio">, and the card reacts through :has() rather than a JS-toggled class. That keeps arrow-key navigation, one tab stop for the group, the announced legend, and form submission — all of which a click-handler div throws away and then has to reimplement badly.

Do

Wrap every group in a fieldset with a legend and give the inputs one shared name. That single attribute buys arrow-key navigation, one tab stop per group, and the group label being announced before the option.

Don't

Don't build radio cards from divs with click handlers. Make the card as large as you like — but keep a real input inside it, or the group loses keyboard selection and stops submitting a value.

Toggle / switch #

A binary setting that takes effect immediately, with no Save step. Use it in preference panels, filter bars and row-level controls where the result of the flip is visible right away.

If the change only lands when the form is submitted, use a checkbox. A switch promises the system has already done the thing, and wiring one into pending form state is the most commonly botched call in this whole category. Avoid switches for destructive or irreversible actions too — there is no confirmation step in a flip.

States
Off
On
Hover
Focus
Disabled off
Disabled on
Label placement — settings rows
Compact — control first, live state
Choosing between a switch and a checkbox
Question Switch Checkbox
When does it apply? The moment it is flipped On submit
How is it undone? Flip it back Discard the form
Where does it live? Settings, filter bars, row controls Forms, consent, multi-select lists
Many of them together? Rarely — one setting per row Yes — that is the normal case
Semantics role="switch" + aria-checked Native checked

Still a native checkbox underneath. The control is <input type="checkbox" role="switch">, so it participates in the form, toggles with Space, and needs no key handling. The aria-checked attribute is written explicitly for the assistive tech that does not map the host-language state onto the switch role — which means it has to be re-set on every change, or it goes stale and starts announcing the opposite of what is on screen. That sync is the one line of script this component needs.

Do

Label the state the switch controls, not the action — 'Auto-refresh cohort counts', not 'Turn on auto-refresh'. The control already says on and off; the label only has to say what.

Don't

Don't put a switch in a form with a Save button. Either it applies on flip or it is staged until submit — a switch that needs saving makes people believe the change already landed.

Slider #

Choosing an approximate value inside a known range, where the direction of travel matters more than the exact number — cohort age bands, minimum cellularity, follow-up windows. The range variant sets both ends of a band with one control.

Not for a value that has to be exact or recalled from memory (a dose, an ID, a date) — pair it with a number input or drop it. On touch, a slider with less than roughly 44px of travel per step is unusable; use a stepper instead.

Single handle — drag for the value bubble
35%
35%
Two handles — clamped so they cannot cross
Age at diagnosis 45–72 years
45 72
Step marks and disabled
12 mo
12 mo
60% · locked

Two inputs, one rail. The range variant stacks two real <input type="range"> elements over a shared track, so each handle keeps its own tab stop, arrow keys, Home/End and announced value — none of which a div-with-two-draggable-handles gets for free. The track takes pointer-events: none and the thumbs take it back, otherwise the top input swallows every press and the lower handle can never be grabbed. Crossing is prevented on input, not change — by the time change fires, the range has already inverted on screen.

Do

Show the value at all times, not only mid-drag, and expose it as text with its unit (aria-valuetext) so it is announced as '35 percent' rather than a bare '35'.

Don't

Don't let range handles cross or stack. Clamp each to the other's value plus one step — a range that can invert produces filters that silently return nothing and look like a data bug.

Form layout #

The conventions that make a form readable before a single field is styled: where the label sits, how required and optional are marked, where helper text and validation go, and how related fields are grouped. Applied consistently, these are what let someone scan a twenty-field form instead of reading it.

Do not mix label placements inside one form, do not mark both required and optional fields — pick whichever is rarer and mark only that — and never move a field's meaning into placeholder text. It disappears the moment someone types, and it is the fastest way to break a form for anyone who gets interrupted mid-entry.

Label placement
Top — the default

One scan column, no width guessing, survives translation.

Inline — dense panels only

Short, familiar, repeated labels. Collapses to top below 640px.

Form conventions and how each one is implemented
Convention Rule Implementation
Required field Marked when most fields are optional required on the input, plus an aria-hidden asterisk in the label
Optional field Marked when most fields are required. Never both in one form. The word "Optional" after the label text in .ds-optional
Helper text Below the control, always visible, never a placeholder .ds-help wired with aria-describedby
Error Replaces nothing — sits under the helper text, on blur not on keystroke aria-invalid="true" plus .ds-error in aria-describedby
Success Only where confirmation is worth the noise — availability, format, sync .ds-success with an icon, not colour alone
Grouping Any set of controls answering one question <fieldset> + <legend>
Inline validation — blur the fields to run it

Used for protocol amendments and query resolution.

Enter a valid email address — for example coordinator@site.org.

Two-letter country code, dash, three digits — for example GB-104.

Identifier is free — not yet claimed by another site.

Assembled — clinical site activation intake

All protocol amendments are sent here.

Specimen types this site can provide

Select every type the site is equipped to collect.

Consent version on file
Demo form — nothing is sent.

One switch in a form is not a contradiction. The notification toggle above applies on flip and says so in its helper text; everything else in the form is staged until Submit. That is the only shape in which the two controls can share a screen — if the switch needed the Submit button, it would have to be a checkbox. Note too that the two-column grid collapses to one column below 640px and the fieldsets always span the full width: a group of related controls that gets cut in half by a column boundary stops reading as a group.

Do

Put labels above the field for anything being entered for the first time — one scan column, no guessing at label-column width, and no reflow when a translation runs 40% longer.

Don't

Don't hold validation until submit. Validate on blur, keep the message next to the field it belongs to, and never signal it with colour alone — the icon and the sentence are what make it an error rather than a red rectangle.

Feedback & status

What the interface says back. Loading, empty, and error are states a real product spends most of its time in, so they are specified here rather than improvised per screen.

Toast #

A short, transient confirmation that something finished in the background — an export queued, a sample removed, a sync completed. It reports on work the user already asked for, so it never blocks the screen and never needs a response.

Not for anything the user must act on, must read in full, or might need again five minutes from now. Validation belongs on the field, blocking failures belong in a dialog, and a record of what happened belongs in an activity log — a toast is gone in five seconds and cannot be scrolled back to.

Spawn a toast — bottom right of the viewport

Hover or tab into a toast to freeze its countdown

Auto-dismiss is a decision, not a default. Info and success clear themselves in five seconds; a warning gets seven because there is more to read; an error never auto-dismisses — the one message a user cannot afford to miss is the one that should not vanish on a timer. A toast carrying an action gets nine seconds, because the user has to decide before they can act.

The timer pauses on hover and on focus. A toast that disappears mid-sentence, or that vanishes while someone is tabbing toward its Undo button, is the classic failure of this pattern. The countdown bar and the JavaScript timeout are paused by the same interaction, so what you see is the real remaining time rather than a decorative animation running next to an independent timer.

Anatomy — the four variants, rendered in flow

Sync started

Pulling assay results for study ONC-2291 from the connected LIMS.

Sample 8842 removed from cohort

It stays in the study and can be added back at any time.

3 wells excluded

Plate P-118 wells B4, C7 and D2 fell below the QC threshold.

Sample QC run failed

Run QC-4417 stopped at well 12 of 48. No results were written.

Two live regions, not one. aria-live cannot be changed per message once a region is registered, so the viewport holds a polite region and an assertive one. Errors go to the assertive region and interrupt; everything else queues politely behind whatever the screen reader is already saying. Both are role="region" with a label, and every dismiss button names the toast it closes rather than saying "Close".

Do

Keep a toast to one sentence of consequence and, at most, one action. If the message needs a heading, a list, or a link to documentation, it was never a toast.

Don't

Stack toasts as a substitute for a status surface. Three simultaneous toasts means a long-running job needs a progress indicator or an activity panel, not more notifications.

      Alert / banner #

      A message that stays on the page until the condition behind it changes. Inline alerts explain something about the section or form they sit in; page-level banners speak for the whole view — a degraded service, a read-only study, a batch awaiting sign-off.

      Not for confirming an action the user just took (that is a toast) and not for a single invalid field (that is field-level error text). A banner that is always present stops being read within a week, so anything permanent belongs in the page copy instead.

      Inline — the four semantics

      Results are provisional until the batch is signed off by the study lead.

      Cohort ONC-2291 saved. 1,284 samples are locked for analysis.

      3 of 48 wells failed QC

      Plate P-118 wells B4, C7 and D2 fell below the intensity threshold. They are excluded from the summary statistics until they are re-run or overridden.

      role="alert" is not decoration. It makes the element an assertive live region: a screen reader abandons whatever it was saying to read it, and it moves the user's attention. That is right for the blocked export above, and wrong for the other three — an informational note announced this way interrupts the sentence someone was listening to. Over-applying role="alert" to every banner is one of the most common accessibility regressions in a design system, precisely because nothing looks broken on screen. Rule of thumb: no role for a message that was already on the page at load, role="status" for one that appears without blocking, and role="alert" only when the user must stop.

      Page level — full bleed under the app header
      Study ONC-2291 · Samples Read only

      This study is locked for the interim analysis. You can read and export, but edits are disabled until 12 August.

      Sample table

      Do

      Say what changed, what it affects, and what the user can do about it. 'Export blocked — consent not on file' beats 'An error occurred' every time.

      Don't

      Make an alert dismissible when the condition it reports is still true. Dismissing a consent warning does not create consent, and the export will still fail.

      Badge / tag / chip #

      A compact label attached to something else. Status badges report the state of a record, count badges carry a number, and chips represent a user-set value — an applied filter, a selected assay — which is why only chips are removable.

      A badge is not a button. If clicking it does something, it is a chip, a filter control, or a link and it needs the affordances of one. And a badge cannot carry a sentence: past two or three words it should be a table column or an alert.

      Status — icon and label, never colour alone
      Running Passed QC Flagged Failed Draft
      Dot indicator — for dense rows where a glyph is too loud
      Running Passed QC Flagged Failed Draft

      The dot is a second channel, not the only one. It is safe here because the word beside it already carries the meaning. A bare coloured dot with no text — the pattern that shows up on avatars and nav items — conveys status by hue alone and fails 1.4.1 Use of Color; if you need one, pair it with .sr-only text and a tooltip, and accept that sighted colourblind users still get nothing.

      Count
      12 3 148 99+ Failed wells 3
      Chips — click the ✕, they really go
      Site 04 — Leiden Assay: RNA-seq Collected after 2025-01-01 QC: passed

      Removal has to be announced. The chip disappearing is visual feedback only; removing one here also writes "Assay: RNA-seq filter removed" into a polite live region, because a keyboard user whose focus target just left the DOM otherwise gets silence. Focus moves to the next chip rather than back to the top of the document.

      Do

      Give every status badge its own glyph and keep that pairing constant across the product, so 'triangle' means warning everywhere and users stop reading the word.

      Don't

      Invent a sixth status colour for a sixth state. Five is already at the limit of what people can hold; a sixth meaning belongs in a column, not in a hue.

      Progress #

      For work with a duration the user is waiting on. Determinate when you know how much is left — uploads, exports, batch jobs with a known item count. Indeterminate when you genuinely do not, and only then.

      Below about one second, a progress bar is worse than nothing: it flashes and reads as a glitch. And a fake determinate bar that crawls to 90% and stalls destroys trust faster than an honest indeterminate one.

      Determinate — press run, the bar and aria-valuenow both move
      Exporting cohort ONC-2291 0%
      Sizes and tone
      Uploading plate map P-118 68%
      Storage used — 41 of 50 GB 82%
      Indeterminate — no aria-valuenow
      Preparing export — counting eligible samples

      Indeterminate means omitting aria-valuenow, not setting it to zero — zero is a real value and announces as "0 percent", which is a lie about a job that has started. Under prefers-reduced-motion the sliding segment is replaced by a static full-width wash in --accent-muted rather than the solid accent, so it still reads as busy without reading as finished.

      Do

      Put the item count in the label — 'Processing 312 of 1,284 samples' answers 'how long' far better than a percentage does.

      Don't

      Leave a determinate bar sitting at 100% after the work is done. Swap it for the result, or the user keeps waiting for something that already happened.

      Spinner #

      The smallest possible 'working on it' — for waits too short or too unpredictable to measure. Inline next to the thing it belongs to, or centred in the region it is filling.

      A spinner tells the user nothing except that the page is not dead. For anything over a couple of seconds a skeleton communicates more, and for anything measurable a progress bar communicates far more.

      Inline — sized to the text it sits beside
      Loading sample manifest…

      16px ring against 15px body text — a spinner larger than its label reads as an error icon

      Section level
      Sample results
      Fetching 1,284 sample records

      The label is the component; the ring is decoration. The spinner is aria-hidden and the text beside it does the announcing — which is also why that text is mandatory rather than optional. Under prefers-reduced-motion the ring stops rotating entirely and holds as a static three-quarter arc, so a user who asked for no motion is left with the sentence, not with a silent frozen circle.

      Do

      Delay a spinner by roughly 200–300ms. Most requests resolve inside that window, and a spinner that flashes for 80ms is pure visual noise.

      Don't

      Replace an entire populated view with one centred spinner on refresh. Keep the stale content, dim it, and put the spinner in the region that is actually changing.

      Skeleton #

      A grey cast of the layout that is loading, shown when you know the shape of what is coming. It holds the page geometry still so nothing jumps when the data lands, which makes a two-second wait feel materially shorter than a spinner does.

      Only when the skeleton actually matches the result. A three-line skeleton that resolves into an empty state or a single row is worse than a spinner — you promised content and delivered nothing. Never skeleton an unknown shape.

      Card
      Loading sample card
      Table rows
      Sample Assay QC
      Loading sample table
      Text block
      Loading study summary

      A screen reader must never read the boxes. Every bar is aria-hidden="true", the container carries aria-busy="true", and one .sr-only sentence says what is loading. Without that, assistive tech announces a dozen empty generics and the user has no idea the page is still working. The last line of a text skeleton is short on purpose — a block of equal-length bars reads as a table, not as prose. Note that the column headers above are not skeletons: those labels are known before the request resolves, so rendering them immediately gives the user something to read while the cells fill in.

      Token gap: the shimmer highlight uses --bg-muted over a --bg-subtle base, and that pairing steps darker in light mode and lighter in dark. Both are visible, but the sweep is not the same gesture in both themes. A proper fix is a token that always steps one increment away from the surface in the mode's own elevation direction — it does not exist yet, and inventing a [data-theme] override in this file would have put a per-mode decision outside the theme layer.

      Do

      Match the skeleton to the real layout — same number of lines, same column widths, same card height — so the content lands without a single pixel of shift.

      Don't

      Shimmer under reduced motion. The animation is suppressed entirely here; the static blocks still hold the layout, which was the actual job.

      Empty state #

      What a collection looks like with nothing in it. There are two of these and they are not interchangeable: a collection that has never had anything in it needs to teach and offer a way in, while a filtered result that came back empty needs to explain what excluded everything and offer a way back.

      Do not use an empty state to apologise. Zero results is a normal outcome, not a failure — if something actually went wrong, that is an error state, and telling the two apart is the whole point of having both.

      Nothing yet — teach, then offer the first action

      No cohorts yet

      A cohort is a saved set of samples you can analyse and export as a unit. Build one from the sample table, or import a list you already have.

      Filtered to nothing — name the filters, offer the way back
      Samples · study ONC-2291 0

      No samples match these filters

      Nothing in this study is both an RNA-seq assay and collected after 2025-01-01. The study itself has 1,284 samples.

      The two differ in every part except the frame. The first-run state has a primary button because creating the first cohort is the thing the user came to do; the filtered state has only secondary buttons, because undoing a filter is a correction, not an achievement. The first-run copy explains what the object is; the filtered copy names the specific predicates that excluded everything, and says how many rows exist without them — which is the number that tells the user whether to widen the filter or fix the study. Same shell, opposite jobs.

      Do

      Make the empty state carry the onboarding. It is the most-read screen in a new account and the only one guaranteed to be seen before any data exists.

      Don't

      Ship a centred grey 'No data' and call it done. It leaves the user with no idea whether the query is wrong, the permissions are wrong, or the product is.

      Error state #

      A region or a route that could not render. It reports a failure of the system rather than of the user's input: a request that timed out, a service that is down, a permission that was revoked mid-session. Always paired with a way to try again.

      This is not a form-field error. A field error sits on the control, blames nothing, and is fixed by typing; an error state replaces content the user cannot recover by editing anything. Using this pattern for a validation failure hides the field that actually needs attention.

      Section level — press Try again, it really retries
      QC results · plate P-118

      Could not load QC results

      The results service did not respond in time. Nothing was lost — the run is still queued and the rest of this page is unaffected.

      Reference QC-4417-A3F1

      Page level — the whole route failed
      Digital Design Studio · Workspace

      Something went wrong

      We hit an unexpected error loading this workspace. Your work is saved. If reloading does not help, send us the reference below and we will pick it up from the logs.

      Reference WS-2026-08-04-9C21

      Section level fails small; page level fails whole. A section error keeps the surrounding chrome alive so the user can still navigate away and still trusts the rest of the screen — which is why the copy says explicitly what was not affected. A page-level error owns the viewport, so it must offer at least one exit that is not "try again", because the retry may keep failing.

      Empty and error share one shell. Both render .ds-blankslate; only the glyph tone, the copy, and the call to action differ. Keeping them one component is deliberate — it is what stops "no results" from drifting into looking like a crash, and a crash from being mistaken for "no results".

      Do

      Give the user a reference they can quote and say plainly what state their data is in. 'Your work is saved' answers the only question they actually have.

      Don't

      Show a raw stack trace, an HTTP status, or 'Error: undefined'. If the detail matters, put it behind a disclosure and log the real thing server-side.

      Overlays

      Anything that floats above the page. All of these trap or manage focus, close on Escape, and draw their stacking order from the z-index scale — never a hand-picked number.

      Drawer #

      A panel that slides in from the edge of the window for work too long or too referential for a modal: a filter builder, a record's full detail, an edit made while the list it came from is still visible. Left for context and navigation, right for detail and actions.

      Not for a single yes or no — that is a confirmation dialog. And not for anything a user may want to link to, leave and come back to, or open beside a second drawer; at that point it has become a page and should have a URL.

      Sides

      Same shell as the modal, different geometry. A drawer is the same <dialog> plus showModal(), so it inherits the focus trap, Escape, the inert background and the scroll lock unchanged — only the panel's placement and its transform change. It sits at --z-drawer, one step below --z-modal, because a confirmation raised from inside a drawer has to land on top of it. Sides use physical margins rather than flex alignment: justify-content follows the writing direction and would quietly swap left and right in an RTL locale.

      Do

      Give a drawer the same four exits a modal has — close button, overlay click, Escape, and an explicit Cancel — and keep its primary action pinned in a footer that does not scroll away. A user four fields into a long form should never have to scroll to find the way out.

      Don't

      Do not open a drawer over a drawer, and do not use one as a wider modal for content that simply would not fit. Do not let a drawer silently discard typed input on an overlay click either: if the form is dirty, confirm before dismissing.

      Study navigator

      STU-118 · Phase II · 14 sites

      Cohorts

      • C-201 — screening, 1,204 subjects
      • C-204 — responders at week 12, 318 subjects
      • C-207 — biomarker-high subset, 92 subjects
      • C-211 — safety follow-up, 1,180 subjects

      Assays

      • Targeted panel — 41 analytes, 6 runs
      • Flow cytometry — 12 panels, 4 runs

      Cohort filters

      Applied to the subject list behind this panel. Nothing is saved until you apply.

      Exclusions

      318 of 1,204 subjects

      Popover #

      A small panel anchored to the control that opened it, for secondary detail or a compact set of controls that would clutter the page: a QC breakdown, column settings, a quick filter. Non-modal — the page stays live and the popover light-dismisses on the next click outside.

      Not for anything the user has to acknowledge, since a stray click dismisses it. Not a substitute for a tooltip on a plain icon. And not for content long enough to need its own scrollbar — that is a drawer wearing a disguise.

      Placement and collision

      The flip is real, and the panel says so. Each popover prints the placement the collision pass actually resolved to. Open Prefers right at a normal window width and it reports left, because there is not enough room to its right; widen the window and it goes back to right. Scroll the page until a trigger sits near the bottom of the window and Prefers bottom-start resolves to top-start. The rule is deliberately conservative: flip only when the preferred side genuinely cannot hold the panel and the opposite side holds more of it, otherwise the panel appears to jump around at random. Cross-axis overflow is handled by shifting rather than flipping, and the whole thing re-runs on scroll and resize.

      Interactive content

      Popovers may contain focusable elements; tooltips may not. That is the line between the two patterns. This one holds a select, two checkboxes and two buttons, focus moves into it on open, and Escape closes it and returns focus to the trigger. Everything here uses the native Popover API, so light dismiss, Escape, and the top layer come from the platform; only the coordinates are computed in script.

      Do

      Anchor the popover to the control that opened it, keep it under roughly two hundred words, and make sure it reaches the same state as the page it summarises. If it has controls, move focus into it and give it a clear commit action.

      Don't

      Do not put a required step inside a popover, do not nest one inside another, and do not open one on hover. A hover-opened panel with buttons in it is unusable by anyone whose pointer shakes, and impossible on touch.

      Tooltip #

      A few words naming a control whose purpose its icon alone does not carry. It appears after a short hover delay, immediately on keyboard focus, and disappears the moment either ends.

      A tooltip must never hold interactive content and must never be the only place a piece of information appears. It is unreachable on touch, absent from print, invisible to anyone who has not hovered, and gone the instant the pointer moves — if it matters, it belongs on the page. If a control needs a sentence to explain it, the control is wrong.

      Placements
      Copy cohort ID Export as CSV Re-run QC Lock cohort

      Hover waits, focus does not. A 350 ms delay on hover stops a row of tooltips firing as the pointer sweeps across a toolbar; keyboard focus shows immediately, because a keyboard user has already committed to that control. Hiding has no delay in either case. Each tooltip is wired to its trigger with aria-describedby, so it is announced as a description rather than replacing the button's own label — the buttons above carry a real aria-label as well, and would still be usable if the tooltip never appeared. The arrow keeps pointing at the trigger even when the panel has been shifted back inside the viewport, and Escape dismisses it without moving the pointer, which WCAG 1.4.13 requires.

      Do

      Use a tooltip only to name an icon-only control, in two or three words, with the same text as the control's accessible label. Trigger it on hover and on focus, and let Escape dismiss it.

      Don't

      Do not put a link, a button, or a close control inside a tooltip; nobody can reach it. Do not use one to hold an error message, a required instruction, or the only definition of a term — a touch user will never see it. And never attach one to text that is not focusable.

      Confirmation dialog #

      The one dialog shape reserved for a decision that cannot be undone from the interface: archiving a cohort, revoking access, deleting a study. Deliberately narrower and flatter than a modal, with the same width, the same button order and the same wording every time, so it is recognised before it is read.

      Not for anything reversible. An undoable action should simply happen and offer Undo in a toast — confirming everything trains people to click through without reading, which is precisely when the destructive one gets past them.

      Destructive confirm

      Nothing has been archived or deleted.

      Focus opens on Cancel, never on the destructive button. A confirmation is often reached at speed, and the muscle memory that got the user here is a keypress away from firing again — Enter or Space on an autofocused Archive button would commit the exact action the dialog exists to slow down. Landing on Cancel makes the reflex safe and costs a deliberate Tab to proceed. The second dialog goes further: for something genuinely irreversible, typing the study identifier is what enables the destructive button, which turns a reflex into a transcription the user has to look at the screen to complete. Reserve that for actions with no recovery path at all; on anything routine it is theatre, and people learn to copy and paste through it.

      Do

      Name the object and the consequence in the title — Archive cohort C-204? — and label the destructive button with the verb, not Yes. Say what survives the action and what does not, so the user is not guessing. Default focus to the safe choice.

      Don't

      Do not ask twice for the same action, do not use a confirmation where an Undo toast would do, and do not write the question so the safe answer is the scary-sounding one. Never make the destructive button the visually dominant one just because it is the primary action of the dialog.

      Archive cohort C-204?

      The 318 subjects stay in the study and their samples are untouched. Saved analyses that reference this cohort will show it as archived and stop refreshing.

      Delete study STU-118?

      This deletes 4 cohorts, 15 assay runs and 1,204 subject records. There is no restore, and no export is taken first.

      The Delete button stays disabled until the identifier matches exactly.

      Data display

      Reading surfaces. Weighted toward dense, dashboard-style work, because that is what the client projects behind this studio actually are.

      Table #

      Reach for a table when someone needs to compare many records across the same attributes — sample QC across a sequencing run, enrolment across sites. A table earns its weight when the columns are scannable and the numbers line up.

      Not for presenting one record's fields (that is a description list) or for a sequence of things with a single attribute each (that is a list). If fewer than three columns carry information, you have built a list with extra chrome.

      Basic
      HELIOS-2 sequencing samples — first four records.
      Sample Assay Site Run date Reads (M) QC
      HLS2-0114 RNA-seq Rotterdam 02 Jun 2026 41.8 Pass
      HLS2-0115 RNA-seq Rotterdam 02 Jun 2026 9.4 Fail
      HLS2-0121 WGS Basel 04 Jun 2026 118.6 Pass
      HLS2-0122 WGS Basel 04 Jun 2026 103.2 Pass
      Search, filter, sort, select, paginate — all live

      0 of 14 samples selected

      HELIOS-2 sequencing samples with QC verdict. Sortable by sample, assay, site, run date and read count.
      QC
      HLS2-0114 RNA-seq Rotterdam 02 Jun 2026 41.8 Pass
      HLS2-0115 RNA-seq Rotterdam 02 Jun 2026 9.4 Fail
      HLS2-0121 WGS Basel 04 Jun 2026 118.6 Pass
      HLS2-0122 WGS Basel 04 Jun 2026 103.2 Pass
      HLS2-0130 Methylation Uppsala 09 Jun 2026 27.5 Flagged
      HLS2-0131 Methylation Uppsala 09 Jun 2026 31.0 Pass
      HLS2-0142 scRNA-seq Kyoto 11 Jun 2026 12.7 Flagged
      HLS2-0143 scRNA-seq Kyoto 11 Jun 2026 8.1 Fail
      HLS2-0150 Exome Boston 16 Jun 2026 74.9 Pass
      HLS2-0151 Exome Boston 16 Jun 2026 68.2 Pass
      HLS2-0158 RNA-seq Basel 18 Jun 2026 52.6 Pass
      HLS2-0159 scRNA-seq Rotterdam 23 Jun 2026 19.3 Flagged
      HLS2-0164 WGS Uppsala 25 Jun 2026 96.4 Pass
      HLS2-0165 Exome Kyoto 30 Jun 2026 5.7 Fail
      Showing 1–8 of 14

      Sort Reads (M) and watch the order. The comparator parses the value out of data-reads and subtracts; a string comparison would rank 103.2 above 12.7 above 9.4 and still look sorted, which is why this defect survives review so often. Run date sorts as a string on purpose — the row carries an ISO 2026-06-04, and a lexical sort of ISO dates is chronological, so the display format 04 Jun 2026 never has to be parsed.

      Density
      Comfortable — default
      Comfortable density example.
      Sample Assay Site Reads (M)
      HLS2-0150 Exome Boston 74.9
      HLS2-0151 Exome Boston 68.2
      HLS2-0158 RNA-seq Basel 52.6
      Compact — long working sessions
      Compact density example.
      Sample Assay Site Reads (M)
      HLS2-0150 Exome Boston 74.9
      HLS2-0151 Exome Boston 68.2
      HLS2-0158 RNA-seq Basel 52.6

      Two details doing quiet work. The sticky header needs border-collapse: separate — under collapse the header's bottom border belongs to the collapsed grid, not the cell, so it is left behind the moment the row detaches. And the scroll container takes tabindex="0" with a name, because a region that only a mouse can scroll is unreachable content.

      Do

      Right-align numeric columns, set font-variant-numeric: tabular-nums, and sort them with a numeric comparator. Magnitude should be readable from the shape of the column before anyone reads a digit.

      Don't

      Don't sort a numeric column with localeCompare. It ranks 103.2 above 12.7 above 9.4, the column still looks ordered, and it ships.

      Card #

      Group one object's summary — a label of context, a title, a line or two of detail, and at most one action — onto a self-contained surface. Cards suit browsable collections where each entry is a distinct thing rather than a row of the same thing.

      Not for uniform records that need comparing field by field: a table does that better in a fifth of the vertical space. And not as a layout device — cards nested inside cards mean the hierarchy has not been decided yet.

      Static vs. clickable vs. selected
      Static

      Cohort · HELIOS-2

      Batch RB-14

      126 libraries across four sites. Mean Q30 94.1%, two re-queued for low yield.

      Clickable · hover
      Selected
      Try it — hover, then Tab into it

      Static

      Nothing happens

      No cursor change, no lift, nothing focusable. A card that cannot be clicked must not behave as if it can.

      The stretched-link trade-off. The overlay that makes the whole card clickable also sits on top of the body copy, so that text can no longer be selected. Worth it for a summary card in a grid; not worth it where the card holds anything a user would copy — an ID, a dose, an address. There, keep the link on the title alone.

      Do

      Give a clickable card exactly one link or button and stretch it across the surface with an ::after overlay, so the target is generous but assistive tech announces a single destination.

      Don't

      Don't nest a second button or link inside a clickable card. Screen readers get two overlapping controls with no stated relationship, and the outer target swallows the inner one for pointer users.

      List #

      A sequence where each row is one thing with one or two attributes — collaborators, recent runs, attached protocol files. Cheaper to scan than cards, lighter to build than a table.

      Once rows need more than a leading element, two lines of text, and one trailing value, stop: you are building a table one row at a time, and it will not sort, filter, or align.

      Simple and two-line
      Simple + trailing value
      • Rotterdam 38 samples
      • Basel 31 samples
      • Uppsala 29 samples
      • Kyoto 28 samples
      Leading avatar · two-line
      • Ines Moreau Ines Moreau Signed off batch RB-14 2h ago
      • Portrait of Devi Raman Devi Raman Re-queued HLS2-0115 for low yield 6h ago
      • HELIOS-2 assay protocol v4 PDF · 1.2 MB v4.0
      Interactive rows — click or Tab to select
      Row states
      Default
      Hover
      Focus
      Active
      Selected

      Selection carries a bar, not just a tint. The --accent-muted wash is a low-alpha fill and reads faintly on a busy background, so the selected row also gets a 2px inset bar and aria-current="true". Focus stays visually distinct from both — an inset outline, offset inward because the list clips its children.

      Do

      Keep one scannable value per row, in the same position on every row. The eye locks onto the column, not the label.

      Don't

      Don't build a list from divs with click handlers. Rows that navigate are links, rows that act are buttons; anything else loses keyboard access, focus rings, and open-in-new-tab.

      Avatar #

      Identify a person at a glance — the owner column of a run table, a list of reviewers, an assignment picker. Falls back to initials when there is no photo, and can carry a presence dot where presence actually changes what someone does.

      Not decoration. If who did it does not matter to the task, the avatar is noise; if it does matter, the name has to be in the text too — a face is not an accessible label.

      Photo, initials, presence
      Photo Portrait of Devi Raman
      Initials Ines Moreau
      Online Ines Moreau, online
      Away Tomas Oyelaran, away
      Offline Sara Kwon, offline
      Sizes
      xs · 24 Ines Moreau
      sm · 32 Ines Moreau
      md · 40 Ines Moreau
      lg · 56 Ines Moreau
      With a name, and stacked
      Paired with text Devi Raman Lab lead, Basel · Status: Online
      Stacked group
      • Devi Raman
      • Ines Moreau
      • Sara Kwon
      • and 3 more reviewers

      Why the fallback is not an afterthought. A photo is raster content — it does not re-point when [data-theme] flips, so it is the one element on the page that cannot be themed. Initials can be, which is why they are built from --accent-muted and --text-primary rather than a per-person tint. When the avatar sits beside its name, the image takes alt="" so the name is not announced twice.

      Do

      Pair every avatar with the person's name — visible text where there is room, .sr-only where there is not. Give presence dots a shape difference as well as a colour: solid, half, hollow.

      Don't

      Don't let the status dot carry meaning through colour alone. Green and amber at 11px are close to indistinguishable for a red-green colour-blind reader, and nothing else on the screen says which is which.

      Stat / KPI card #

      One number a person actually watches, its label, and how it has moved since the last comparable period. The backbone of a monitoring dashboard, and the first thing anyone looks at when a run goes wrong.

      Not for numbers nobody acts on. A row of eight tiles where two matter teaches people to skip the row, which costs you the two.

      KPI row
      Samples sequenced · 7d 462 Up 10.5% vs. prior week
      Median Q30 94.1% No change, 0.0 pp vs. prior week
      QC failure rate 4.1% Down 0.5 pp vs. prior week
      Delta variants — coloured by meaning, not by sign
      Positive
      Sites enrolling 5/ 6 Up 1 site
      Negative
      Runs completed on time 82.4% Down 5.1 pp
      Neutral
      Assays in panel 5 No change, unchanged

      The sparkline is context, not content. It is aria-hidden — a screen-reader user gets the value and the delta in text, which is the whole story — and it is painted in --text-tertiary rather than --accent, because the accent is reserved for things you can click and spending it on a decorative trend line devalues it everywhere else on the page. Both the value and the delta use tabular-nums, so a live tile does not jitter sideways when its last digit ticks over.

      Do

      Colour the delta by whether the change is good, not by its sign. A falling QC-failure rate is a win and must read as one, so the modifier is named --positive rather than --up.

      Don't

      Don't put eight tiles on a dashboard because you have eight numbers. Every KPI on the screen spends the attention budget of the one that actually drives a decision.

      Accordion #

      Collapse long, independently useful sections so the shape of the page stays visible — per-site protocol caveats, assay methodology notes, release history. Good when a reader needs one of several blocks and you cannot predict which.

      Never behind an accordion: anything required to finish the current task, and form fields in particular — validation then fails somewhere the user cannot see. Weigh find-in-page too, which does not reach collapsed content unless the panel opts into the until-found value of hidden.

      Single-open — panels are alternatives

      Libraries are prepared on the site's own automation line, so index sets differ from the central protocol. Reads below 15 M are re-queued rather than flagged.

      Multi-open — panels are reference material

      Any library under 10 M reads, or with Q30 below 90%, fails outright. Between 10 M and 15 M it is flagged for a reviewer rather than failed automatically.

      Once, under the original sample ID. The dashboard reports attempts separately so the failure rate is not inflated by successful re-runs.

      Single-open vs. multi-open is a content decision. Single-open suits panels that are alternatives — one site's protocol at a time — and keeps the group's height predictable so the page below does not jump. Multi-open suits panels that are reference material, where someone may reasonably want two open side by side; never close a panel the user opened deliberately. Collapsed panels use the hidden attribute rather than a height animation, which is what keeps their contents out of the tab order — a panel that animates to zero height still holds focusable controls. The value until-found keeps them reachable by find-in-page in browsers that support it, and degrades to a plain hidden in those that do not.

      Do

      Pick single-open when the panels are alternatives the reader chooses between, and multi-open when they are reference material someone may want to compare. Say which one it is in the component's props, not in a comment.

      Don't

      Don't collapse content a user needs to complete the task in front of them — and never put required form fields inside a panel, where a validation error can fire somewhere invisible.