Skip to content

Input

A single-line text field, 32px tall with an indigo caret and focus ring.

Status
Stable
Level
Atom
Category
Inputs
Adoption
Not used yet
import { Input } from "@oration/canon/components/input";
packages/canon/src/components/input.tsx

Remit-to address

import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Hero() {    const id = React.useId();    return (        <form            className="flex w-full max-w-md flex-col overflow-hidden rounded-xl bg-popover text-left shadow-lg"            onSubmit={(event) => {                event.preventDefault();                toast.add({                    type: "success",                    title: "Remit-to address saved",                    description:                        "Northwind Freight's next check goes to the new address.",                });            }}        >            <div className="flex flex-col gap-4 p-4">                <p className="text-base leading-none font-medium text-foreground">                    Remit-to address                </p>                <div className="flex flex-col gap-2">                    <Label htmlFor={`${id}-street`}>Street</Label>                    <Input                        id={`${id}-street`}                        autoComplete="address-line1"                        defaultValue="1450 Harbor Way, Suite 300"                    />                </div>                <div className="grid grid-cols-[minmax(0,1fr)_5rem_6rem] gap-3">                    <div className="flex min-w-0 flex-col gap-2">                        <Label htmlFor={`${id}-city`}>City</Label>                        <Input                            id={`${id}-city`}                            autoComplete="address-level2"                            defaultValue="Oakland"                        />                    </div>                    <div className="flex min-w-0 flex-col gap-2">                        <Label htmlFor={`${id}-state`}>State</Label>                        <Input                            id={`${id}-state`}                            autoComplete="address-level1"                            defaultValue="CA"                        />                    </div>                    <div className="flex min-w-0 flex-col gap-2">                        <Label htmlFor={`${id}-zip`}>ZIP</Label>                        <Input                            id={`${id}-zip`}                            inputMode="numeric"                            autoComplete="postal-code"                            placeholder="94607"                            className="tabular-nums"                        />                    </div>                </div>            </div>            <div className="flex items-center justify-end gap-2 border-t border-border bg-muted/50 px-4 py-3">                <Button type="reset" variant="ghost">                    Reset                </Button>                <Button type="submit">Save address</Button>            </div>        </form>    );}

Usage#

Input is the single-line text field: 32px tall, 10px corners, a 1px Field Stroke, a transparent fill and an indigo caret. It holds names, emails, amounts, IDs and search terms across every form and sheet in Oration, and it is built on Base UI Input, so it joins Base UI Field validation when it sits in one. Width is set by its container. The thing people get wrong is naming: an Input always has a Label, and the placeholder only ever shows an example.

When to use

  • For short free text a person types: a supplier's legal name, a remit-to email, an invoice number.
  • For numbers and amounts that are typed rather than stepped, with inputMode="decimal" and tabular-nums.
  • For machine strings such as a vendor ID, an EIN or a routing number, in font-mono.
  • Inside a Field, with a label above, an optional description and an inline error.
  • For a file picker in a plain form, with type="file", when a Dropzone would be too much.

When not to use

  • For more than one line of text, such as a note to a supplier. Use Textarea
  • For a search box with an icon and a clear button. Use Search field
  • For a prefix or suffix inside the field, such as $ or @cedarline.io. Use Input group
  • For a secret such as an API key or a password. Use Password input
  • For choosing from a known list of values. Use Select
  • For a verification code typed one character at a time. Use One-time code

Inputs use 16px below 768px

The field is text-base (16px) on small screens and text-sm (14px) from 768px. Mobile Safari zooms the page when a focused field is under 16px, so never pin a smaller size below md.

Every field has a label

A visible Label above, or an sr-only one when the context names the field. A placeholder is an example value, never the name.

Anatomy#

  1. Container. 32px tall, full width, 10px corners, a 1px Field Stroke border and a transparent fill (the input color at 30% in dark).
  2. Value. 16px below 768px and 14px above, with 10px side padding. The caret is Quiet Indigo.
  3. Placeholder. Slate Meta, for an example value such as ap@northwindfreight.com. Gone once the field has a value.
  4. Focus ring. On keyboard and pointer focus: an indigo border and a 3px Focus Indigo ring at 50%.

