Components · Forms

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.

Stable · v1.0 shadcn/ui WCAG 2.2 AA SC 1.4.11

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

Text field · default, hint, error
ng/L. If it has not resulted, say so — do not leave blank.
MRN must be 9 digits — 7 entered. Check the wristband and re-enter.
ElementRule
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.
The border is a governed token

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

Do
ng/L

Label and unit both survive typing. A colleague reading over a shoulder knows what 1240 is.

Don't

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

Out-of-range: confirm, do not reject
ng/L
25 000 ng/L is outside the usual range
Typical peak in confirmed STEMI is 1 000–10 000 ng/L. Confirm this is correct, or re-check the result.
The rule

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.

Do
9 digits, no punctuation MRN must be 9 digits — 7 entered. Check the wristband and re-enter.

The requirement is still on screen while it is being corrected. Both messages are described, and the error is announced first.

Don't
Invalid

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.

The rule

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 askedempty
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.

Do
ng/L

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.

Don't
ng/L. Leave blank if not yet resulted.

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

KindRule
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.
No autocomplete on identifiers

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

All states
MRN must be 9 digits — 6 entered.
Available once the ECG has been analysed.

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.

RuleHeld by How
The label never disappearsThe 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 itThe component Separate elements, both named in aria-describedby, error announced first.
Asserted: TextField: an error never destroys the hint
The described ids resolveThe 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 nameThe 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 profileYou 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 keystrokeYou 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 fixYou “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 meaningYou hint is free text. Nothing stops it saying “leave blank if not resulted”, which is the rule this page spends a section refusing.
Why this is written down

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

Do
9 digits, as printed on the wristband

Autofill and autocorrect off, mono stack so 1 and l cannot be confused.

Don't
Suggested from earlier this shift

Browser autofill on an MRN field, offering a different patient the clinician saw an hour ago.

Do
ng/L

type="text" with inputmode. A scroll gesture over the field scrolls the page.

Don't
ng/L

type="number". A scroll over the focused field silently changes the recorded value.

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/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.

PropTypeDefaultNotes
labelstring— Required. Rendering without one fails lint.
hintstring— Wired via aria-describedby.
errorstring— Sets aria-invalid. Never clears the value.
unitstring— Required for measurement; rendered as a permanent suffix.
readOnlybooleanfalse Value is selectable and copyable.
disabledbooleanfalse Requires hint explaining the precondition.
NotJustAnyMed.Tech Design System · Text field · v1.0 · draft for review
Reference applications named in this system are fictional; all patient data shown is fabricated. Reference ranges are illustrative, not clinical guidance.