Input
A single-line text field, 32px tall with an indigo caret and focus ring.
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"andtabular-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
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
sr-only one when the context names the field. A placeholder is an example value, never the name.Anatomy#
- Container. 32px tall, full width, 10px corners, a 1px Field Stroke border and a transparent fill (the input color at 30% in dark).
- Value. 16px below 768px and 14px above, with 10px side padding. The caret is Quiet Indigo.
- Placeholder. Slate Meta, for an example value such as ap@northwindfreight.com. Gone once the field has a value.
- 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#
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> );}| State | Treatment |
|---|---|
| Rest | Field Stroke border on a transparent fill. No hover style. |
| Placeholder | Slate Meta example text until the first keystroke. |
| Focus | Indigo border and a 3px ring at 50%. focus-visible matches every focus on a text field, pointer included. |
| Invalid | With 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. |
| Disabled | 50% opacity on a faint input-tinted fill, no pointer events. The value is not submitted with the form. |
| Read-only | readOnly 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-fullandmin-w-0, so size it with the column or amax-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
typeandinputMode(email,decimal,numeric,tel), and help autofill withautoComplete.
Do and don't#
md:text-[13px] in dense layouts.Synced from NetSuite. Change it there.
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 ansr-onlylabel.aria-labelis the last resort. - Set
aria-invalidand pointaria-describedbyat the error message, so the reason is announced with the field. - On submit, move focus to the first invalid field.
- Use
autoCompletetokens (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
readOnlyinstead. - The 16px size below 768px also prevents the zoom that disorients low-vision users on phones.
| Keys | Action |
|---|---|
| Tab | Moves focus into the field; the caret lands at the end. |
| Enter | Submits the surrounding form. |
| Escape | Clears the value when type="search". |
Design tokens#
| Token | Used for |
|---|---|
--input | The 1px Field Stroke; 30% fill in dark; the disabled fill |
--primary | The caret, set globally for input and textarea |
--muted-foreground | Placeholder text |
--ring | Focus border and the 3px ring at 50% |
--destructive | Invalid border and the ring at 20% (40% in dark) |
--radius-lg | 10px corners |
text-base md:text-sm | 16px 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">).
| Prop | Type | Default | Description |
|---|---|---|---|
type | React.HTMLInputTypeAttribute | "text" | The native input type. email, url, tel, search, number, file and date all take the same box. |
value | string | number | readonly string[] | No default | Controlled value. Pair with onChange. |
defaultValue | string | number | readonly string[] | No default | Initial value when uncontrolled. |
onChange | React.ChangeEventHandler<HTMLInputElement> | No default | Fires on every edit. |
placeholder | string | No default | An example value in Slate Meta. Not a label. |
aria-invalid | boolean | No default | Draws the red border and ring. Set it with the error message. |
disabled | boolean | false | Dims to 50%, blocks input and leaves the value out of the form. |
readOnly | boolean | false | Keeps the value focusable, selectable and submitted, but not editable. |
className | string | No default | Merged 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.