Examples#

Types

One box for every kind of value. Pick the keyboard with type and inputMode, align amounts right in tabular figures, set machine strings in mono, and let type="file" style the browser's file button to match.

import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Types() {    const id = React.useId();    return (        <div className="grid w-full max-w-xl grid-cols-1 gap-4 sm:grid-cols-2">            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-name`}>Legal name</Label>                <Input                    id={`${id}-name`}                    autoComplete="organization"                    defaultValue="Orchard Street Supply Co."                />            </div>            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-email`}>Remit-to email</Label>                <Input                    id={`${id}-email`}                    type="email"                    autoComplete="email"                    placeholder="ap@orchardstreet.com"                />            </div>            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-amount`}>Invoice total (USD)</Label>                <Input                    id={`${id}-amount`}                    inputMode="decimal"                    defaultValue="18,240.00"                    className="text-right tabular-nums"                />            </div>            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-ein`}>Tax ID (EIN)</Label>                <Input                    id={`${id}-ein`}                    inputMode="numeric"                    placeholder="12-3456789"                    className="font-mono"                />            </div>            <div className="flex flex-col gap-2 sm:col-span-2">                <Label htmlFor={`${id}-w9`}>Signed W-9</Label>                <Input                    id={`${id}-w9`}                    type="file"                    accept="application/pdf"                    onChange={(event) => {                        const file = event.target.files?.[0];                        if (file) {                            toast.add({ title: `Attached ${file.name}` });                        }                    }}                />            </div>        </div>    );}

Widths

The field fills its container, so size the wrapper to the value: a state code, a ZIP and a routing number get short fields, a bank name takes the rest of the row.

import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function Widths() {    const id = React.useId();    return (        <div className="flex w-full max-w-xl flex-wrap items-end gap-3">            <div className="flex w-20 flex-col gap-2">                <Label htmlFor={`${id}-state`}>State</Label>                <Input id={`${id}-state`} defaultValue="TX" />            </div>            <div className="flex w-24 flex-col gap-2">                <Label htmlFor={`${id}-zip`}>ZIP</Label>                <Input                    id={`${id}-zip`}                    inputMode="numeric"                    defaultValue="75201"                    className="tabular-nums"                />            </div>            <div className="flex w-36 flex-col gap-2">                <Label htmlFor={`${id}-routing`}>Routing number</Label>                <Input                    id={`${id}-routing`}                    inputMode="numeric"                    defaultValue="111000025"                    className="font-mono"                />            </div>            <div className="flex min-w-48 flex-1 flex-col gap-2">                <Label htmlFor={`${id}-bank`}>Bank name</Label>                <Input id={`${id}-bank`} defaultValue="Bank of America, N.A." />            </div>        </div>    );}

Invalid

Validate on submit. Set aria-invalid, link the message with aria-describedby and move focus to the field. The error clears as soon as the value changes.

