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.
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
| Priority indicator | Finding | ||||
|---|---|---|---|---|---|
| Critical | HARLAND, A. MRN 44 812 907 |
STEMI pattern — anterior V2–V4 | 4 min | 0.94 | |
| Urgent | OKONKWO, B. MRN 51 330 264 |
hs-troponin rising — NSTEMI not excluded | 19 min | 0.71 | |
| Urgent | DELACROIX, M. MRN 47 120 883 |
ST depression — inferior leads | 27 min | 0.66 | |
| Advisory | NAKAMURA, T. MRN 58 904 471 |
Interpretation limited — paced rhythm | 33 min | — |
| Element | Rule |
|---|---|
| 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.
- Default sort is restored on every session start. A sort order inherited from the previous shift is a hazard, so it is never persisted.
- A non-default sort is stated in the bar, in words, persistently — a clinician walking up to a colleague's screen must be able to see the queue is not in priority order.
- One-click reset is always present while a non-default sort is active.
- Sorting never reorders rows under the pointer. It is an explicit action, so the reorder is expected — but focus stays on the header that was activated.
| Priority indicator | |||
|---|---|---|---|
| HARLAND, A. | 0.94 | 4 min | |
| VASQUEZ, R. | 0.88 | 41 min | |
| OKONKWO, B. | 0.71 | 19 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
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.
| Priority indicator | Patient | Finding | Waiting |
|---|---|---|---|
| HARLAND, A. | STEMI pattern — anterior V2–V4 | 4 min | |
| OKONKWO, B. | hs-troponin rising | 19 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
- No filter is active on open. Filters are never persisted across sessions.
- Hidden row count is always shown next to the filter control —
Showing 5 of 23 · 18 hidden by filter. - Critical rows bypass filters entirely and render with a note that they fall outside the current filter.
- Hidden columns are disclosed in the state bar. A column a clinician does not know is missing is worse than one they chose to hide.
- Export and print always contain every row in scope, filtered or not, with the filter stated on the output.
Density
| Density | Row height | Use | Touch |
|---|---|---|---|
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.
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.
- Anything with a clinical envelope is edited where the envelope fits — a panel or a dialog that can show units, range and what the change replaces.
- Inline editing is for fields with no envelope: a free-text note, a flag, an assignment. Even then the cell is a real labelled control, not a div that becomes editable on a click nobody advertised.
- An editable cell is visibly editable before it is touched. A control that appears on hover is a control that does not exist on touch and is invisible to a keyboard user until focus lands in it.
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.
| Interaction | Available | On what terms |
|---|---|---|
| Sort a column | Yes | A real button in the header with aria-sort, and the result stated in the
bar — see Sorting. |
| Sort several columns | Conditionally | 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 column | Yes | 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 columns | Yes | Same terms. A drag with no visible handle is undiscoverable and drag-only. |
| Freeze a column | Yes, 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 column | Yes, 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 dragging | No | 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 row | No | 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 actions | No | 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.
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.
- No tab stop per cell. This is a
<table>, not the ARIA grid widget: its cells are not focusable, and a screen reader’s own table commands move between them. This line used to promise arrow-key cell navigation the component has never had. Controls you put in cells are tab stops each; if a row carries several, a grid widget with its own keyboard model is yours to build. - The rendered window never changes what is true. Counts, the state bar and any off-screen alarm report describe the whole set, never the rows currently mounted.
- Scroll position survives leaving and returning, per Pagination. Re-reading a queue to find your place is where the second-to-last row gets skipped.
- Loading more on scroll is never the only route to the end. The total is stated from the start, so a clinician always knows how much there is — the objection infinite scroll earns.
- New rows arriving are staged, not spliced — see Live data. Virtualisation makes insertion cheap, which is exactly why it needs the same restraint.
Empty, loading and degraded
Empty and demonstrably alive. The timestamp and source count are the proof.
Indistinguishable from a dead ingestion pipeline. An empty clinical queue must always prove why it is empty.
- Loading shows skeleton rows, never a blank grid, so the clinician knows data is coming rather than absent.
- A partial load is an error, not a shorter list. If some rows failed to fetch, the grid says so at Urgent — silent partial coverage is the most dangerous state a clinical queue can be in.
- Stale data is labelled with its age once beyond a documented threshold.
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.
| Rule | Held by | How |
|---|---|---|
| Every column states its unit | The 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 ordered | The 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 announced | The 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 empty | The 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 pointer | You | 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 own | You | There is no sort state here. What the order is, and whether it changed, is yours. |
| Selection survives a sort, or is cleared explicitly | You | 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 sessions | You | Also yours — and a grid that reopens with a colleague's sort is one more piece of inherited state on a shared workstation. |
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
New rows staged and announced. The clinician inserts them when their hand is not already moving.
| NEW — inserted at top just now | |
| OKONKWO, B. |
Live insertion at the top. Everything below moves down mid-reach, and the wrong patient opens.
The state bar states the sort in words, permanently, for whoever walks up to the screen.
A small arrow in a header. A colleague cannot tell this queue is sorted by confidence, not acuity.
| Patient | Finding |
|---|---|
| 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.
| Patient | Finding |
|---|---|
| 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".
Column header: Waiting
Resize, freeze and hide all reachable from a visible labelled control, and by dragging for anyone who prefers it.
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.
<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.
<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.
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.
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
- A virtualised grid carries
aria-rowcountandaria-rowindex. A screen reader counts the DOM, so a window of thirty rows out of three hundred and forty is announced as a table of thirty — see Virtual scrolling. - A static table, not a grid widget. Cells are not tab stops; a screen reader moves between them with its own table commands. Arrow-key cell navigation is not provided, and this bullet used to say it was.
- A real
<table>with<th scope="col">. Div-based grids lose screen-reader table navigation entirely. - Sorting is yours. The component renders rows in the order given. If you
sort, put a real button in the header and
aria-sorton the sorted column, and say the order in words above the grid. - DOM order equals visual order. Never reorder with CSS — announced order must match seen order.
- Selection is yours. The component has none. If you add it, use
aria-selectedand a cue that is not colour. - Live updates announce through one polite summary region — "2 new cases" — never by making rows live regions.
- Sticky headers are disabled at high zoom and the grid reflows rather than scrolling horizontally at 320 px.
- Row actions are reachable by keyboard without entering a roving-tabindex maze; the primary action is a link or button in the last cell.
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.
- Effectiveness — Completeness dominates here: every case in scope is visible. Accuracy is secondary but real — the row opened is the row intended.
- Efficiency — Time to locate the highest-acuity case, and the number of scrolls, sorts and filters needed to get there.
- Satisfaction — Trust that the queue is complete and genuinely in the order it claims. A single discovered omission costs more than a hundred correct renders earn.
Clinical safety notes
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.
- No persisted sort or filter. (you) Mitigates: a clinician inheriting a colleague's view and believing it is the default.
- Non-default state stated in words. (the component) Mitigates: a re-sorted queue being read as clinical priority order.
- Criticals bypass filters. (you) Mitigates: the highest-acuity patient being filtered out of view.
- Rows staged, never inserted under the pointer. (you) Mitigates: wrong-patient selection from a queue reordering mid-reach.
- Density never removes information. (you) Mitigates: identifiers dropped to fit more rows.
- Partial load raises an alarm. (you) Mitigates: silent incomplete coverage.
- Export contains everything in scope. (you) Mitigates: a handover or audit document that silently inherits a filter.
- Empty state proves liveness. (the component says why it is empty; proof of life is you) Mitigates: a failed pipeline reading as a quiet ward.
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.
| Prop | Type | Default | Notes |
|---|---|---|---|
caption | string | — | Required. What the set is. A real caption, visible by default. |
columns | Column<Row>[] | — | Required. Header, unit, alignment, numeric, and a render function per column. |
rows | Row[] | — | 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. |
density | comfortable | compact | comfortable |
Padding only; nothing is dropped. The component cannot tell a touch surface, so choosing comfortable there is yours. |
empty | React.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. |
Related
- Affordance & perceivability — the general rule this page applies: a control exists only if every reader who can operate it can perceive it.
- Triage worklist — this component's principal use.
- Alert Banner — the compact variant used inside rows.
- Scaling & displays — row height on touch.
- Typography — tabular numerals in columns.