Components · Data

Data grid

The substrate under every list of patients, results and events. Its defaults decide what a clinician sees first and what they never see at all, which makes sorting, filtering and row insertion safety behaviours rather than conveniences.

Stable · v1.0 shadcn/ui TanStack Table WCAG 2.2 AA

Overview

A general-purpose data grid optimises for flexibility: sort anything, filter anything, hide anything, remember what the user did last time. Every one of those defaults is wrong in a clinical queue, because each of them can silently remove a patient from view.

This component keeps the flexibility and inverts the defaults. Everything is available; almost nothing is on by default; anything currently hiding data says so on screen.

Complete by default

Opens showing every row in scope. No remembered filter, no collapsed group, no pagination hiding the top of a queue.

Stable under the hand

Rows never move beneath a pointer. Live data arrives staged and announced, inserted when the clinician is ready.

Honest about state

Any non-default sort, active filter or hidden column is stated in the grid's own chrome, legible to someone who did not set it.

Anatomy

Data grid · default state
23 cases · sorted by priority, then longest waiting Density: comfortable
Priority indicator Finding
Critical HARLAND, A.
MRN 44 812 907
STEMI pattern — anterior V2–V4 4 min0.94
Urgent OKONKWO, B.
MRN 51 330 264
hs-troponin rising — NSTEMI not excluded 19 min0.71
Urgent DELACROIX, M.
MRN 47 120 883
ST depression — inferior leads 27 min0.66
Advisory NAKAMURA, T.
MRN 58 904 471
Interpretation limited — paced rhythm 33 min—
ElementRule
State bar Row count and the active sort, always. This is the component's honesty surface.
Sticky header Column meaning stays visible while scrolling. Never sticky at 200 % zoom, where it would consume the viewport.
Sortable header A real <button> inside the <th>, with aria-sort on the header.
Priority rail 4 px of IEC hue where rows carry clinical priority. Always paired with a text column.
Selection --accent fill plus aria-selected. Never colour alone.

Sorting

The default sort is a product decision with clinical consequence, declared once and enforced by the component. In AcuteLine it is priority, then longest waiting — see Triage worklist.

Non-default sort · stated and reversible
Sorted by confidence, highest first — not clinical priority
Priority indicator
HARLAND, A.0.944 min
VASQUEZ, R.0.8841 min
OKONKWO, B.0.7119 min

A reviewed 41-minute case now sits above an urgent 19-minute one. The bar says so in words, not with a small arrow icon.

Live data

Rows never move under the pointer

New rows are staged, not inserted. A banner announces how many are waiting and the clinician inserts them. Auto-insertion is permitted only after a documented idle interval with no pointer or keyboard activity — because a queue that reorders while someone is reaching for row two produces wrong-patient selection.

Staged arrivals
2 new cases — 1 critical, 1 urgent
Priority indicator Patient Finding Waiting
HARLAND, A.STEMI pattern — anterior V2–V44 min
OKONKWO, B.hs-troponin rising19 min

Once inserted, new rows carry a temporary marker so a clinician can see what changed since they last looked. The marker is positional and textual, never a colour wash that could be mistaken for priority.

Filtering and columns

Density

DensityRow heightUseTouch
comfortable~44 px Default. Any grid whose rows are tappable.Yes
compact~28 px Dense analytic review, pointer only, non-clinical-priority data.No

Density never removes information — it reduces padding only. A compact grid that drops the MRN is not compact, it is incomplete. On touch platforms use comfortable, and the row must satisfy the physical touch floor — the component cannot tell a touch surface from a workstation, so this is yours; see Scaling.

Inside a cell

Density above governs how much padding a row has. This governs what happens when the content still does not fit, which is the more consequential question.

The rule

A clinical cell wraps. It never truncates. A clipped identifier is a wrong-patient hazard and a clipped value is a number with an unknown quantity of digits missing. If the content does not fit, the column is too narrow or the grid has too many columns — both are fixable, and neither is fixed by an ellipsis.

The common answer is to truncate and put the full text in a tooltip. It fails here for the reason it fails on Navigation and Accordion: hover does not exist on a touch cart, so the value simply is not there — and in a grid the thing being hidden is a name, an identifier or a result rather than a section label. Tooltip already forbids a tooltip carrying anything load-bearing; a truncated cell makes the tooltip the only copy.

Zebra rows