import { Button } from "@oration/canon/components/button";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import { toast } from "@oration/canon/components/toast";import * as React from "react";export function Invalid() {    const id = React.useId();    const [ein, setEin] = React.useState("12-34567");    const [error, setError] = React.useState<string>();    return (        <form            noValidate            className="flex w-full max-w-xs flex-col gap-4"            onSubmit={(event) => {                event.preventDefault();                if (!/^\d{2}-\d{7}$/.test(ein)) {                    setError("Enter a 9-digit EIN, like 12-3456789.");                    document.getElementById(`${id}-ein`)?.focus();                    return;                }                setError(undefined);                toast.add({ type: "success", title: "Tax ID saved" });            }}        >            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-ein`}>Tax ID (EIN)</Label>                <Input                    id={`${id}-ein`}                    inputMode="numeric"                    value={ein}                    onChange={(event) => {                        setEin(event.target.value);                        if (error) setError(undefined);                    }}                    aria-invalid={error ? true : undefined}                    aria-describedby={error ? `${id}-ein-error` : undefined}                    className="font-mono"                />                {error ? (                    <p                        id={`${id}-ein-error`}                        className="text-13 text-destructive"                    >                        {error}                    </p>                ) : null}            </div>            <Button type="submit" variant="outline" className="self-start">                Save tax ID            </Button>        </form>    );}

Disabled and read-only

Disabled for a value someone can't change here, with the reason beside it. Read-only for a value people need to select and copy, such as a trace number.

Synced from NetSuite. Change it there.

Read-only. Focus it to select the number for the supplier.

import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function DisabledAndReadOnly() {    const id = React.useId();    return (        <div className="grid w-full max-w-xl grid-cols-1 gap-4 sm:grid-cols-2">            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-vendor`} className="opacity-50">                    Vendor ID                </Label>                <Input                    id={`${id}-vendor`}                    disabled                    defaultValue="V-004417"                    aria-describedby={`${id}-vendor-description`}                    className="font-mono"                />                <p                    id={`${id}-vendor-description`}                    className="text-13 text-muted-foreground"                >                    Synced from NetSuite. Change it there.                </p>            </div>            <div className="flex flex-col gap-2">                <Label htmlFor={`${id}-trace`}>ACH trace number</Label>                <Input                    id={`${id}-trace`}                    readOnly                    defaultValue="021000021784530"                    aria-describedby={`${id}-trace-description`}                    className="font-mono"                    onFocus={(event) => event.currentTarget.select()}                />                <p                    id={`${id}-trace-description`}                    className="text-13 text-muted-foreground"                >                    Read-only. Focus it to select the number for the supplier.                </p>            </div>        </div>    );}

16px below 768px

The same field at each width. Below 768px it is 16px so mobile Safari doesn't zoom on focus; from 768px it steps down to 14px. Dense layouts override only the md: size.

import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";export function MobileSize() {    return (        <div className="grid w-full max-w-xl grid-cols-1 gap-6 sm:grid-cols-2">            <div className="flex flex-col gap-2">                <Label htmlFor="input-mobile-small">Below 768px: 16px</Label>                <Input                    id="input-mobile-small"                    defaultValue="maya.okafor@cedarline.io"                    className="md:text-base"                />            </div>            <div className="flex flex-col gap-2">                <Label htmlFor="input-mobile-large">768px and up: 14px</Label>                <Input                    id="input-mobile-large"                    defaultValue="maya.okafor@cedarline.io"                    className="text-sm"                />            </div>        </div>    );}

States#

Rest
Placeholder
Focus
Invalid
Disabled
Read-only
import { Input } from "@oration/canon/components/input";import { cn } from "@oration/canon/lib/utils";export function StatesRow() {    const states = [        { name: "Rest", value: "INV-20417", className: "" },        { name: "Placeholder", value: "", className: "" },        {            name: "Focus",            value: "INV-20417",            className: "border-ring ring-3 ring-ring/50",        },        { name: "Invalid", value: "INV", className: "" },        { name: "Disabled", value: "INV-20417", className: "" },        { name: "Read-only", value: "INV-20417", className: "" },    ];    return (        <div className="grid w-full grid-cols-2 gap-x-4 gap-y-5 sm:grid-cols-3">            {states.map((state) => (                <div key={state.name} className="flex min-w-0 flex-col gap-2">                    <span className="text-xs text-muted-foreground">                        {state.name}                    </span>                    <Input                        aria-label={`Invoice number, ${state.name.toLowerCase()}`}                        tabIndex={-1}                        defaultValue={state.value}                        placeholder="INV-20417"                        disabled={state.name === "Disabled"}                        readOnly={state.name === "Read-only"}                        aria-invalid={state.name === "Invalid" || undefined}                        className={cn(                            "pointer-events-none font-mono",                            state.className,                        )}                    />                </div>            ))}        </div>    );}
States
StateTreatment
RestField Stroke border on a transparent fill. No hover style.
PlaceholderSlate Meta example text until the first keystroke.
FocusIndigo border and a 3px ring at 50%. focus-visible matches every focus on a text field, pointer included.
InvalidWith aria-invalid, a Signal Red border and a 3px red ring at 20% (50% border and 40% ring in dark). The ring stays until the value is fixed.
Disabled50% opacity on a faint input-tinted fill, no pointer events. The value is not submitted with the form.
Read-onlyreadOnly keeps the value selectable and submitted but draws exactly like rest. See known gaps.

