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.

A control is a label plus an input. Neither is optional.
<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.

It grows as you type — no JavaScript involved
<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.

Readonly is still selectable and focusable; disabled is neither
<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.

.is-invalid on the field, .error beneath, aria-describedby joining them

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.

Prefix, attached button, and both at once
https://
$ USD
<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 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.

The icon is inside the field and not clickable
<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.

Type in it: the label lifts. Empty it: the label drops back.
<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 > .input in src/10-datepicker.css makes room for the calendar trigger.
  • .datepicker-time .input sizes the hour and minute boxes inside the picker's footer.
  • .combo-control shares 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.

Classes, generated from the stylesheet source
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.

Design tokens this component reads
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-label is 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: none with box-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 .error paragraph 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-invalid is 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 disabled attribute, not by the class. .input[disabled] only styles what the attribute already did — there is no .is-disabled for 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-strong against --surface clears 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-start and 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.

All three mirror — the select needs one hand-written rule to do it
https://
<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 .input gives you the browser's tiny spinners, which differ per browser and are hard to hit.
  • Not for a searchable list of options. A .select with 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 .segmented or a group of .check radios shows immediately.
  • Not for a date, if the format matters. <input type="date"> renders differently in every browser and cannot be styled. Use .datefield with the datepicker, which controls the format and the calendar.
  • Not on a <div contenteditable>. The class will style it and nothing else will work: no name, no form submission, no validation, no mobile keypad. Use .editor if you need rich text.
  • .input-group is 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.