Alternating row fills help the eye track across a wide grid, and they are permitted. What they cost is contrast headroom: every foreground in the grid now has two backgrounds to clear rather than one, and the striped row is the one that gets forgotten. Check both, in both themes — the checklist greyscale pass is the place. Where the grid already carries a priority rail and status chips, dividers are the safer choice.

Editing in place

A form control inside a row is convenient and is the wrong place for a value with a clinical envelope. A dose stepper in a 36 px row is a control that can be nudged by a stray scroll, beside eleven identical ones, with no room for the units, the permitted range or the confirmation that Numeric input requires.

What may be done to a row or a column

Grids accumulate interactions — reorder, resize, freeze, hide, pin, launch. Each is reasonable and most arrive with an affordance that only some users can perceive.

InteractionAvailable On what terms
Sort a columnYes A real button in the header with aria-sort, and the result stated in the bar — see Sorting.
Sort several columnsConditionally Only if the resulting order can be said in one sentence in the state bar — "priority, then longest waiting". An order that cannot be stated cannot be checked, and one reached by holding a modifier key nobody mentioned cannot be reproduced.
Resize a columnYes The handle is always present, not revealed on hover, and the same adjustment is reachable from the header's menu — dragging is never the only means, per SC 2.5.7.
Reorder columnsYes Same terms. A drag with no visible handle is undiscoverable and drag-only.
Freeze a columnYes, and the identity column by default A row scrolled horizontally must never lose its patient. This is the one freezing decision the component makes rather than the deployment.
Hide a columnYes, stated Never a column carrying identity or clinical priority. What is hidden is named in the bar, because the next clinician at that workstation did not hide it.
Reorder rows by draggingNo Row order in a clinical queue is a clinical statement — see Triage worklist. It is not something a reader rearranges by hand.
Double-click a rowNo Undiscoverable, with no touch equivalent, triggering a large action from an ambiguous gesture. The row's primary action is a link or button in the row.
Right-click for actionsNo Nothing advertises it, the touch equivalent is a long press competing with scroll, and it hides actions from the readers most likely to need them.

The pattern underneath: an interaction is available when its affordance is visible to everyone who can use the grid. Hover-revealed controls, drag-only reordering, modifier-key combinations and double-click all fail that in the same way, and they fail it silently — every one of them works perfectly for the person who built it.

Virtual scrolling, and the count that must stay true

Pagination prefers virtual scrolling because the set stays continuous and its size stays on screen. That preference comes with an obligation most implementations miss.

A windowed grid lies unless it is told not to

Virtualisation keeps perhaps thirty rows in the DOM out of three hundred and forty. A screen reader counts what is in the DOM, so it announces a table of thirty rows — and the clinician using it believes they have reached the end of the queue.

The grid carries aria-rowcount for the true total and aria-rowindex on each row for its real position. Without them, virtualisation trades a scrollbar the sighted reader can see for a total the non-sighted reader cannot.

Empty, loading and degraded

Do
No cases awaiting review
Queue is current. Last analysis 14:47. Monitoring 4 sources.

Empty and demonstrably alive. The timestamp and source count are the proof.

Don't
Nothing here

Indistinguishable from a dead ingestion pipeline. An empty clinical queue must always prove why it is empty.

What the component enforces, and what it only asks for

Almost everything this page argues about sorting, staging and selection is behaviour the grid does not own. It renders rows you give it, in the order you give them.

RuleHeld by How
Every column states its unitThe component unit is part of the column definition and renders in the header.
Asserted: DataGrid: a column's unit reaches the header
A caption states what the set is and how it is orderedThe component caption is required, and it is the table's accessible name.
Asserted: DataGrid: real table markup with scoped headers and a caption
Row count and position are announcedThe component aria-rowcount and aria-rowindex, including under virtualisation.
Asserted: DataGrid: a virtualised window announces the whole set, not the window
An empty grid says why it is emptyThe component empty is required, and takes an Empty state rather than a string.
Asserted: DataGrid: an empty set renders the required empty state
A new row never inserts itself under the pointerYou rows is an array. Staging an arriving row until the reader asks for it happens before it reaches this component — see Reprioritisation.
Sort order is stated and does not change on its ownYou There is no sort state here. What the order is, and whether it changed, is yours.
Selection survives a sort, or is cleared explicitlyYou The grid holds no selection. Nothing here can notice that a re-sort has left a selection pointing at different rows.
Nothing is persisted between sessionsYou Also yours — and a grid that reopens with a colleague's sort is one more piece of inherited state on a shared workstation.
Why this is written down

