Input
.input, .textarea and .select are declared in
one rule and differ only where they have to. They are
42px tall, full width, and
16px on any coarse pointer — because Safari on iOS zooms the whole
page when a control smaller than 16px receives focus, and no amount of viewport
configuration prevents it.
When to use it
Put .input on a real <input>,
.textarea on a <textarea>, and
.select on a <select>. The class is a look; the
element is the behaviour, and the element is what decides the keyboard, the mobile
keypad and what a screen reader announces.
Almost always wrap the control in a .field,
which supplies the label, the help text and the error slot with the right gaps. A
bare .input with no label is the most common form defect there is.
<div class="field" style="max-inline-size:22rem">
<label class="label" for="ex-email">Email</label>
<input class="input" type="email" id="ex-email" placeholder="you@example.com">
</div>
<div class="field" style="max-inline-size:22rem">
<label class="label" for="ex-email">Email</label>
<input class="input" type="email" id="ex-email" placeholder="you@example.com">
</div>
The three controls
One shared rule sets the size, border, radius, shadow and transition. Each control then adds only what makes it itself.
Input
The shared rule, unmodified. Every type works — the class does not
care, and the type is what gives a phone the right keypad.
<input class="input" type="text" placeholder="Text" aria-label="Text">
<input class="input" type="email" placeholder="you@example.com" aria-label="Email">
<input class="input" type="tel" placeholder="+1 555 0100" aria-label="Telephone">
<input class="input" type="date" aria-label="Date">
<input class="input" type="text" placeholder="Text" aria-label="Text">
<input class="input" type="email" placeholder="you@example.com" aria-label="Email">
<input class="input" type="tel" placeholder="+1 555 0100" aria-label="Telephone">
<input class="input" type="date" aria-label="Date">
Textarea
field-sizing: content makes it grow with what is typed, from two rows
up to a 22rem ceiling, so a comment box is never a four-row window
onto a long paragraph. resize: vertical stays available for readers
who want more.
<textarea class="textarea" placeholder="Type a few lines and watch it grow" aria-label="Notes"></textarea>
<textarea class="textarea" placeholder="Type a few lines and watch it grow" aria-label="Notes"></textarea>
field-sizing is not in every browser yet. Where it is missing the
textarea is simply two rows tall and scrolls, which is the ordinary behaviour — the
feature degrades to the default rather than breaking.
Select
appearance: none plus two CSS gradients draw the chevron, so there is
no background image to load and the arrow takes
currentColor — it follows the text colour into dark mode on its own.
<select class="select" aria-label="Plan">
<option>Starter</option>
<option>Team</option>
<option>Enterprise</option>
</select>
<select class="select" aria-label="Plan">
<option>Starter</option>
<option>Team</option>
<option>Enterprise</option>
</select>
background-position is one of the few properties with no logical
equivalent, so the arrow cannot mirror itself.
src/19-logical.css redeclares it for dir="rtl" —
including swapping the two gradient angles, because a chevron reflected without
swapping them points the wrong way. See Right to left.
States
Hover darkens the border. Focus swaps it for --focus and adds
--ring, replacing the browser outline rather than sitting beside it.
Disabled sinks the background and drops the shadow; readonly keeps the border but
tints the fill, so the two are distinguishable at a glance.
<input class="input" value="Ordinary" aria-label="Ordinary">
<input class="input" value="Readonly" readonly aria-label="Readonly">
<input class="input" value="Disabled" disabled aria-label="Disabled">
<input class="input" value="Ordinary" aria-label="Ordinary">
<input class="input" value="Readonly" readonly aria-label="Readonly">
<input class="input" value="Disabled" disabled aria-label="Disabled">
Validation waits for the reader
This is the part of Deck's form handling worth understanding.
:invalid matches an empty required field immediately, so a
stylesheet that uses it paints a form red before the reader has typed anything. Deck
uses :user-invalid, which only matches after the reader has interacted
with the control and left it.
.is-invalid on the enclosing .field is the escape hatch
for validation your server did: it produces the same border and focus ring without
needing the browser to agree that the value is wrong.
That is not a VAT number we recognise.
<div class="field is-invalid" style="max-inline-size:22rem">
<label class="label" for="ex-vat">VAT number <span class="required">*</span></label>
<input class="input" id="ex-vat" value="GB-nope" aria-describedby="ex-vat-err">
<p class="error" id="ex-vat-err">That is not a VAT number we recognise.</p>
</div>
<div class="field is-invalid" style="max-inline-size:22rem">
<label class="label" for="ex-vat">VAT number <span class="required">*</span></label>
<input class="input" id="ex-vat" value="GB-nope" aria-describedby="ex-vat-err">
<p class="error" id="ex-vat-err">That is not a VAT number we recognise.</p>
</div>
.is-invalid is one of the nine states an author writes by hand. The
rest are set by Deck's JavaScript at runtime.
Input groups
.input-group is a flex row that takes over the border, radius, shadow
and overflow: hidden from the control inside it. The
.input then strips its own border and background, so the seam between a
prefix and the field does not double up. :focus-within on the group
draws the focus ring around the whole assembly, which is why focusing the input
lights up the prefix too.
<div class="input-group" style="max-inline-size:24rem">
<span class="addon">https://</span>
<input class="input" value="example.com" aria-label="Domain">
</div>
<div class="input-group" style="max-inline-size:24rem">
<input class="input" placeholder="Coupon code" aria-label="Coupon code">
<button class="btn">Apply</button>
</div>
<div class="input-group" style="max-inline-size:24rem">
<span class="addon">$</span>
<input class="input" type="text" inputmode="decimal" value="1240.00" aria-label="Amount">
<span class="addon">USD</span>
</div>
<div class="input-group" style="max-inline-size:24rem">
<span class="addon">https://</span>
<input class="input" value="example.com" aria-label="Domain">
</div>
<div class="input-group" style="max-inline-size:24rem">
<input class="input" placeholder="Coupon code" aria-label="Coupon code">
<button class="btn">Apply</button>
</div>
<div class="input-group" style="max-inline-size:24rem">
<span class="addon">$</span>
<input class="input" type="text" inputmode="decimal" value="1240.00" aria-label="Amount">
<span class="addon">USD</span>
</div>
.addon borders are logical:
border-inline-end normally, swapped to
border-inline-start on :last-child. So a leading and a
trailing addon each get their divider on the correct side, in both writing
directions, from two rules.
Search
.search positions an icon inside the control with
inset-inline-start and adds
2.5rem of leading padding to the
input. pointer-events: none on the icon means clicking it focuses the
field underneath rather than doing nothing.
<div class="search" style="max-inline-size:22rem">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#search"></use></svg>
<input class="input" type="search" placeholder="Search invoices" aria-label="Search invoices">
</div>
<div class="search" style="max-inline-size:22rem">
<svg class="icon" aria-hidden="true"><use href="/assets/deck/deck-icons.svg#search"></use></svg>
<input class="input" type="search" placeholder="Search invoices" aria-label="Search invoices">
</div>
Floating labels
.float wraps an .input and lifts its label out of the field
once there is content in it, with no JavaScript. Two things about it are
.input's business rather than the wrapper's, and both trip people up:
the control needs placeholder=" ", and the label must come
after it in the markup.
<div class="float" style="max-inline-size:22rem">
<input class="input" id="ex-float" placeholder=" ">
<label for="ex-float">Company name</label>
</div>
<div class="float" style="max-inline-size:22rem">
<input class="input" id="ex-float" placeholder=" ">
<label for="ex-float">Company name</label>
</div>
The outlined variant, the invalid state, what happens with a
<select>, and the reasons to prefer a plain
.field most of the time are all on
the floating label page.
In other components
.input appears inside components that are documented elsewhere, and
each adjusts it rather than restyling it:
-
.datefield > .inputinsrc/10-datepicker.cssmakes room for the calendar trigger. -
.datepicker-time .inputsizes the hour and minute boxes inside the picker's footer. -
.combo-controlshares the invalid-shake rule, so a combobox and an input misbehave identically.
Classes
Generated from the stylesheet source. .textarea,
.select, .addon and .search are documented on
this page in prose but belong to no component root, so they are not in the table —
the table is the input component exactly as the extractor reports it.
| Class | Layer | What it does | Source |
|---|---|---|---|
.input |
deck.components |
Marks a field invalid from your own validation, producing the same border and ring as :user-invalid without needing the browser to agree. One of the few states an author writes by hand. | 06-forms.css:49 |
.input-group |
deck.components |
A prefix, suffix or attached button joined to a control. The group takes over the border, radius and shadow, and the input inside strips its own, so the seam does not double up. :focus-within puts the ring around the whole assembly. | 06-forms.css:123 |
.is-invalid |
deck.components |
No declarations. | 06-forms.css:110 |
.addon |
deck.components |
No declarations. | 06-forms.css:145 |
.search |
deck.components |
A control with an icon inside its starting edge. The icon is pointer-events: none, so clicking it focuses the field underneath rather than doing nothing. | 06-forms.css:163 |
Tokens
--control-h is shared with .btn, which is why a button and
an input in the same row line up without either being adjusted.
| Token | Live | Declared value |
|---|---|---|
--control-h |
42px |
|
--surface |
light-dark(var(--ink-0), var(--ink-950)) |
|
--surface-2 |
light-dark(var(--ink-25), var(--ink-900)) |
|
--bg-sunken |
light-dark(var(--ink-100), oklch(8.6% .012 var(--hue-neutral))) |
|
--line |
light-dark(var(--ink-200), oklch(28% .016 var(--hue-neutral))) |
|
--line-strong |
light-dark(var(--ink-300), oklch(36% .018 var(--hue-neutral))) |
|
--ink-400 |
oklch(70.8% calc(var(--chroma-neutral) * 2.6) var(--hue-neutral)) |
|
--focus |
light-dark(var(--brand-500), var(--brand-300)) |
|
--ring |
0 0 0 3px light-dark(oklch(from var(--focus) l c h / .32), oklch(from var(--focus) l c h / .42)) |
|
--r-sm |
7px |
|
--r-full |
999px |
|
--shadow-1 |
0 1px 2px oklch(20% .03 var(--shadow-color) / .06), 0 1px 1px oklch(20% .03 var(--shadow-color) / .04) |
|
--text-base |
.9375rem |
|
--text-sm |
.8125rem |
|
--text |
light-dark(var(--ink-900), var(--ink-100)) |
|
--text-muted |
light-dark(var(--ink-600), var(--ink-400)) |
|
--text-faint |
light-dark(var(--ink-500), var(--ink-500)) |
|
--bad-500 |
oklch(58% calc(var(--chroma-status) * 1.25) var(--hue-bad)) |
|
--space-2 |
.5rem |
|
--space-3 |
.75rem |
|
--space-10 |
2.5rem |
|
--dur-1 |
110ms |
Accessibility
-
Every control needs a label. A
<label class="label" for="id">is the right answer.aria-labelis the fallback used by the short examples on this page and is worse: it is invisible, so it drifts from the placeholder beside it and cannot be translated by the page. -
A placeholder is not a label. It disappears when the reader
types, has no programmatic association, and is
--text-faint, which is below 4.5:1 on purpose — placeholders are hints, and Deck styles them as hints. -
Focus replaces the outline rather than adding to it.
outline: nonewithbox-shadow: var(--ring)is only safe because the ring is always drawn; the border colour changes too, so there are two cues. -
16px on touch is an accessibility fix, not a style choice.
@media (pointer: coarse)raises the font size so iOS Safari does not zoom on focus. A page that zooms unexpectedly loses a low-vision reader's place entirely. -
Errors need
aria-describedby. A.errorparagraph next to an input is invisible to a screen reader unless the input points at it. Deck cannot wire that up from CSS; the example in Validation shows the markup. -
:user-invalidis the accessible choice. Marking a field wrong before the reader has touched it is noise, and a screen-reader user hears "invalid entry" on a field they have not reached. -
Disabled controls are removed from the tab order by the
disabledattribute, not by the class..input[disabled]only styles what the attribute already did — there is no.is-disabledfor inputs, on purpose. -
The select's chevron is decorative. It is drawn with
background-image, so it is never announced and never selectable. That is correct — the element already announces itself as a combobox. -
Contrast.
--line-strongagainst--surfaceclears 3:1, the non-text minimum for a control boundary, in both themes.
Right to left
Padding, the addon dividers and the search icon are all logical, so they mirror with
no extra rules at all. Two things cannot, and
src/19-logical.css handles both by hand:
-
The select's chevron. It is drawn with
background-position, which has no logical form, so the rule redeclares the positions from the other edge and swaps the two gradient angles — a mirrored chevron that keeps its original angles points the wrong way. The padding is redeclared with it, because the arrow and the text-side padding have to move together. -
The search icon, which is positioned with
inset-inline-startand therefore already correct — the rule beside it exists for the icon's own asymmetry rather than its position.
This is the general shape of Deck's RTL story: logical properties handle almost
everything, and the handful of properties that have no logical form are collected in
one file rather than scattered as [dir="rtl"] overrides beside each
component.
<div dir="rtl" class="stack stack-3" style="max-inline-size:22rem">
<div class="field">
<label class="label" for="ex-rtl">البريد الإلكتروني</label>
<input class="input" id="ex-rtl" placeholder="you@example.com">
</div>
<div class="input-group">
<span class="addon">https://</span>
<input class="input" value="example.com" aria-label="النطاق">
</div>
<select class="select" aria-label="الخطة">
<option>أساسية</option>
<option>فريق</option>
</select>
</div>
<div dir="rtl" class="stack stack-3" style="max-inline-size:22rem">
<div class="field">
<label class="label" for="ex-rtl">البريد الإلكتروني</label>
<input class="input" id="ex-rtl" placeholder="you@example.com">
</div>
<div class="input-group">
<span class="addon">https://</span>
<input class="input" value="example.com" aria-label="النطاق">
</div>
<select class="select" aria-label="الخطة">
<option>أساسية</option>
<option>فريق</option>
</select>
</div>
Reduced motion
Controls transition border-color, box-shadow and
background-color over
110ms. The global reset collapses all
three to .01ms under prefers-reduced-motion: reduce.
src/16-motion.css also shakes an invalid field once —
.field.is-invalid:not(.was-shaken) — and that rule lives inside
@media (prefers-reduced-motion: no-preference), so it is never declared
for a reader who has asked for less motion. The :not(.was-shaken) guard
is what stops it repeating every time the page re-renders.
Printing
src/99-print.css gives .input, .textarea,
.select and .combo-control the same treatment: a plain
#999 border, a white background, and
min-block-size: auto so a control is only as tall as what is in it.
Deck's own border colours are near-invisible on paper and its shadows print as grey
smudges, so both are replaced rather than removed — a printed form should still look
like a form.
Two things are dropped entirely: .btn and
.form-actions are display: none, because a printed Save
button is a button nobody can press. .btn.print-keep is the escape
hatch for the rare button that should appear — a reference code, a QR link.
Overriding it
@layer app.components {
.input, .textarea, .select {
border-radius: var(--r-md);
box-shadow: none;
}
}
/* The whole control size, globally, from one token */
:root { --control-h: 48px; }
Prefer changing --control-h over setting a height on
.input: buttons, selects and the datepicker's trigger all read the same
token, so the token keeps them aligned and a local height does not.
When not to use it
-
Not for a number the reader will step. Use
.number, which has real increment and decrement buttons.<input type="number">with.inputgives you the browser's tiny spinners, which differ per browser and are hard to hit. -
Not for a searchable list of options. A
.selectwith two hundred<option>s is unusable. Use.combo, which filters as the reader types. -
Not for one-of-three. A select with three options costs two
clicks to reveal a choice that
.segmentedor a group of.checkradios shows immediately. -
Not for a date, if the format matters.
<input type="date">renders differently in every browser and cannot be styled. Use.datefieldwith the datepicker, which controls the format and the calendar. -
Not on a
<div contenteditable>. The class will style it and nothing else will work: noname, no form submission, no validation, no mobile keypad. Use.editorif you need rich text. -
.input-groupis not for two separate fields. It removes the inner borders so the assembly reads as one control. Two inputs that the reader fills in independently need two.fields in a.field-row.