Text field
Where a clinician types something that will end up in a patient's record. Its boundary must be findable, its label must be permanent, and its errors must never destroy what was entered.
Overview
Text input is where the most ordinary-looking accessibility failures do the most clinical damage. A placeholder used as a label vanishes the moment someone types. A 1.1:1 border makes the field itself hard to locate. An error that clears the input costs a clinician the measurement they just read off a monitor.
None of those are edge cases — they are the default behaviour of most component libraries, including stock shadcn's border treatment. This component fixes them and the token gate keeps them fixed.
Findable
The boundary of the control is visible without hovering or guessing, at 3:1 against every surface it can sit on.
Permanently labelled
The label is outside the field and stays there. Nothing that identifies an input disappears when the input is used.
Never destructive
Validation explains; it does not clear, reformat or reject silently. What the clinician typed survives until they change it.
Anatomy
| Element | Rule |
|---|---|
| Label | A real <label for>, above the field, always visible.
Never a placeholder. |
| Input | 44 px tall — the touch floor — with a --input border at ≥ 3:1.
16 px text minimum, which also prevents iOS zoom-on-focus. |
| Hint | Units, format, or what "blank" means. Wired with aria-describedby so it
is announced, not just seen. |
| Error | Added below the hint, never in place of it — both are named in aria-describedby, error first. States what is wrong and what to do. Two-pixel border
plus text — never colour alone. |
Stock shadcn sets --input equal to --border, which in this
palette measured 1.10:1 against the surfaces it sits on — far below the 3:1
that WCAG 2.2 SC 1.4.11 requires for identifying a control. --input is therefore a
governed token, gated in CI against
background, card and muted surfaces. A divider may be faint; a control boundary
may not.
Labels, not placeholders
Label and unit both survive typing. A colleague reading over a shoulder knows what 1240 is.
The placeholder that said "Peak troponin (ng/L)" is gone. The number is now unitless and unlabelled in a clinical record.
Placeholders may be used for examples of format — e.g. 1964-03-11 —
never for the name of the field, and never as the only statement of a required unit.
Requiredness is stated in words, not in an asterisk. The asterisk is the
near-universal convention and it is learned rather than self-explanatory: it needs a legend
somewhere on the page, it is frequently outside the accessible name so it is never announced, and
at 12 px it is a hard target for anyone who does not already know what it means. The
component writes (required) beside the label and sets required on the
input; nothing depends on the reader having been told what a star is.
Where nearly every field on a form is required, marking each one is noise that stops being
read. Mark the exceptions instead — (optional) — and say at the top of
the form which convention is in use. What is never acceptable is marking neither and letting the
reader discover requiredness by being rejected at submit.
Validation
- Validate on blur, not on keystroke. Flagging "invalid" while someone is halfway through typing an MRN is noise that trains people to ignore the error state.
- Never clear, truncate or reformat the user's input on error. A field that empties itself costs a value the clinician may have read once off a monitor.
- Errors name the fix. "MRN must be 9 digits — 7 entered. Check the wristband and re-enter", not "Invalid input".
- Never block submission silently. If a form cannot submit, say which field and move focus there.
- Warnings are not errors. A physiologically unusual but possible value gets a confirmation, not a rejection — the patient may genuinely have a troponin of 25 000.
An error is added to the hint, never substituted for it. The hint carries the format. The reader who has just got the format wrong is the reader who most needs to see it, and the conventional implementation removes it from them at exactly that moment.
This component rendered {error ?? hint} into a single element until this section
was written — one message, one id, the hint destroyed on error. It looked correct
because the error strings on this page all restate the format themselves; nothing required them
to, and error="Invalid" would have lost it entirely.
- Both are named in
aria-describedby, error first. The order of that list is announcement order and it is independent of DOM order — so the error is spoken first while the hint keeps a fixed position on screen. - The hint does not move when an error appears. Inserting the error above it would shift a line the reader may be mid-way through reading. It is appended below.
- Neither is a
titleattribute or a tooltip. Format requirements that appear on hover are unavailable to touch, to keyboard, and to anyone reading with the field already focused — see Perceivable affordance. - Clearing the error does not clear the hint, and clearing the field does not clear the error until the value is valid. An error that vanishes on the first keystroke was never read.
The requirement is still on screen while it is being corrected. Both messages are described, and the error is announced first.
The error replaced the format, then failed to restate it. The reader knows they are wrong and no longer knows what right looks like.
What an empty field means
The Date & time picker rejects the standard instruction “leave blank if the date is unknown”, for one reason: a blank records nothing about why it is blank. That argument has nothing to do with dates. It is about blanks, and it applies to every field on this page.
If a blank has to mean something, it is not a blank — it is a value. A field may only be left empty where empty carries no clinical meaning at all. Where the absence itself is information, the absence is recorded as an option, not as the lack of one.
An empty field on a form has at least five readings, and nothing downstream can tell them apart:
| What actually happened | What is stored |
|---|---|
| Nobody asked | empty |
| Asked, and the answer is genuinely unknown | |
| Asked, and the patient declined to answer | |
| Ordered, not yet resulted | |
| Recorded, and lost between systems |
The reader who later sees the gap will supply one of those five from context, and they have no way to know which. A troponin that was never ordered and a troponin still in the analyser look identical, and only one of them means somebody should wait.
- Making the field required does not fix it — it makes it worse. Forcing a value out of someone who does not have one produces a value that is wrong rather than missing, and a wrong number is acted on where a gap would have been questioned. Requiredness is for fields that always have an answer.
- The options are clinical, not technical. “Not resulted”, “not applicable” and “patient declined” are different facts about the patient. A single “unknown” collapses them, and a null collapses them further.
- Where the vocabulary genuinely cannot be extended — a field on a form this system does not own — the interface states what the blank is not evidence of, next to the blank, rather than letting the reader assume.
- Never write a hint that assigns a meaning to empty. “Leave blank if not yet resulted” instructs the reader to encode a fact in a place that cannot hold one. This page carried exactly that hint until it was found by comparison with the date picker; it is recorded here rather than quietly removed.
or Not resulted · Not ordered · Patient declined
The absence has been given somewhere to live. A reader can tell a sample still in the analyser from a test nobody ordered.
The hint asks the blank to carry a fact it cannot carry. Four other things also produce a blank, and all five arrive downstream identical.
Clinical input rules
| Kind | Rule |
|---|---|
| Measurements | Unit shown as a permanent label or suffix, never assumed. Value and unit are stored together. |
| Identifiers | Mono stack, no auto-capitalisation, no autocorrect, no autocomplete from a browser profile. Grouping is display-only; the stored value is unformatted. |
| Dates | ISO 8601 with a visible format hint. Never a locale-ambiguous mask. |
| Free-text clinical notes | Never truncated silently. If a limit exists it is shown from the start with a live remaining count. |
| Passwords | Paste always permitted. A reveal toggle is provided. Never impose composition rules that force written-down credentials. |
Browser autofill on an MRN or patient-name field can suggest a different patient
the clinician entered earlier that shift. Patient identifier inputs set
autocomplete="off", autocorrect="off" and
spellcheck="false". Credential fields do the opposite — they carry correct
autocomplete tokens so password managers work.
States
Read-only and disabled are different. Read-only is a value the clinician may read and copy but not change — model version, acquisition time. Disabled is an input that is not currently applicable, and it always carries adjacent text saying what would make it applicable. A disabled field with no explanation is a dead end.
What the component enforces, and what it only asks for
The wiring is held here; the judgement is not. Everything about ids, labels and announcement order is the component's, and everything about when a value is wrong is yours.
| Rule | Held by | How |
|---|---|---|
| The label never disappears | The component | placeholder is omitted from the prop type, so a label that vanishes when the field is used cannot be written.Asserted: TextField: the label is bound to the input by id |
| An error is added to the hint, never substituted for it | The component | Separate elements, both named in aria-describedby, error announced first.Asserted: TextField: an error never destroys the hint |
| The described ids resolve | The component | Generated together and asserted in a browser, because markup containing both strings does not prove either id points anywhere. Asserted: TextField: the described ids resolve, error first |
| A unit is part of the field’s name | The component | When unit is set it is in the label as well as the box beside the field, so a screen reader user entering a weight is told it is in kilograms. Read from the accessibility tree.Asserted: AX tree: a text field's unit is in its accessible name |
| An identifier field is not autofilled from a browser profile | You | This row said the component until an assertion was written for it: text-field.tsx sets no autocomplete, no autocorrect and no spellcheck at all. The clinical input rules on this page describe what you must pass; the component passes them through and does not supply them. Browser autofill on an MRN field can offer a different patient the clinician entered earlier that shift, so this one is worth wiring yourself. |
| Validation happens on blur, not on every keystroke | You | There is no validation here at all. error is a string you decide to pass, and when you decide it is the difference between a helpful field and one that calls a half-typed MRN wrong. |
| The error names the fix | You | “MRN must be 9 digits — 7 entered” and “Invalid” are both strings. The type cannot tell them apart. |
| A blank is never asked to carry a meaning | You | hint is free text. Nothing stops it saying “leave blank if not resulted”, which is the rule this page spends a section refusing. |
A rule stated on a component page reads as a rule the component keeps. 4 of these 7 are; 3 are obligations this page was quietly handing to the reader. The component can guarantee a reader is told something. It cannot guarantee that what they are told is true, or that they are told it at the right moment.
Do's and don'ts
Autofill and autocorrect off, mono stack so 1 and l cannot be confused.
Browser autofill on an MRN field, offering a different patient the clinician saw an hour ago.
type="text" with inputmode. A scroll gesture over the field scrolls the page.
type="number". A scroll over the focused field silently changes the recorded value.
Accessibility
- Every field has a
<label for>.aria-labelis a fallback for genuinely label-less controls, not a shortcut. - Hints and errors are wired with
aria-describedbyso they are announced with the field, not stranded visually beside it. aria-invalidon error, alongside the 2 px border and the message. Three channels, no reliance on colour.- Focus ring at ≥ 3:1 against both the field and the surface behind it.
Never
outline: none. - 44 px minimum height and 16 px text. Meets the touch floor and prevents mobile zoom-on-focus.
- Correct
inputmodeso a numeric keypad appears on touch — without usingtype="number", whose spinners and scroll-wheel behaviour cause silent value changes. - Errors announced politely on blur, never assertively on every keystroke.
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 — The value recorded matches the value intended — correct units, nothing truncated, no autofill substitution from another patient.
- Efficiency — Entry time, and re-entry rate after validation. A field that clears itself on error doubles the cost of every mistake.
- Satisfaction — Confidence that what was typed was accepted as typed. Silent reformatting erodes this faster than an explicit rejection does.
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.
- Visible boundary at ≥ 3:1, governed. (the component) Mitigates: entering data into the wrong field, or failing to find one.
- Permanent labels and units. (the component) Mitigates: an unlabelled or unit-less value entering the record.
- Input never cleared or reformatted on error. (the component rewrites nothing; your handler must not either) Mitigates: loss of a value read once from a device.
- Out-of-range confirms rather than rejects. (you) Mitigates: a true extreme value being made un-enterable.
- No autofill on patient identifiers. (you) Mitigates: wrong-patient data entry from a suggestion.
- No
type="number". (you) Mitigates: silent value change from a scroll wheel or stray spinner press. - Disabled fields explain themselves. (you) Mitigates: a clinician unable to proceed and unable to see why.
- ISO dates with visible format. (you) Mitigates: day/month transposition.
Implementation
$ npx shadcn@latest add https://md.notjustany.tech/r/text-field.json
<TextField
label= "Peak troponin"
unit= "ng/L"
hint= "If it has not resulted, say so — do not leave blank."
inputMode= "decimal"
value={value}
onChange={setValue}
onBlur={validate}
error={error} // added below the hint, never in place of it
/>
// 'placeholder' is omitted from the prop type, so a label that disappears
// when the field is used cannot be written.
What the component takes. This table used to list variant — a prop the component does not have, and never had. Found by the registry gate’s props-table rule.
| Prop | Type | Default | Notes |
|---|---|---|---|
label | string | — | Required. Rendering without one fails lint. |
hint | string | — | Wired via aria-describedby. |
error | string | — | Sets aria-invalid. Never clears the value. |
unit | string | — | Required for measurement; rendered as a permanent suffix. |
readOnly | boolean | false |
Value is selectable and copyable. |
disabled | boolean | false |
Requires hint explaining the precondition. |
Related
- Sign in / sign out — credential fields and paste.
- Clinician override — the optional free-text reason.
- Tokens — why
--inputis governed. - Typography — identifiers and units.