A rule stated on a component page reads as a rule the component keeps. 4 of these 8 are; 4 are obligations this page was quietly handing to the reader. The wrong-row hazards this page exists to prevent — insertion under a moving pointer, and a selection that outlives the ordering it was made in — are both outside the component.

Do's and don'ts

Do
2 new cases — 1 critical, 1 urgent

New rows staged and announced. The clinician inserts them when their hand is not already moving.

Don't
NEW — inserted at top just now
OKONKWO, B.

Live insertion at the top. Everything below moves down mid-reach, and the wrong patient opens.

Do
23 cases · sorted by priority, then longest waiting Density: comfortable

The state bar states the sort in words, permanently, for whoever walks up to the screen.

Don't
23 cases ▼

A small arrow in a header. A colleague cannot tell this queue is sorted by confidence, not acuity.

Do
PatientFinding
OKONKWO, B.
MRN 51 330 264
hs-troponin rising — NSTEMI not excluded

The cell wraps. Every digit of the identifier and every word of the finding is on screen, on any device.

Don't
PatientFinding
OKONKWO, B. MRN 51 33… hs-troponin rising — NSTE…

The full text is in a tooltip, which does not exist on a cart. A truncated MRN and a finding that stops before "not excluded".

Do

Column header: Waiting

Resize, freeze and hide all reachable from a visible labelled control, and by dragging for anyone who prefers it.

Don't

Resizer appears on hover · reorder by dragging the header · options on right-click

Three affordances, none perceivable on a touch cart and none announced. Each works perfectly for the person who built it.

Do

<table aria-rowcount="340">
30 rows mounted · 340 announced

The window is a rendering detail. The count a screen reader hears is the count that exists.

Don't

<table> with 30 virtualised rows
announced: "table, 30 rows"

The clinician using a screen reader believes they have reached the end of the queue. Three hundred and ten cases below the fold say otherwise.

Do

Dose is edited in a panel: units, permitted range, and what the change replaces, all visible.

The envelope fits, so the confirmation means something. See Numeric input.

Don't

A dose stepper inside a 36 px row, eleventh of fourteen identical rows.

Nudgeable by a stray scroll, with no room for units or range, in a list where every row looks the same.

Accessibility

Outcomes of use

What this contributes to, in the terms of Usability & context of use. These are attributes believed to contribute to an outcome; the outcome itself is settled by observing real use in a specified context, not by this page.

Clinical safety notes

Risk controls this page specifies

Trace these in your risk file (ISO 14971) and usability engineering file (IEC 62366-1). Which of these the component keeps and which are yours is stated in the guarantees table; a control the component does not keep is one your risk file must show you keep.

Implementation

$ npx shadcn@latest add https://md.notjustany.tech/r/data-grid.json
<DataGrid
  caption= "Triage worklist — 19 cases, newest first" 
  columns={columns}          // each declares header, unit and whether it is numeric
  rows={cases}
  rowKey={(c) => c.id}
  density= "comfortable" 
  empty={<EmptyState title= "No cases waiting"  searched= "this ward, last 24 h"  />}
/>

// Sorting, filtering and the staging of incoming rows are the caller's.
// The component does not persist them, and the rules above say what a
// correct implementation has to do — including that a new row never
// inserts itself under a pointer.

What the component takes. This table used to list defaultSort, persistState, stageIncoming, bypassFilter, onRowActivate — props the component does not have, and never had. Found by the registry gate’s props-table rule.

PropTypeDefaultNotes
captionstring— Required. What the set is. A real caption, visible by default.
columnsColumn<Row>[]— Required. Header, unit, alignment, numeric, and a render function per column.
rowsRow[]— Required. The rows to render — or the window of them, when virtualised.
rowKey(row: Row) => string— Required. A stable key per row, so a re-sort does not remount the wrong row.
densitycomfortable | compactcomfortable Padding only; nothing is dropped. The component cannot tell a touch surface, so choosing comfortable there is yours.
emptyReact.ReactNode— Required. Rendered when there are no rows, so an empty grid cannot be a header with nothing under it.
virtualised{ totalRows: number; firstRowIndex: number }— The whole set’s size and the first rendered row, so a screen reader counts the set rather than the window.
NotJustAnyMed.Tech Design System · Data grid · v1.0 · draft for review
Reference applications named in this system are fictional; all patient data shown is fabricated.