Behavior#

  • Renders a native <input> through Base UI Input. Inside a Base UI Field it reports dirty, touched and validity to the field; on its own it is a plain input.
  • Width comes from the container: the field is w-full and min-w-0, so size it with the column or a max-w-* on its wrapper.
  • Border and fill transition over 150ms. The ring is a box-shadow and appears at once.
  • type="file" styles the browser's file button as 14px medium text on no fill, so the field reads like the others.
  • Pick the keyboard with type and inputMode (email, decimal, numeric, tel), and help autofill with autoComplete.

Do and don't#

Do. Size the field to the value it holds: a ZIP code gets a short field, an email a long one.
Don't. Stretch every field to the full width of the form. A 5-digit ZIP in a 40rem field reads as a long answer.
Do. Keep the field at 16px below 768px and step down with md:text-[13px] in dense layouts.
Don't. Pin the field to 13px at every width. Mobile Safari zooms the page on focus.

Synced from NetSuite. Change it there.

Do. Explain a disabled field beside it, so people know what would unlock it.
Don't. Disable a field with no reason. It looks broken, and screen readers skip it entirely.

Content#

  • Placeholders show the format, not the instruction: ap@northwindfreight.com, 12-3456789. Never Enter email.
  • Put rules the value must follow (format, length, units) in the description, where they stay visible while typing.
  • Errors say what to do: Enter a 9-digit EIN, like 12-3456789. Not Invalid input.
  • Units and currency go beside the value, in an Input group, not inside the placeholder.

Accessibility#

  • Name every Input with a Label linked by htmlFor, or an sr-only label. aria-label is the last resort.
  • Set aria-invalid and point aria-describedby at the error message, so the reason is announced with the field.
  • On submit, move focus to the first invalid field.
  • Use autoComplete tokens (email, organization, postal-code) so browsers and password managers can fill the form.
  • Disabled inputs leave the tab order. If people need to read or copy the value, use readOnly instead.
  • The 16px size below 768px also prevents the zoom that disorients low-vision users on phones.
Keyboard interactions
KeysAction
TabMoves focus into the field; the caret lands at the end.
EnterSubmits the surrounding form.
EscapeClears the value when type="search".

Design tokens#

Design tokens
TokenUsed for
--inputThe 1px Field Stroke; 30% fill in dark; the disabled fill
--primaryThe caret, set globally for input and textarea
--muted-foregroundPlaceholder text
--ringFocus border and the 3px ring at 50%
--destructiveInvalid border and the ring at 20% (40% in dark)
--radius-lg10px corners
text-base md:text-sm16px below 768px, 14px above

API reference#

Input

A styled Base UI Input. Renders data-slot="input".

Other props spread onto Base UI Input (typed as React.ComponentProps<"input">).

Props of Input
PropTypeDefaultDescription
typeReact.HTMLInputTypeAttribute"text"The native input type. email, url, tel, search, number, file and date all take the same box.
valuestring | number | readonly string[]No defaultControlled value. Pair with onChange.
defaultValuestring | number | readonly string[]No defaultInitial value when uncontrolled.
onChangeReact.ChangeEventHandler<HTMLInputElement>No defaultFires on every edit.
placeholderstringNo defaultAn example value in Slate Meta. Not a label.
aria-invalidbooleanNo defaultDraws the red border and ring. Set it with the error message.
disabledbooleanfalseDims to 50%, blocks input and leaves the value out of the form.
readOnlybooleanfalseKeeps the value focusable, selectable and submitted, but not editable.
classNamestringNo defaultMerged after the base classes. Override the size with md:text-[13px] so the 16px mobile size stays.

Known gaps#

Where the implementation and the system disagree today. Follow the system, not the gap.

readOnly has no style of its own, so a read-only field is indistinguishable from an editable one until someone tries to type.

The props are typed as React.ComponentProps<"input">, so Base UI's onValueChange works at runtime but isn't in the type. Use onChange.

disabled:cursor-not-allowed never shows, because disabled:pointer-events-none stops the pointer from reaching the field.