Text Input
One line of typed text, in a box that owns its own edge.
import { TextInput } from "@dede-style/ui/text-input", then <TextInput value={v} onChange={setV} />. It is controlled, and onChange hands over the string rather than the event, so the DOM stays out of every call site. Put it in a Field to give it a name.
The border belongs to the box, not to the input. The visible field is a flex row, and the <input> inside it is transparent and borderless. That is the one decision here that cannot be corrected later, so it is the one settled first.
It buys three things. An affix is a sibling rather than an overlay, so a long value never scrolls underneath one — the visible bug in most implementations of this. The focus ring encloses the affixes, because it is drawn on the row rather than on the input. And when an interactive affix arrives, a password reveal or a clear button, it has a real place in the tab order waiting for it.
Affixes
Decoration at either end, and never part of the value.
- prefix
- prefixIcon
- suffix
An affix is a constrained pair rather than arbitrary content: prefix takes a string and prefixIcon takes a glyph, so each admits only things the system already knows about. The practical payoff is on the Properties tab — each half gets a real control, which arbitrary JSX could never have. A button in there is deliberately impossible; that is the interactive affix, and it will arrive under its own name.
They are hidden from screen readers, unselectable, and they never shrink. A decorative pound sign announced before the field’s own name is noise, dragging across the value should not pick up a mark that is not in it, and when the value runs long the input yields instead of the affix, so the text scrolls inside its own box.
prefix and suffix are reading-order words rather than spatial ones, so they are already correct in a right-to-left document where left and right would not be.
Frame or plain
A second edge inside the first is one line too many.
variant="plain" drops the fill, the edge and the inset, leaving the glyph, the words and the caret. It is the toolbar case — a site search sitting inside a header that is already a container, where the default frame box would draw a boundary inside a boundary.
It gives up the thing the frame exists to provide, so it has to earn it back. A field is identified by its edge — that is what the 3:1 on --color-control-border is for — and plain has none. Which is why this example is a search rather than a bare box: the magnifier and the placeholder do that job instead. Use it where something else says a control is there, and not to make an ordinary form field quieter.
Focus is untouched, and deliberately: with nothing drawn at rest, the ring is the only thing marking the control once a keyboard reaches it. The height does not change either, so it still lines up with the buttons beside it.
Sizes
Three control heights, matching Button, so a field and a button standing side by side agree.
- 8 — 32px
- 10 — 40px
- 12 — 48px
The padding and the gap ramp with the height exactly as Button’s do. The type size does not — the value is 16px at every step, and that is not a stylistic choice: iOS zooms the whole page on focus for any input under 16px, and the zoom does not undo itself when focus leaves.
It is why the field’s own type ramp looks upside down. The label, the hint and the error are all sm; the value is the only text in the field at base.
There is no 24px step. It is exactly WCAG’s minimum target size, so it would pass with zero headroom, and 16px of text in a 24px box leaves four pixels of padding.
The lane sets the width
A form has two widths: full, and a fraction. The fraction is a lane, so the short answer’s edge lands on the grid rather than near it.
A postcode box running the full width of a form is a usability bug rather than a tidy one: it says the answer might be long when it cannot be. A field’s width is information about its content, which is why this is a prop rather than something the page reaches in and sets — and why the usual rule that text sets no layout does not reach it.
A traditional form is one column, and the shape above is the whole rule. Most rows run full width. A short answer does not fill its row — the postcode is width="10" sitting in an ordinary full-width row, so it needs no lane and no arrangement of any kind. And a Stack lanes="1|1" appears exactly once, on the name pair, because that is the only place two fields genuinely belong side by side. Reaching for a lane to make one field narrow would be two mechanisms doing one job — and the halves of a real pair then align to the same grid every other band on the site uses.
The unit is ch, which is the advance of the digit zero in the box’s own font — so a count is exact for digits and generous-to-tight for letters, since a capital W is wider than a nought and an i is narrower. Ten characters of a date fits precisely; ten characters of a name may not. Size these against the widest real value, not against the count.
The steps are named counts rather than lengths, and each resolves to that many characters plus the box’s own padding and borders — so width="4" genuinely holds four characters rather than three and a scroll.
Type, and the one that lies
Six of the seven names render themselves. The seventh is an abstraction over a broken element.
- number
- password
type="number" renders an ordinary text input with a numeric keyboard and a pattern. <input type="number"> changes its value on the scroll wheel, ignores maxLength, and reports an empty string for anything it cannot parse — so a half-typed number is indistinguishable from an empty box. GOV.UK and Stripe both land here. The API name is the abstraction; the DOM is honestly text.
The keyboard, the spell-checker, the auto-capitalisation and the auto-correction are all derived from the type rather than being four more props. They are consequences of it: iOS capitalises the first letter of an email address unless it is told not to, and nobody wants a red underline under their own username. inputMode is the one override, because decimal against numeric is a real distinction the type cannot express — a price and a house number both arrive as number.
Password ships without a reveal, and that is an interim rather than a position. The type earns its place on browser and password-manager behaviour alone, but a box you cannot check what you typed into is half a control. The reveal is the first interactive affix, and it is waiting on the focus questions that come with one.
Read-only is not disabled
They look similar and behave nothing alike.
- readOnly
- disabled
If the reader needs to read or copy the value, it must be read-only. A read-only field is focusable, selectable, announced, and it submits. A disabled one is none of those — it is skipped by the keyboard, often skipped by a screen reader entirely, and its value is left out of the form. Disabled is the trap people reach for when they mean read-only.
Read-only drops to the sunken fill, which says set into the page rather than a well you type in; the border stays, because it is still a control and still has to identify itself. Disabled is one opacity over the whole box, so the edge, the fill, the affixes and the value dim together and keep their relationship to each other — a set of disabled colours would dim them one at a time and let the ratios drift apart.
It needs a name from somewhere
A Field in a form; an aria-label on its own.
- in a Field
- aria-label
Inside a Field everything is wired automatically: the id, the aria-describedby pointing at the hint and the error in that order, and aria-invalid. The control reads them from a context and writes nothing itself, so a caller cannot mistype an id that points at nothing.
Standing on its own is a supported arrangement, not a mistake. With no Field above it the context is empty and the control falls back to its own props — which is exactly what this site’s own properties tables do, because a props table is not a form: nothing submits, nothing is required or optional, and the Prop column is already the label.
A placeholder is not a name. It is a format example, and it disappears the moment anything is typed — so anything the reader still needs at that point belongs in the label or the hint.