Input
Text input, choice control, and grouped field
Intent
Input is a family of form controls that share a chrome token
spine. .input is the universal class for the
family: text <input>, <select>,
<textarea>, plus
<input type="checkbox">,
<input type="radio">,
<input type="checkbox" role="switch">
(toggle), and range. Each
element type renders its own chrome (text rectangle,
checkbox tick, radio dot, switch track, slider track and
thumb), but they share --input-* tokens so
dense forms feel visually unified.
.input-group joins inputs with icons, addons,
or a button inside a single shared border.
Basic usage
Add .input to the native element and pair it with
a <label>. brio has no
.form or .field wrapper class --
<form class="stack"> and nested
<div class="stack is-tight"> cover the layout.
For real-world form patterns (stacked, horizontal,
multi-column, sectioned with fieldsets), see
Form composition.
<form class="stack">
<div class="stack is-tight">
<label for="f-email">Email</label>
<input id="f-email" type="email" class="input" placeholder="you@example.com" required=""/>
</div>
<div class="stack is-tight">
<label for="f-message">Message</label>
<textarea id="f-message" class="input" rows="3"></textarea>
</div>
<button class="button" type="submit">Send</button>
</form>
Native <fieldset> and <legend>
work bare with base defaults -- reach for them when grouping
radios or checkboxes.
Variants
Size
Sizes key to the ui-pad-* density grammar.
.is-sm tightens padding and drops the font one
step; .is-lg relaxes padding and steps the font up.
<div class="cluster is-tight">
<input type="text" class="input is-sm" placeholder="Small"/>
<input type="text" class="input" placeholder="Default"/>
<input type="text" class="input is-lg" placeholder="Large"/>
</div>State
Validation paints the border red in two ways:
:invalid:not(:placeholder-shown) covers client-side
feedback after a user has typed; [aria-invalid="true"]
covers server-rendered or JS-set invalid state. Pair either
with adjacent error copy in a
<p class="text-danger">.
:disabled dims the input and swaps to a sunken
background. :focus shows the ring on click AND
keyboard -- text entry needs the ring to indicate where the
caret will land, so the asymmetry with button's
:focus-visible is deliberate.
Letters, numbers, and underscores only.
<div class="stack">
<div class="stack is-tight">
<label for="f-username">Username</label>
<input id="f-username" type="text" class="input" value="nope!" aria-invalid="true" aria-describedby="f-username-err"/>
<p id="f-username-err" class="text-danger text--1">Letters, numbers, and underscores only.</p>
</div>
<div class="stack is-tight">
<label for="f-locked">Account ID</label>
<input id="f-locked" type="text" class="input" value="ACC-0042" disabled=""/>
</div>
</div>
Readonly vs disabled. readonly
keeps the value selectable and submittable but blocks edits;
disabled greys it out and excludes it from form
submission. Pick by intent. A readonly field
keeps its normal chrome; only the caret and interaction are
suppressed.
Help text. A short <p class="text-muted text--1">
below the input gives context (format hints, character limits,
privacy notes). Wire it with aria-describedby so
screen readers announce it after the label.
Shown on your public profile.
<div class="stack">
<div class="stack is-tight">
<label for="f-readonly">Account ID</label>
<input id="f-readonly" type="text" class="input" value="ACC-0042" readonly=""/>
</div>
<div class="stack is-tight">
<label for="f-helped">Display name</label>
<input id="f-helped" type="text" class="input" value="octetic" aria-describedby="f-helped-help"/>
<p id="f-helped-help" class="text-muted text--1">Shown on your public profile.</p>
</div>
</div>
Success state is opt-in. brio doesn't paint
a green :valid border by default -- a field that
happens to pass validation while the user is still typing
shouldn't announce success every keystroke. If your form
design calls for it, opt in per-form:
.my-form .input:valid:not(:placeholder-shown) {
--input-border: var(--color-success);
}
Native types
Every text-shaped input type works with
.input -- email, password, search, tel, url,
number, date, datetime-local, month, time, week. Browser-
supplied pickers (date, time) inherit the surrounding chrome;
the picker chrome itself is browser-drawn.
Range and color.
type="range" takes .input and
gets brio's slider chrome -- track, thumb, and size variants.
See range for the dedicated
page. type="color" is left unclassed: brio
doesn't currently style the swatch picker (the browser-
drawn chrome varies considerably across platforms and the
consumer cases are rare).
<div class="stack is-snug">
<input type="email" class="input" placeholder="you@example.com"/>
<input type="password" class="input" placeholder="Password"/>
<input type="search" class="input" placeholder="Search..."/>
<input type="tel" class="input" placeholder="(555) 123-4567"/>
<input type="url" class="input" placeholder="https://example.com"/>
<input type="number" class="input" placeholder="42"/>
<input type="date" class="input"/>
<input type="time" class="input"/>
</div>Textarea
<textarea class="input"> uses
block-size: auto so the rows
attribute controls height. A handful of sizing patterns cover
most needs -- pick per context:
-
Fixed rows. The default.
rows="4"renders a four-line textarea; the user can drag the resize handle to grow it vertically. -
Fill container. Set a height on the
wrapper and
block-size: 100%on the textarea. Useful for editor panels, comment boxes in a sidebar, notes that should occupy all available vertical space. -
Grow with content.
field-sizing: contentauto-grows the textarea as the user types, eliminating the need for JS height-management. It ignoresrows, so setmin-block-sizefor the starting height. Baseline modern CSS.
<div class="stack">
<textarea class="input" rows="4" placeholder="Fixed rows"></textarea>
<div style="block-size: 12rem">
<textarea class="input" style="inline-size: 100%; block-size: 100%" placeholder="Fill container"></textarea>
</div>
<textarea class="input" style="field-sizing: content; min-block-size: 2.25rem" placeholder="Grow with content"></textarea>
</div>Select
<select class="input"> replaces the native
dropdown arrow with a CSS-gradient chevron themed by
--input-chevron-color.
<div class="stack is-tight">
<label for="f-role">Role</label>
<select id="f-role" class="input">
<option>Admin</option>
<option>Editor</option>
<option>Viewer</option>
</select>
</div>File
input[type="file"].input styles the native
file-selector button with the family's chrome. Leave the
button label to the browser -- consumers can't restyle the
selected-filename text.
<div class="stack is-tight">
<label for="f-avatar">Avatar</label>
<input id="f-avatar" type="file" class="input" accept="image/*"/>
</div>Checkbox and radio
Checkbox and radio use appearance: none custom
chrome consuming the spine -- the unchecked box matches
.input's border and background, checked state
fills with --input-check-accent, and the tick
or dot paints in --input-check-color. Windows
High Contrast and other forced-colors modes revert to
OS-rendered controls automatically.
<div class="cluster is-snug">
<input type="checkbox" checked="" class="input"/>
<input type="checkbox" class="input"/>
<input type="radio" name="demo-r" checked="" class="input"/>
<input type="radio" name="demo-r" class="input"/>
</div>
For a labeled control, put
.cluster.is-snug on the
<label>. No wrapper <span>
around the text is needed -- flexbox treats loose text inside
a flex container as an anonymous flex item, so it aligns with
the control automatically.
<div class="stack">
<label class="cluster is-snug">
<input type="checkbox" class="input"/> I agree to the terms and conditions
</label>
<fieldset class="stack is-tight">
<legend>Notification frequency</legend>
<label class="cluster is-snug">
<input type="radio" name="demo-freq" checked="" class="input"/> Immediately
</label>
<label class="cluster is-snug">
<input type="radio" name="demo-freq" class="input"/> Daily digest
</label>
<label class="cluster is-snug">
<input type="radio" name="demo-freq" class="input"/> Weekly digest
</label>
</fieldset>
</div>
Multi-line labels. When label text wraps
to multiple lines, reach for .media instead of
.cluster. .media's figure-and-body
shape aligns the checkbox with the first line of text (via
align-items: flex-start) and lets the wrapped
text flow beside it. Tighten --media-gap from
the relaxed default to close the checkbox-to-text distance:
<label class="media" style="--media-gap: var(--space-xs)">
<input type="checkbox" class="input media-figure"/>
<span class="media-body">I agree to the Terms of Service, Privacy Policy, and Cookie Policy as described on the legal page, including cross-site tracking consent where applicable in my jurisdiction.</span>
</label>
:indeterminate state on checkbox shows a short
horizontal bar instead of a tick. It can only be set from
JavaScript -- there's no HTML attribute for it:
document.getElementById("parent-check").indeterminate = true;
:indeterminate also matches every radio in a
group with no selection yet -- that's a CSS spec quirk, not a
bug. brio's radio rules scope the filled-state styling to
:checked only so fresh groups render as empty
circles.
Toggle switch
Checkbox with role="switch" renders as a
track-and-thumb instead of a box-and-tick. Same input
family chrome (border color, focus ring, disabled state);
the thumb slides across the track on :checked.
Screen readers announce it as a switch (on/off) rather
than a checkbox (checked/unchecked).
<div class="cluster is-snug">
<input type="checkbox" role="switch" class="input"/>
<input type="checkbox" role="switch" checked="" class="input"/>
<input type="checkbox" role="switch" disabled="" class="input"/>
<input type="checkbox" role="switch" checked="" disabled="" class="input"/>
</div>
Labeled pattern matches checkbox and radio --
.cluster.is-snug on the
<label>:
<div class="stack is-tight">
<label class="cluster is-snug">
<input type="checkbox" role="switch" checked="" class="input"/> Email notifications
</label>
<label class="cluster is-snug">
<input type="checkbox" role="switch" class="input"/> Push notifications
</label>
<label class="cluster is-snug">
<input type="checkbox" role="switch" class="input"/> SMS notifications
</label>
</div>Input groups
.input-group joins an input with icons, addons,
or a button in a single shared border. The group owns the
border, background, and focus ring; the input inside goes
transparent. Addons and buttons sit inside the shared border
and read as part of the same control.
Focus routes through :focus-within on the group,
so tabbing into any child lights the outer border and ring.
Validation state on any inner input paints the group's border
red via :has(). When a child input is disabled,
the group inherits its dim state naturally.
<div class="stack">
<div class="input-group">
<span class="input-addon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</span>
<input type="search" class="input" placeholder="Search"/>
</div>
<div class="input-group">
<span class="input-addon">$</span>
<input type="number" class="input" placeholder="0.00"/>
<span class="input-addon">USD</span>
</div>
<div class="input-group">
<input type="email" class="input" placeholder="you@example.com"/>
<button class="button" type="submit">Subscribe</button>
</div>
<div class="input-group">
<span class="input-addon">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="8"></circle>
<path d="m21 21-4.3-4.3"></path>
</svg>
</span>
<input type="search" class="input" placeholder="Find a product"/>
<button class="button" type="submit">Search</button>
</div>
<div class="input-group">
<span class="input-addon">@</span>
<input type="text" class="input" value="octetic" disabled=""/>
</div>
</div>
.input-addon marks a child as a label or icon
inside the group. In the default shape, addons have muted
color and transparent background -- they sit inside the
group's border alongside the input. Buttons inside the group
lose the border-radius on the side facing the input so they
sit flush; the outer side rounds naturally via the group's
overflow: hidden clipping.
Validation
Any inner control's :invalid:not(:placeholder-shown)
or [aria-invalid="true"] propagates to the group's
border via :has(), so the red border surrounds the
entire chrome -- addons, input, button alike. Pair with adjacent
error copy via aria-describedby.
Amount must be greater than zero.
<div class="stack is-tight">
<label for="ig-amount">Donation amount</label>
<div class="input-group">
<span class="input-addon">$</span>
<input id="ig-amount" type="number" class="input" value="-50" aria-invalid="true" aria-describedby="ig-amount-err"/>
<span class="input-addon">USD</span>
</div>
<p id="ig-amount-err" class="text-danger text--1">Amount must be greater than zero.</p>
</div>Classic shaded endcaps
Add .is-joined to the group for the classic
bordered-everything-with-shaded-endcaps look. Each child
gets its own border; addons get a sunken background; the
focused input rises above its neighbors.
<div class="input-group is-joined">
<span class="input-addon">https://</span>
<input type="url" class="input" placeholder="example.com"/>
</div>Form composition
The input family pairs with brio's composition primitives to
build real forms. .stack handles vertical rhythm
(not .flow -- forms want tiered spacing and flex
alignment, which stack provides and flow doesn't).
.cluster handles horizontal rows and action bars.
.grid or .switcher handles paired
fields. A few common shapes cover most forms.
Stacked (canonical)
The default. Outer .stack separates fields at
its default gap; inner .stack.is-tight ties each
label, input, and optional help or error together. Submit
button sits at the end as a sibling of the field wrappers.
<form class="stack">
<div class="stack is-tight">
<label for="signin-email">Email</label>
<input id="signin-email" type="email" class="input" required="" autocomplete="username"/>
</div>
<div class="stack is-tight">
<label for="signin-password">Password</label>
<input id="signin-password" type="password" class="input" required="" autocomplete="current-password"/>
</div>
<label class="cluster is-tight">
<input type="checkbox" name="remember" class="input"/> Remember me on this device
</label>
<button class="button" type="submit">Sign in</button>
</form>Horizontal
A single field-and-action pair on one line.
.cluster.is-snug on the form puts label, input,
and button side by side and wraps on narrow viewports. For
the input-plus-button-only shape without a visible label,
reach for .input-group -- the joined border
reads as a unified control.
<docs-resize>
<form class="cluster is-tight" role="search">
<label for="quick-search">Search products</label>
<input id="quick-search" style="flex: 1;" type="search" class="input" placeholder="Widget, gadget, ..." autocomplete="off"/>
<button class="button" type="submit">Go</button>
</form>
</docs-resize>Multi-column fields
Pair short fields that logically belong together (first/last
name, city/state/zip) using .grid for a fixed
minimum column width or .switcher for
columns-to-stack at a threshold. Each grid cell holds a
.stack.is-tight field.
<docs-resize>
<form class="stack">
<div class="stack is-tight">
<label for="addr-street">Street address</label>
<input id="addr-street" type="text" class="input" autocomplete="street-address"/>
</div>
<div class="grid" style="--grid-min: 12rem">
<div class="stack is-tight">
<label for="addr-city">City</label>
<input id="addr-city" type="text" class="input" autocomplete="address-level2"/>
</div>
<div class="stack is-tight">
<label for="addr-state">State</label>
<input id="addr-state" type="text" class="input" autocomplete="address-level1"/>
</div>
<div class="stack is-tight">
<label for="addr-zip">ZIP</label>
<input id="addr-zip" type="text" inputmode="numeric" class="input" autocomplete="postal-code"/>
</div>
</div>
<div class="cluster is-end">
<button class="button is-ghost" type="button">Cancel</button>
<button class="button" type="submit">Save address</button>
</div>
</form>
</docs-resize>
The grid's --grid-min sets the minimum column
width before wrapping. Set it in a demo class (above) or a
consumer utility; the grid's behavior is responsive without
media queries.
Sectioned with fieldsets
Group related controls with <fieldset> and
a <legend>. Each section is its own
.stack.is-tight; sections sit inside the outer
form's .stack. An action bar at the bottom uses
.cluster.is-end for trailing buttons or
.cluster.is-between for left/right splits
(cancel on the left, save on the right).
<form class="stack">
<fieldset class="stack is-tight">
<legend class="text-subtle text-semibold">Notifications</legend>
<label class="cluster is-tight">
<input type="checkbox" checked="" class="input"/> Product updates
</label>
<label class="cluster is-tight">
<input type="checkbox" class="input"/> Weekly digest
</label>
<label class="cluster is-tight">
<input type="checkbox" checked="" class="input"/> Security alerts
</label>
</fieldset>
<fieldset class="stack is-tight">
<legend class="text-subtle text-semibold">Profile visibility</legend>
<label class="cluster is-tight">
<input type="radio" name="visibility" value="public" checked="" class="input"/> Public (anyone can find your profile)
</label>
<label class="cluster is-tight">
<input type="radio" name="visibility" value="unlisted" class="input"/> Unlisted (direct link required)
</label>
<label class="cluster is-tight">
<input type="radio" name="visibility" value="private" class="input"/> Private (only you)
</label>
</fieldset>
<div class="cluster is-between">
<button class="button is-ghost" type="button">Cancel</button>
<button class="button" type="submit">Save settings</button>
</div>
</form>Class reference
.input-- base class for<input>,<select>, and<textarea>. Checkbox and radio get custom chrome without the class..is-sm,.is-lg-- size variants. Remap the element-specific sizing tokens..input-group-- wrapper that joins children in a shared border. Default is the modern unified shape..is-joined(on.input-group) -- classic shaded-endcap variant with per-child borders..input-addon-- label, icon, or unit inside an.input-group.
Customization
The family shares a chrome spine -- --input-border,
--input-radius, --input-bg,
--input-focus-color, --input-transition
-- declared on every family member. Override any of these on
a form ancestor (or any wrapper) and every input, addon, group,
checkbox, and radio inside retones together. Element-specific
tokens (--input-height, --input-padding,
--input-chevron-color, --input-check-accent,
etc.) stay scoped to their element. Size variants remap only
the .input-specific sizing tokens.
Full token list and defaults:
src/css/input.css.
Accessibility
-
Always label the control. Every
<input>,<select>, and<textarea>needs an associated<label>-- either viafor=/idor by nesting. The label-above-input shape inside.stack.is-tightis brio's canonical form rhythm; radios and checkboxes go inside a<fieldset>with a<legend>. -
Pair validation state with error copy.
The red border signals invalid state visually; screen
readers need the reason. Use
aria-invalid="true"on the control plus an adjacent<p class="text-danger">referenced byaria-describedby. For page-level summaries, surface errors in<div class="alert is-danger" role="alert">. -
Focus rings behave differently by control
type. Text inputs use
:focusso the ring shows on click and keyboard alike -- the caret needs to be visible where it lands. Checkbox, radio, and select use:focus-visible(keyboard only); they have no caret, so a ring on click would be noise. -
Forced-colors mode is covered.
Checkbox and radio revert to native rendering inside
@media (forced-colors: active). Windows High Contrast and similar modes paint OS-drawn controls so system colors take over automatically. -
Dim disabled labels yourself. A
disabled control dims its own chrome via
opacity. The surrounding label text stays at full opacity unless you wire it:.my-label:has(:disabled) { color: var(--color-text-muted) }on the label wrapper covers the common case. -
Use proper
autocompletetokens. Browser autofill and password managers rely onautocomplete="email","current-password","street-address", etc. Using the right tokens is an accessibility win -- users with motor impairments benefit disproportionately from working autofill.
CSS Reference: Input
Styled form input for text, select, textarea, checkbox, radio, and range
Source: src/css/input.css
Tokens
| Token | Default | Description |
|---|---|---|
--input-border | var(--color-border-stronger) | Border color shared across the input family |
--input-radius | var(--radius-md) | Border radius shared across the input family |
--input-bg | var(--color-surface) | Background color shared across the input family |
--input-focus-color | var(--color-focus) | Focus ring color shared across the input family |
--input-transition | var(--transition-fast) | Transition speed shared across the input family |
--input-height | 2.25rem | Block height for text inputs and select |
--input-padding | var(--ui-pad-y-compact) var(--ui-pad-x-tight) | Inner padding for text inputs and select |
--input-font-size | var(--ui-text-sm) | Font size for text inputs and select |
--input-line-height | var(--leading-tight) | Line height for text inputs and select |
--input-chevron-color | var(--color-text-muted) | Color of the select dropdown chevron |
--input-check-size | var(--ui-text-base) | Size of the checkbox or radio control |
--input-check-accent | var(--color-primary) | Accent color when checked |
--input-check-color | var(--color-on-primary) | Tick or dot color when checked |
--input-switch-track-width | 2.25rem | Width of the switch track |
--input-switch-track-height | 1.25rem | Height of the switch track |
--input-switch-thumb-offset | 2px | Inset offset for the thumb from the track edge |
--input-switch-thumb-size | calc(var(--input-switch-track-height) - var(--input-switch-thumb-offset) * 2) | Computed size of the switch thumb |
--input-switch-track-off | var(--color-border-stronger) | Track color in the off state |
--input-switch-track-on | var(--input-check-accent) | Track color in the on state |
--input-switch-thumb | var(--color-surface-float) | Thumb color |
--input-range-track-h | 0.375rem | Height of the range track |
--input-range-track-bg | var(--color-fill) | Background color of the range track |
--input-range-track-radius | var(--radius-pill) | Border radius of the range track |
--input-range-thumb-size | 1.125rem | Size of the range thumb |
--input-range-thumb-bg | var(--color-primary) | Background color of the range thumb |
Classes
| Class | Description |
|---|---|
.input | component root |
.is-sm | Smaller text input with tighter padding |
.is-lg | Larger text input with more padding |
CSS Reference: Input Group
Joins inputs with addons or buttons
Source: src/css/input.css
Slots
| Slot | Description |
|---|---|
.input-addon | Label, icon, or unit addon inside an input group |
Classes
| Class | Description |
|---|---|
.input-group | component root |
.is-joined | Classic shaded-endcap look with per-child borders |