Batch RB-14
126 libraries across four sites. Mean Q30 94.1%, two re-queued for low yield.
Design system
Not a screenshot of a component library — the real thing, running. Every token, variant, and state below is live CSS from a production stack, in both themes, keyboard-operable, and checked to WCAG AA.
The decisions everything else inherits — components reference only the semantic layer, which is what makes a theme switch a token remap.
Two layers: raw ramps hold the palette, semantic aliases name a job and re-point per mode. Components reference only the aliases, which is what makes dark mode a token remap.
Never reference a ramp step from a component — it cannot know which mode it is in.
| 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 |
QC passed · 384/384 wells
Plate 7 flagged for re-read
Import failed · 12 rows rejected
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. |
Reach for the alias that describes the job; if none fits, the gap is in theme.css and belongs there.
Don't hard-code a hex, a ramp step, or a [data-theme='dark'] override in a component.
One sans family for what a person reads, one mono for what a machine produced. Eight sizes, three weights, and a 10px root so 1rem = 10px throughout.
Do not add a size between two steps because one heading is two pixels too tall in one layout.
A lede sits directly under a title and carries the one sentence a reader will take away if they read nothing else.
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.
Secondary copy: helper text under a field, a table caption, the second line of a list row.
Assay: IL-6 ELISA · Plate 07 · Read 450nm
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. |
Pick the step by role, and change weight or colour before reaching for a larger size.
Don't set a bare px font-size — it drifts out of sync the moment the scale is retuned.
A 4px base unit with ten steps — linear at the small end where the difference is visible, geometric at the large end where it is not. Layout sits on a 1120px container and a 12-column grid.
If a layout needs --space-9 between two related controls, the problem is the grouping, not the spacing.
| 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. |
Compose padding and gaps from the scale so vertical rhythm survives contact with real content.
Don't split the difference with a 10px or 18px value; one is invisible, twenty read as unfinished.
Three levels, --elevation-1/2/3, for anything that has to read as floating above the page — cards, menus, modals, toasts.
Elevation is not hierarchy: a card is higher because something can appear underneath it.
| 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. |
Use --elevation-* and pair each level with its surface — a popover is --bg-surface-raised at --elevation-2.
Don't reach for --shadow-lg on a dark surface; a shadow on near-black is invisible by construction.
Four values. Radius is how a component announces its size class: the smaller the element, the tighter the corner, so a chip and a modal do not share a curve.
Do not mix radii inside one component — a card and its button at the same radius read as two cards.
| 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. |
Inline 24×24 SVGs on a 24-unit grid, stroked at 1.5 with round caps, no fills, in three sizes. Inlined rather than loaded as a font, so every icon inherits colour.
An icon on its own is not a label: icon-only controls need an aria-label, decorative ones need aria-hidden.
Keep the 24×24 viewBox at every size, and name the action rather than the picture — 'Export cohort', not 'Download icon'.
Don't colour an icon to decorate it, and don't ship an icon-only control without a label.
Four durations and three easing curves. Motion confirms a change — a menu opening, a theme flipping — and never carries information on its own.
Nothing waits on an animation; if the interface is unusable until it finishes, that is a loading state.
| 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. |
Transition named properties with a duration and an easing token; anything user-triggered stays at --dur-fast or --dur-base.
Don't write `transition: all`, and don't animate width, height or top/left when transform will do.
Eight named layers in one place, spaced 100 apart so a value can be slotted between two without renumbering. Everything that overlaps draws its stacking order from here.
The bare z-index that fixes it today is what traps a tooltip behind a modal six months from now.
Give every new overlay one of the eight names, adding to the scale in theme.css if a layer is genuinely missing.
Don't create a stacking context — transform, filter, opacity below 1 — on an ancestor of an overlay.
Everything that commits a user to something, governed by one rule: a single primary action per view.
Commits the user to something: submitting a form, exporting a file, destroying a record. One primary per view; everything else steps down to secondary or tertiary.
Not for navigation — if it changes the URL it is a link, and assistive technology announces the two differently.
Give the primary action the most specific verb available — 'Export cohort', not 'Submit'.
Don't ship two primary buttons in one view, or use the danger variant for anything reversible.
A button whose only label is an icon, for dense toolbars, table rows and card headers. Only for actions with a settled visual convention: close, search, download, delete.
The accessible name is not optional, and any icon whose meaning has to be guessed needs a visible tooltip too.
Keep the tooltip text and the aria-label byte-identical, and give every icon button one.
Don't ship the 32px size on a touch surface — it is below the 44px minimum pointer target.
Two or more related controls presented as one unit. The connected form keeps separate actions on one object together; the segmented form is a single exclusive choice between views.
A segmented control is not a tab set or a filter — past five options, or one wrapping label, it should be a select.
Result density: Compact
Label the group itself with role='group' or role='radiogroup' and an aria-label.
Don't let a segmented control run an action, and never put a destructive button in a connected group.
Navigation: changes the URL, loads a document, or moves within the page. Inline links stay underlined in body copy, because colour alone is not a reliable signal.
Never a link for something that mutates data — prefetchers and link-scanners will fire it uninvited.
Every patient record in the demo dataset is synthetic. The generation rules, including the age and comorbidity distributions, are set out in the cohort data dictionary, and the inclusion criteria are frozen per run so a query executed today can be reproduced eighteen months from now.
Endpoint definitions follow the registry entry published at ClinicalTrials.gov(opens in a new tab), which is the source of record for the protocol version this build was validated against.
Write link text that survives being read on its own, out of context, in a list.
Don't style a button as a link: the element sets the keyboard contract and whether open-in-new-tab exists.
The densest surface in most clinical and scientific tools, and the one where state coverage matters most.
The default control for a short value the user types rather than picks — a sample ID, participant initials, a site code. For answers that are open-ended but short.
Not for a value drawn from a known set, anything past one line, or a number people will nudge.
Format: SPL-YYYY-NNNN.
Format: SPL-YYYY-NNNN.
Format: SPL-YYYY-NNNN.
Format: SPL-YYYY-NNNN.
Locked after accessioning.
Sample ID must match SPL-YYYY-NNNN.
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 / 32Over the 32-character limit — the label will be truncated on print.
| 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 |
Say what the format is before the user types it, as helper text rather than as an error.
Don't use the placeholder as the label — it vanishes on the first keystroke.
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 needing per-line validation, and not a dumping ground for data you will later parse.
Visible to the monitor at the next visit.
Visible to the monitor at the next visit.
Visible to the monitor at the next visit.
Visible to the monitor at the next visit.
Read-only once the deviation is signed.
A deviation note needs at least 20 characters.
Grows to 260px, then scrolls rather than pushing the page around.
0 / 280Let the field grow with the content up to a sensible cap, then hand overflow back to a scrollbar.
Don't cap the height at three lines and remove the resize handle.
One value from a short, known, mutually exclusive list — specimen type, cohort, review outcome. Best up to about a dozen options, where people recognise rather than recall.
Past roughly fifteen options use a combobox; for two to four, radios show every choice without a click.
Must match the specimen on the accession form.
Set at accessioning.
Set at accessioning.
Set at accessioning.
Set at accessioning.
Set at accessioning.
Specimen type is required before accessioning.
Keep the options in a stable, meaningful order and explain any disabled option in place.
Don't hide the current value behind a placeholder that reads like a choice.
A text input backed by a filtered list: type a few characters to cut hundreds of options down to one. The multi-select variant is the right control for 'any number of sites'.
Not for a list short enough to scan at a glance, and not when arbitrary text is genuinely allowed.
Turnaround time is shown on the right of each option.
Backspace on an empty field highlights the last site; press it again to remove it.
Keep the query and the list in sync, announce the result count, and make Escape a reliable way back.
Don't silently discard a half-typed query on blur — it reads as a bug and loses the user's work.
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, and never the only route to content.
No samples match
Check the year prefix, or clear the field to browse all five.
No samples match
Check the year prefix, or clear the field to browse all five.
Write a no-results state that repeats what was searched for and offers the next move.
Don't bury search behind an icon-only expander on a screen where searching is the main job.
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.
At least 12 characters. Paste is allowed and encouraged.
At least 12 characters.
At least 12 characters.
At least 12 characters.
At least 12 characters.
At least 12 characters.
Use at least 12 characters.
Allow paste, state the rules before the field, and keep the reveal available right up to submit.
Don't block paste or silently truncate at a maximum length; both push people toward weaker passwords.
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 answers 'which Monday'.
Not for a date the user already knows exactly, like a date of birth: a typed field beats sixty clicks.
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
Window 16 Feb – 10 Apr 2026
Must fall inside the study window (16 Feb – 10 Apr 2026).
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
Window 16 Feb – 10 Apr 2026
Pick the first day, then the last. Picking earlier than the start restarts the range.
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | ||||||
| 2 | 3 | 4 | 5 | 6 | 7 | 8 |
| 9 | 10 | 11 | 12 | 13 | 14 | 15 |
| 16 | 17 | 18 | 19 | 20 | 21 | 22 |
| 23 | 24 | 25 | 26 | 27 | 28 | 29 |
| 30 | 31 |
| Mo | Tu | We | Th | Fr | Sa | Su |
|---|---|---|---|---|---|---|
| 1 | 2 | 3 | 4 | 5 | ||
| 6 | 7 | 8 | 9 | 10 | 11 | 12 |
| 13 | 14 | 15 | 16 | 17 | 18 | 19 |
| 20 | 21 | 22 | 23 | 24 | 25 | 26 |
| 27 | 28 | 29 | 30 |
Window 16 Feb – 10 Apr 2026
Study window: 16 Feb – 10 Apr 2026.
Constrain to the valid window and keep out-of-range days visible but unselectable.
Don't leave range validation until submit, two screens after the mistake was made.
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 or resumable transfers; past a few hundred megabytes this needs a chunked uploader behind it.
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.
Drop an assay export here
FCS, CSV or TSV · up to 25 MB
Choose fileDrop an assay export here
FCS, CSV or TSV · up to 25 MB
Choose fileDrop an assay export here
FCS, CSV or TSV · up to 25 MB
Choose filerun-2026-03-11.zip is not a supported format. Upload FCS, CSV or TSV.
State the accepted formats and the size cap up front, then name the specific reason in the error.
Don't make drag-and-drop the only way in — keyboard and most touch users cannot drag.
Independent yes/no choices, or a single opt-in staged until the form is submitted. Reach for it whenever more than one option in a group can be true at once.
Not for a setting that applies the instant it is flipped — that is a switch.
Wrap the input inside its <label> so the whole line is a hit target.
Don't fake 'some selected' with a third visual state; set el.indeterminate so the announcement agrees.
One choice from a small, mutually exclusive set — two to five options — where seeing every option at once is worth the vertical space. The card variant adds consequence copy.
Past roughly six options use a select, and never ship a group with nothing preselected unless 'no answer' is legitimate.
Wrap every group in a fieldset with a legend and give the inputs one shared name.
Don't build radio cards from divs with click handlers; keep a real input inside the card.
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 is visible right away.
If the change only lands when the form is submitted, use 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 |
Label the state the switch controls, not the action — 'Auto-refresh cohort counts'.
Don't put a switch in a form with a Save button; people will believe the change already landed.
Choosing an approximate value inside a known range, where the direction of travel matters more than the exact number. 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.
Show the value at all times and expose it as text with its unit via aria-valuetext.
Don't let range handles cross or stack; a filter that can invert silently returns nothing.
The conventions that make a form readable before a single field is styled: label placement, required marking, where helper text and validation go, and how fields are grouped.
Do not mix label placements in one form, mark both required and optional, or move a field's meaning into placeholder text.
One scan column, no width guessing, survives translation.
Short, familiar, repeated labels. Collapses to top below 640px.
| 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> |
Used for protocol amendments and query resolution.
Enter a valid email address — for example coordinator@site.org.
Address reachable — invitation will be sent on approval.
Two-letter country code, dash, three digits — for example GB-104.
Use the format GB-104. Country code first, then three digits.
Identifier is free — not yet claimed by another site.
Put labels above the field: one scan column, and no reflow when a translation runs longer.
Don't hold validation until submit, and never signal it with colour alone.
What the interface says back — loading, empty, and error, specified here rather than improvised per screen.
A short, transient confirmation that something finished in the background — an export queued, a sync completed. 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.
Hover or tab into a toast to freeze its countdown
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.
Keep a toast to one sentence of consequence and, at most, one action.
Don't stack toasts as a status surface; three at once means the job needs a progress indicator.
A message that stays on the page until the condition behind it changes. Inline alerts speak for their section; page-level banners speak for the whole view.
Not for confirming an action just taken, and anything permanent belongs in the page copy instead.
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.
Export blocked — consent not on file
11 of the 1,284 selected samples have no recorded consent for secondary use. Remove them or request consent before exporting.
This study is locked for the interim analysis. You can read and export, but edits are disabled until 12 August.
Sample table
Say what changed, what it affects, and what the user can do about it.
Don't make an alert dismissible while the condition it reports is still true.
A compact label attached to something else. Status badges report state, count badges carry a number, and chips represent a user-set value — which is why only chips are removable.
A badge is not a button, and it cannot carry a sentence: past three words it belongs in a column.
Give every status badge its own glyph and keep that pairing constant across the product.
Don't invent a sixth status colour; a sixth meaning belongs in a column, not in a hue.
For work with a duration the user is waiting on. Determinate when you know how much is left; indeterminate when you genuinely do not, and only then.
Below about one second a progress bar reads as a glitch, and a fake bar that stalls at 90% destroys trust.
Put the item count in the label — 'Processing 312 of 1,284 samples' beats a percentage.
Don't leave a determinate bar sitting at 100% after the work is done.
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 fills.
Over a couple of seconds a skeleton says more, and anything measurable wants a progress bar.
16px ring against 15px body text — a spinner larger than its label reads as an error icon
Delay a spinner by roughly 200–300ms; most requests resolve inside that window.
Don't replace an entire populated view with one centred spinner on refresh.
A grey cast of the layout that is loading, for when you know the shape of what is coming. It holds the page geometry still so nothing jumps when the data lands.
Only when the skeleton actually matches the result — never skeleton an unknown shape.
Match the skeleton to the real layout so the content lands without a single pixel of shift.
Don't shimmer under reduced motion; the static blocks still hold the layout, which was the job.
What a collection looks like with nothing in it. Two variants, not interchangeable: a first run that teaches and offers a way in, and a filtered result that explains and offers a way back.
Do not use an empty state to apologise — zero results is a normal outcome, not a failure.
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.
Nothing in this study is both an RNA-seq assay and collected after 2025-01-01. The study itself has 1,284 samples.
Make the first-run state carry the onboarding; it is the most-read screen in a new account.
Don't ship a centred grey 'No data' and call it done.
A region or route that could not render — a request that timed out, a service that is down, a permission revoked mid-session. Always paired with a way to try again.
This is not a form-field error; using it for a validation failure hides the field that needs attention.
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
QC results for plate P-118 loaded — 45 of 48 wells passed.
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
Give the user a reference they can quote and say plainly what state their data is in.
Don't show a raw stack trace or 'Error: undefined'; log the real thing server-side.
Anything that floats above the page: all of these manage focus, close on Escape, and draw their stacking order from the z-index scale.
A focused task the user finishes or abandons before returning to the page underneath. It takes focus and makes everything behind it inert, so it is an interruption you have to justify.
Not for anything needing the page behind it, a workflow past one screen, or passive information a banner could carry.
| Pattern | Reach for it when | Wrong when | Opens focused on |
|---|---|---|---|
| Modal | Everything needed to answer is inside the dialog, and it fits one screen. | The user has to read the table behind it. | First field |
| Drawer | The form is long, or the user keeps comparing it to the row it came from. | The task deserves a URL someone can send to a colleague. | First field |
| Confirm dialog | One irreversible yes or no, nothing to fill in. | The action can be undone — offer Undo instead of asking. | Cancel |
Reach for a modal when the answer is entirely inside the dialog and fits one screen.
Do not open a modal from a modal — the first one was the wrong container.
A panel that slides in from the edge for work too long or too referential for a modal: a filter builder, a record's full detail. Left for context, right for detail and actions.
Not for a single yes or no, and not for anything a user may want to link to — at that point it is a page.
Give a drawer the same four exits a modal has, and pin its primary action in a footer that does not scroll.
Do not let a drawer silently discard typed input on an overlay click.
A small panel anchored to the control that opened it, for secondary detail or compact controls. Non-modal — the page stays live and it light-dismisses on the next click outside.
Not for anything the user has to acknowledge, and not for content long enough to need its own scrollbar.
Anchor it to its trigger, keep it under roughly two hundred words, and move focus in if it has controls.
Do not open a popover on hover, nest one inside another, or put a required step in it.
Run RUN-2291 is awaiting operator sign-off and is excluded from the totals above.
Aliquots in this cohort have been through two cycles or fewer. Three subjects have a single aliquot on cycle three, flagged in the sample list.
A few words naming a control whose icon alone does not carry its purpose. It appears after a short hover delay, immediately on keyboard focus, and leaves the moment either ends.
Never the only place a piece of information appears — a tooltip is unreachable on touch and absent from print.
Use two or three words matching the control's accessible label, on hover and on focus.
Do not put a link, a button, or a close control inside a tooltip; nobody can reach it.
The one dialog shape reserved for a decision that cannot be undone from the interface. Same width, same button order and the same wording every time, so it is recognised before it is read.
Not for anything reversible — confirming everything trains people to click through without reading.
Nothing has been archived or deleted.
Name the object and the consequence in the title, label the button with the verb, and focus Cancel.
Do not use a confirmation where an Undo toast would do, or make the destructive button dominant.
Reading surfaces, weighted toward the dense, dashboard-style work the client projects behind this studio actually are.
For comparing many records across the same attributes — sample QC across a sequencing run, enrolment across sites. It earns its weight when the columns are scannable and the numbers line up.
Not for one record's fields, and if fewer than three columns carry information it is a list with extra chrome.
| 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 |
| 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 | |
| No samples match those filters. | ||||||
| Sample | Assay | Site | Reads (M) |
|---|---|---|---|
| HLS2-0150 | Exome | Boston | 74.9 |
| HLS2-0151 | Exome | Boston | 68.2 |
| HLS2-0158 | RNA-seq | Basel | 52.6 |
| Sample | Assay | Site | Reads (M) |
|---|---|---|---|
| HLS2-0150 | Exome | Boston | 74.9 |
| HLS2-0151 | Exome | Boston | 68.2 |
| HLS2-0158 | RNA-seq | Basel | 52.6 |
Right-align numeric columns, set tabular-nums, and sort them with a numeric comparator.
Don't sort a numeric column with localeCompare; it ranks 103.2 above 12.7 and still looks ordered.
One object's summary — a label of context, a title, a line or two of detail, at most one action — on a self-contained surface. Suits browsable collections of distinct things.
Not for uniform records that need comparing field by field, and not as a layout device.
126 libraries across four sites. Mean Q30 94.1%, two re-queued for low yield.
Uppsala · 96.4 M reads · QC pass
Basel · 52.6 M reads · QC pass
SelectedNo cursor change, no lift, nothing focusable. A card that cannot be clicked must not behave as if it can.
One real link, stretched over the card with a pseudo-element. Hover lifts, focus rings — two different signals for two different inputs.
Give a clickable card exactly one link or button, stretched across the surface with an ::after overlay.
Don't nest a second button or link inside a clickable card.
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, and one trailing value, you are building a table.
Keep one scannable value per row, in the same position on every row.
Don't build a list from divs with click handlers; rows that navigate are links, rows that act are buttons.
Identify a person at a glance — the owner column of a run table, a list of reviewers. Falls back to initials, and can carry a presence dot where presence changes what someone does.
Not decoration: if who did it matters to the task, the name has to be in the text too.
Pair every avatar with the person's name, and give presence dots a shape difference as well as a colour.
Don't let a status dot carry meaning through colour alone at 11px.
One number a person actually watches, its label, and how it has moved since the last comparable period. 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.
Colour the delta by whether the change is good, not by its sign.
Don't put eight tiles on a dashboard because you have eight numbers.
Collapse long, independently useful sections so the shape of the page stays visible — protocol caveats, methodology notes, release history. Good when a reader needs one block of several.
Never behind an accordion: anything required to finish the current task, and form fields in particular.
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.
WGS only. Batches are released once the full plate clears Q30 ≥ 93%, so a single failing library holds the batch.
Single-cell runs are demultiplexed locally before upload. Expect the run date to precede the ingest date by up to 48 hours.
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.
None. RNA-seq, WGS, Exome, Methylation and scRNA-seq each carry their own threshold; the shared column is a display convenience, not a shared rule.
Pick single-open for alternatives and multi-open for reference material, and say which one in the props.
Don't put required form fields inside a panel, where a validation error can fire somewhere invisible.