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 why switching themes is a token remap rather than a second design pass.
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.
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.
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.
| 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. |
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.
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 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.
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.
The samples above are divs carrying the heading styles, not real h1–h6 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.
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. |
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.
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 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.
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.
| 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.
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 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.
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.
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. |
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 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.
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.
| 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.
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.
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.
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 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.
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.
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. |
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 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.
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.
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?”
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 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.
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.
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.
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.
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.
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 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.
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.
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.
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 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.
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.
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.
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 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.
Navigation: changes the URL, loads a document, or moves within the page. Inline links stay underlined inside body copy because colour alone is not a reliable signal for a colour-blind reader; standalone links drop the underline until hover, since there is no surrounding prose they need to be distinguished from.
Never a link for something that mutates data. An href gets fetched by prefetchers, crawlers and email link-scanners, so a 'delete' behind one is fired by machines nobody invited. And a link that opens a new tab has to say so in text assistive technology can reach — the arrow glyph alone is invisible to anyone not looking at it.
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.
Visited can only ever be a colour. Browsers block every other property on :visited to stop history sniffing, so the treatment is a single hue shift — the link colour muted toward --text-tertiary — and nothing else. Hover therefore strengthens the underline rather than shifting the colour, which leaves the colour channel free for visited to use. target="_blank" always carries rel="noopener noreferrer": without noopener the opened page gets a handle on this one through window.opener and can redirect it.
Write link text that survives being read on its own. Screen reader users pull the links out of a page as a list, and a page of 'read more' becomes a list of identical links pointing nowhere in particular.
Don't style a button to look like a link to make it feel lighter. The element sets the keyboard contract — Space activates a button, Enter activates a link — and it decides whether the browser offers open-in-new-tab at all.
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.
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).
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 |
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.
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 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.
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.
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 / 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.
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 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.
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.
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.
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.
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 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.
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.
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.
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.
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 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.
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.
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.
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.
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 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.
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.
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.
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.
Allow paste, state the rules before the field rather than after the failure, and keep the reveal available right up to submit.
Don't block paste or silently truncate at a maximum length. Both push people toward shorter, more memorable, weaker passwords — and neither prevents anything.
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.
| 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.
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.
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 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.
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.
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.
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.
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 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.
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.
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.
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 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.
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.
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.
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 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.
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.
| 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.
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 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.
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.
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.
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 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.
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.
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.
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.
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 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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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. 'Export blocked — consent not on file' beats 'An error occurred' every time.
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.
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.
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.
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.
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.
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.
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.
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.
Put the item count in the label — 'Processing 312 of 1,284 samples' answers 'how long' far better than a percentage does.
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.
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.
16px ring against 15px body text — a spinner larger than its label reads as an error icon
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.
Delay a spinner by roughly 200–300ms. Most requests resolve inside that window, and a spinner that flashes for 80ms is pure visual noise.
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.
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.
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.
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.
Shimmer under reduced motion. The animation is suppressed entirely here; the static blocks still hold the layout, which was the actual job.
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.
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.
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.
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.
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.
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.
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
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".
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.
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.
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.
A focused task the user finishes or abandons before returning to the page underneath — editing a record, resolving a merge conflict, confirming a batch operation. It takes focus and makes everything behind it inert, so treat it as an interruption you have to justify.
Not for anything the user needs to read the page behind it to answer, not for a workflow that runs past one screen, and never for passive information a banner or a toast could carry. Two stacked modals means the first one was the wrong container.
Built on the native <dialog> element. Calling
showModal() hands the platform the four things a hand-rolled modal
gets wrong: the top layer (so no ancestor's overflow or
transform can clip or capture it), the focus trap, the inert
background, and Escape. ::backdrop is painted with the
--overlay token, so the scrim re-points with the theme like
everything else. What is left to write by hand is overlay-click dismissal, the body scroll
lock, and returning focus to the exact element that opened it — about thirty lines instead of
three hundred.
| 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 |
Choose by what the task needs from the page behind it. Modal when the answer is entirely inside the dialog and fits one screen. Drawer when the form runs long or the user keeps checking it against the row underneath. Confirm dialog only for a single irreversible yes or no with nothing to type — same width, same wording, same button order every time, so it is recognised before it is read.
Do not let a confirm dialog grow a form, do not open a modal from a modal, and do not reach for a drawer just because the content would not fit a modal — content that will not fit a drawer either belongs on its own page. And never put the only copy of something behind an overlay: overlays cannot be linked, bookmarked, or printed, so anything a user may need to send a colleague belongs on the page.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
Reading surfaces. Weighted toward dense, dashboard-style work, because that is what the client projects behind this studio actually are.
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.
| 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. | ||||||
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.
| 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 |
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.
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 sort a numeric column with localeCompare. It ranks 103.2 above 12.7 above 9.4, the column still looks ordered, and it ships.
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.
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.
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.
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 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.
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.
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.
Keep one scannable value per row, in the same position on every row. The eye locks onto the column, not the label.
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.
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.
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.
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 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.
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.
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.
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 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.
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.
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.
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.
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 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.