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: content auto-grows the textarea as the user types, eliminating the need for JS height-management. It ignores rows, so set min-block-size for 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.

Notification frequency
<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.

$ USD
@
<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.

$ USD

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.

https://
<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).

Notifications
Profile visibility
<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 via for=/id or by nesting. The label-above-input shape inside .stack.is-tight is 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 by aria-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 :focus so 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 autocomplete tokens. Browser autofill and password managers rely on autocomplete="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

TokenDefaultDescription
--input-bordervar(--color-border-stronger)Border color shared across the input family
--input-radiusvar(--radius-md)Border radius shared across the input family
--input-bgvar(--color-surface)Background color shared across the input family
--input-focus-colorvar(--color-focus)Focus ring color shared across the input family
--input-transitionvar(--transition-fast)Transition speed shared across the input family
--input-height2.25remBlock height for text inputs and select
--input-paddingvar(--ui-pad-y-compact) var(--ui-pad-x-tight)Inner padding for text inputs and select
--input-font-sizevar(--ui-text-sm)Font size for text inputs and select
--input-line-heightvar(--leading-tight)Line height for text inputs and select
--input-chevron-colorvar(--color-text-muted)Color of the select dropdown chevron
--input-check-sizevar(--ui-text-base)Size of the checkbox or radio control
--input-check-accentvar(--color-primary)Accent color when checked
--input-check-colorvar(--color-on-primary)Tick or dot color when checked
--input-switch-track-width2.25remWidth of the switch track
--input-switch-track-height1.25remHeight of the switch track
--input-switch-thumb-offset2pxInset offset for the thumb from the track edge
--input-switch-thumb-sizecalc(var(--input-switch-track-height) - var(--input-switch-thumb-offset) * 2)Computed size of the switch thumb
--input-switch-track-offvar(--color-border-stronger)Track color in the off state
--input-switch-track-onvar(--input-check-accent)Track color in the on state
--input-switch-thumbvar(--color-surface-float)Thumb color
--input-range-track-h0.375remHeight of the range track
--input-range-track-bgvar(--color-fill)Background color of the range track
--input-range-track-radiusvar(--radius-pill)Border radius of the range track
--input-range-thumb-size1.125remSize of the range thumb
--input-range-thumb-bgvar(--color-primary)Background color of the range thumb

Classes

ClassDescription
.inputcomponent root
.is-smSmaller text input with tighter padding
.is-lgLarger text input with more padding

CSS Reference: Input Group

Joins inputs with addons or buttons

Source: src/css/input.css

Slots

SlotDescription
.input-addonLabel, icon, or unit addon inside an input group

Classes

ClassDescription
.input-groupcomponent root
.is-joinedClassic shaded-endcap look with per-child borders