Tabs
Peer views of one subject, only one visible at a time. Tabs are the only structural component whose entire purpose is to hide things — which is why their rules are about what may never be hidden.
Overview
Tabs solve a real problem — a subject with several equally valid views that will not fit on one screen. They solve it by concealment, and concealment in clinical software is the mechanism behind most of the failure modes elsewhere in this system: the filtered-out patient, the collapsed queue, the value below the fold.
So the component is permitted, narrowly, with one governing rule: nothing a clinician needs in order to notice something may live behind a tab.
Peer views
Tabs hold alternative views of one subject, never steps in a sequence and never unrelated destinations. If the tabs are a workflow, it is a wizard; if they are places, it is navigation.
Nothing urgent hidden
An active alarm is never behind a tab. Where a hidden panel holds something waiting, the tab says how much.
Addressable
The selected tab is in the URL. A clinician can send a colleague the view they are looking at, and the browser's back button does what it says.
Anatomy
12-lead ECG
Critical| Element | Rule |
|---|---|
| Tab label | Names the content, not the category. "Troponin", not "Labs". One or two words, never truncated. |
| Count | How many items are waiting in that panel, and what they are. This is what makes hiding acceptable — without it, an empty-looking tab and a full one are identical. It is a Badge, not a number: “Troponin 2” announces a quantity with no unit, and 2 results, 2 prior studies and 2 unread messages are different facts. Severity never colours it — the alarm marks the tab. |
| Selected indicator | 2 px --primary underline and full-contrast text. Never colour
alone. |
| Panel | One subject's alternative view. Never a different patient, never a different encounter. |
What may never be behind a tab
An active Critical or Urgent alarm is never concealed by a tab. If a condition arises in a hidden panel, the alarm renders in the docked region above the tabs, where it is visible regardless of which tab is selected — see Alert Banner. The tab additionally marks itself, so the clinician knows where to go, but the alarm does not wait there.
The alarm is docked above and fully readable without changing tab. The Troponin tab marks itself so the clinician knows where the evidence is — it does not have to be found.
- Never hide an alarm. As above.
- Never hide patient identity. The patient header sits above the tab strip, never inside a panel.
- Never hide the primary action of the screen behind a non-default tab.
- Never hide a required form field. A clinician cannot submit a form whose errors are in a panel they cannot see; if a form needs tabs, it needs to be a sequence instead.
Structure
- Two to five tabs. One tab is not a tab. Beyond five, the labels shrink or scroll and the set stops being scannable — that content wants navigation, not tabs.
- Never nest tabs. Tabs inside tabs cannot be held under interruption and cannot be described unambiguously by assistive technology.
- The default tab is the one the screen exists for, and it is stable — never "whichever was open last time", which produces a colleague's view on your screen.
- Overflow wraps. It does not scroll, and it never collapses into a menu. A tab inside an overflow menu is hidden twice, and a strip that scrolls sideways offers nothing a reader can see — the cropped-tab hint is a convention you have to already know, and on touch the sideways swipe competes with the page. Wrapping costs a second row and shows every label. This page asked for scrolling until the rule was read against the code, which wraps; the code was right.
- Selecting a tab never reorders the strip. Mainstream systems let a tab chosen from an overflow menu move to the end of the row, shifting every other tab to make room. The set a clinician learned the shape of is then rearranged by the act of using it, under a pointer already moving — the objection in Reprioritisation, applied to navigation.
- Panels keep their state when a clinician switches away and back — an annotation toggled on in the ECG view is still on when they return.
Three peer views of one patient. Counts make the hidden content countable.
A sequence, an unrelated destination and a support page in one strip. None of these are peer views of a subject.
Content behind a tab is still content in the record
A clinician who wants to know whether a patient is on amiodarone presses Ctrl + F and types the word. If the answer is in a panel they have not opened, the browser reports no match — and no match is not the same answer as no amiodarone.
An unselected panel is hidden from view, never hidden from search. Content that exists in the record and cannot be found by looking for it is the failure this component is most likely to cause, because concealment is what a tab is for.
The platform has the mechanism. hidden="until-found" keeps a panel invisible and
out of the accessibility tree while leaving its text reachable by the browser's own find; when a
match is inside, the browser fires beforematch and reveals it. The component listens
for that and selects the matching tab, so the strip and the panel agree — otherwise the
reader ends up reading a panel whose tab still says it is not selected.
- This is the same argument that rebuilt the accordion.
Accordion is a native
<details>for exactly this reason. Tabs kept plainhiddenfor longer only because nobody checked. - It does not weaken the concealment rules. A panel is still not read out, still not tabbed into, and an alarm still never waits in one — see What may never be behind a tab. Findable is not the same as present.
- Engines without support lose nothing. An unknown value on
hiddenis truthy, so the panel hides as it did before. - Server-rendered markup still says
hiddenuntil the component mounts, because React types the attribute as a boolean. The behaviour is asserted in a real browser rather than in the render gate, which is the honest place for it.
Ctrl + F “amiodarone” → the Medications tab opens with the match in view.
The record answered the question that was asked of it. The strip moved to match what the reader is now looking at.
Ctrl + F “amiodarone” → 0/0
The drug is in the record and the search says it is not. A reader who trusts the search now believes something false about the patient.
Arrowing through a tab is not reading it
Arrow keys move focus only; Enter, Space or a click selects. ARIA’s authoring practices permit either, and this component selected on arrow for a long time while the accessibility notes below said it did not — the per-bullet audit of the safety notes was what caught it. Automatic activation walks a keyboard user through every panel between the first tab and the fourth, rendering, announcing and loading each in passing. Manual activation means a tab is selected because someone chose it. What follows is about that selection.
onChangeis a selection event, not a view. If it is wired to record that a clinician looked at a panel, a tab chosen and left in a second files a viewing that did not happen. That is the objection Editing the draft makes to dwell timers, arriving through a keyboard instead.- Nothing else may hang off selection either — not marking a result acknowledged, not clearing a notification, not starting a billable action. Selecting a tab changes what is on screen and nothing in the record.
- Where a panel is expensive to populate, fetch on selection but debounce it, so traversal does not fire four requests. The component holds no such state; this is yours.
What the component enforces, and what it only asks for
The tab strip holds its keyboard and its concealment rules. What it cannot hold is the decision about what is safe to conceal at all, which is the subject of most of this page.
| Rule | Held by | How |
|---|---|---|
| One tab stop, with arrow keys inside | The component | A roving tabIndex with Left, Right, Home and End.Asserted: Tabs: arrow keys move focus only, and Enter selects |
| A closed panel is findable by Ctrl + F | The component | hidden="until-found", and beforematch selects the tab so the strip agrees with what was revealed.Asserted: Tabs: an unselected panel is hidden until found, not just hidden |
| A count on a tab says what it counts | The component | The count is a Badge, so a bare number with no noun cannot be rendered. Asserted: Tabs: a count on a tab says what it counts |
| Selection is not carried by colour alone | The component | A 2 px underline and a weight change, plus aria-selected.Asserted: Tabs: selection is an underline and a weight, not colour alone |
| An alarm is never concealed by a tab | You | The component renders the panels it is given. Hoisting an alarm out of a panel into the docked region happens in your layout — nothing here can detect that you did not. |
| Arrowing along the strip selects nothing | The component | Arrow keys, Home and End move focus; onChange fires on Enter, Space or a click. The tab stop follows focus inside the strip and returns to the selected tab on leaving it. Driven in a browser.Asserted: Tabs: arrow keys move focus only, and Enter selects |
| Nothing is recorded when a tab is selected | You | onChange is a selection, not a reading. If you file a view, acknowledge a result or start a billable action there, a tab chosen and left in a second files one of them. |
| Two to five tabs, and never nested | You | tabs is an array of any length, and nothing stops a panel containing another Tabs. |
| Panels keep their state across a switch | You | Panel content is yours; the component only hides and shows it. |
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 rule this page is named for — that an alarm never waits inside a hidden panel — is the one the component cannot keep.
Do's and don'ts
Counts make the hidden content countable, which is what buys the right to hide anything.
No counts. A tab with two unreviewed results looks exactly like an empty one.
Accessibility
- Full ARIA tabs pattern —
role="tablist"with an accessible name,role="tab"witharia-selected, androle="tabpanel"associated back to its tab. - Roving tabindex. One tab stop for the strip; arrow keys move between tabs, Home and End jump to the ends.
- Manual activation. Arrow keys move focus; Enter or Space selects. Auto-activation on arrow renders every panel in passing, which is noisy for screen-reader users and expensive when a panel loads data.
- Selected state is not colour alone — underline plus text weight plus
aria-selected. - Counts are in the tab's accessible name: "Troponin, 2 items" rather than a visual badge a screen reader announces as a bare number.
- Panels are labelled by their tab and are focusable so keyboard users land in the content after selecting.
- At 320 px and 200 % zoom the strip scrolls horizontally with visible affordance; labels never truncate.
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 — nothing needed goes unnoticed because it was in another panel. Counts and docked alarms are what buy the right to hide anything at all.
- Efficiency — one subject's views reachable without losing context or re-establishing identity, which is what tabs are genuinely good at.
- Satisfaction — confidence that the screen is not concealing something. A clinician who checks every tab before acting has been given a strip that does not tell them enough.
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.
- Alarms never behind a tab. (you) Mitigates: a Critical condition arising in a hidden panel and waiting there unseen.
- Counts on tabs with pending content. (the component names what it counts; supplying them is you) Mitigates: an unattended result being indistinguishable from an empty view.
- Patient identity above the strip. (you) Mitigates: acting inside a panel with the record's identity out of view.
- Stable default tab, never last-used. (you) Mitigates: inheriting a colleague's view and assuming it is the standard one.
- No nested tabs; five maximum. (you) Mitigates: structure that cannot be held under interruption.
- Panels retain state. (you) Mitigates: silently reverting a deliberate view setting such as annotation or calibration.
- Manual activation. (the component) Mitigates: loading and announcing panels a clinician only passed through.
Implementation
$ npx shadcn@latest add https://md.notjustany.tech/r/tabs.json
<Tabs
label="Patient record"
activeId={active}
onChange={setActive}
tabs={[
{ id: "ecg", label: "ECG", content: <p>12-lead, acquired 14:26</p> },
{ id: "troponin", label: "Troponin",
count: { value: 2, of: { one: "result", other: "results" } },
content: <p>47 ng/L, rising</p> },
]}
/>
// The count is a Badge, so a bare number with no unit cannot be shown.
// Arrow keys move focus; Enter or Space selects. onChange is a selection, not a view.
What the component takes. This table used to list defaultValue, syncToUrl, activationMode, count, keepMounted — props the component does not have, and never had. Found by the registry gate’s props-table rule.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | string | — | Required. The tab list’s accessible name. |
tabs | Tab[] | — | Required. Each an id, a label, an optional count with its noun, and the panel content. |
activeId | string | — | Required. Which tab is selected. Controlled. |
onChange | (id: string) => void | — | Required. Called on Enter, Space or a click — not on arrowing past. |
Related
- Alert Banner — where hoisted alarms render.
- Card — the usual content of a panel.
- Patient header — sits above the strip.
- Data grid — the same disclosure logic for hidden rows.