Label
The visible name of a form control, linked to it for pointer and screen reader.
import { Checkbox } from "@oration/canon/components/checkbox";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(); const [advice, setAdvice] = React.useState(true); return ( <div className="flex w-full max-w-sm flex-col gap-4 rounded-xl bg-card p-4 text-left shadow-border"> <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@northwindfreight.com" /> </div> <div className="flex flex-col gap-2"> <Label htmlFor={`${id}-po`}> PO number <span className="font-normal text-muted-foreground"> (optional) </span> </Label> <Input id={`${id}-po`} placeholder="PO-20931" className="font-mono" /> </div> <Label> <Checkbox checked={advice} onCheckedChange={(checked) => { setAdvice(checked); toast.add({ title: checked ? "Remittance advice on" : "Remittance advice off", }); }} /> Email remittance advice to the supplier </Label> </div> );}Usage#
Label is the visible name of a form control: a native <label> in Control type (14px, weight 500) that points at its control with htmlFor, or wraps it. Clicking it focuses or toggles the control, and screen readers announce it as the control's name. Inside a form, reach for Field, whose FieldLabel is this Label with field spacing and states. The common mistake is a label that isn't linked: text that looks like a label but names nothing.
When to use
- To name an Input, Textarea, Select or Combobox, above it, with
htmlForset to the control'sid. - To wrap a Checkbox or Radio with its text, so the whole line is one click target.
- Beside a Switch whose name is longer than the Switch's own
labelprop should carry, withhtmlFor. - As the named element a Slider or custom control points at with
aria-labelledby. - In dense sheets and settings rows at 13px, passing
text-[13px]throughclassName.
When not to use
- Inside a form layout with descriptions and errors. FieldLabel adds the spacing, the invalid color and the disabled dimming. Use Field
- To name a group of radios or checkboxes. A group is named by a fieldset legend, not by a label. Use Radio group
- For a section heading above several fields. That is a Title, set as a heading. Use Section header
- For a value on a record, such as a stage or a tier. Use Tag
- For a read-only key and value pair in a record rail. Use Meta line
Every field has a label
Mark the optional, not the required
Anatomy#
- Label text. Control type: 14px at weight 500,
leading-none, sentence case. The color is inherited, so it reads as Graphite Ink on the plane. - Optional marker. A muted (optional) in weight 400 after the text, for the few fields that can stay empty.
- Wrapped control. When the label wraps a Checkbox or Radio, the label is a flex row with an 8px gap, so text and control align on their centers.
- Linked control. With
htmlFor, the control sits outside the label. Clicking the text focuses the input or toggles the checkbox.
Examples#
Above a field
The default: the label sits 8px above its Input and points at it with htmlFor. Click the label and the caret lands in the field.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function AboveField() { const id = React.useId(); return ( <div className="flex w-full max-w-xs flex-col gap-2"> <Label htmlFor={`${id}-terms`}>Payment terms</Label> <Input id={`${id}-terms`} defaultValue="Net 30" /> </div> );}Wrapping a control
Wrap a Checkbox and its text in one Label so the whole line toggles. Options in a list drop to weight 400 so the group heading keeps the weight.
1 of 3 documents required before the first payment
import { Checkbox } from "@oration/canon/components/checkbox";import { Label } from "@oration/canon/components/label";import * as React from "react";export function WrappingControl() { const [docs, setDocs] = React.useState<string[]>(["w9"]); const options = [ { value: "w9", label: "W-9 on file" }, { value: "coi", label: "Certificate of insurance" }, { value: "bank", label: "Bank letter on letterhead" }, ]; return ( <div className="flex flex-col gap-3"> {options.map((option) => ( <Label key={option.value} className="font-normal"> <Checkbox checked={docs.includes(option.value)} onCheckedChange={(checked) => setDocs((current) => checked ? [...current, option.value] : current.filter( (value) => value !== option.value, ), ) } /> {option.label} </Label> ))} <p className="text-xs text-muted-foreground tabular-nums"> {docs.length} of 3 documents required before the first payment </p> </div> );}Linked to a switch
A settings row names its Switch with a Label and htmlFor. Base UI links the label to the switch's hidden input, so clicking the text flips it.
Invoices post, and payment waits for the form.
import { Label } from "@oration/canon/components/label";import { Switch } from "@oration/canon/components/switch";import * as React from "react";export function LinkedSwitch() { const id = React.useId(); const [hold, setHold] = React.useState(true); return ( <div className="flex w-full max-w-sm items-start justify-between gap-4"> <div className="flex flex-col gap-1"> <Label htmlFor={`${id}-hold`}> Hold payments until a W-9 is on file </Label> <p className="text-13 text-muted-foreground"> {hold ? "Invoices post, and payment waits for the form." : "Suppliers are paid whether or not a W-9 is on file."} </p> </div> <Switch id={`${id}-hold`} checked={hold} onCheckedChange={setHold} /> </div> );}Dense
In sheets and settings panels, step the label and the field down to 13px with text-[13px], and pair the label with an Info tip when the term needs a definition. The field stays 16px below 768px.
import { InfoTip } from "@oration/canon/components/info-tip";import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";import * as React from "react";export function Dense() { const id = React.useId(); return ( <div className="flex w-full max-w-xs flex-col gap-4 rounded-xl bg-card p-4 shadow-border"> <div className="flex flex-col gap-1.5"> <div className="flex items-center gap-1"> <Label htmlFor={`${id}-cutoff`} className="text-[13px]"> Run cutoff </Label> <InfoTip title="Run cutoff" description="Invoices approved after the cutoff wait for the next weekly payment run." /> </div> <Input id={`${id}-cutoff`} defaultValue="Thursday, 5:00 PM CT" className="md:text-[13px]" /> </div> <div className="flex flex-col gap-1.5"> <Label htmlFor={`${id}-vendor`} className="text-[13px]"> Vendor ID </Label> <Input id={`${id}-vendor`} defaultValue="V-004417" className="font-mono md:text-[13px]" /> </div> </div> );}Naming a slider
Controls that aren't labelable elements take the label's id through aria-labelledby. Base UI passes it to each thumb's range input.
import { Label } from "@oration/canon/components/label";import { Slider } from "@oration/canon/components/slider";import * as React from "react";export function NamingASlider() { const id = React.useId(); const [limit, setLimit] = React.useState(40); return ( <div className="flex w-full max-w-xs flex-col gap-3"> <div className="flex items-baseline justify-between"> <Label id={`${id}-limit`}>Daily sending limit</Label> <span className="text-13 text-muted-foreground tabular-nums"> {limit} a day </span> </div> <Slider aria-labelledby={`${id}-limit`} value={[limit]} min={5} max={100} step={5} onValueChange={(value) => { const next = Array.isArray(value) ? value[0] : value; if (typeof next === "number") setLimit(next); }} /> </div> );}States#
Use the full number, like INV-20417.
import { Input } from "@oration/canon/components/input";import { Label } from "@oration/canon/components/label";export function StatesRow() { return ( <div className="grid w-full grid-cols-1 gap-6 sm:grid-cols-3"> <div className="flex flex-col gap-3"> <span className="text-xs text-muted-foreground">Rest</span> <div className="flex flex-col gap-2"> <Label htmlFor="label-state-rest">Invoice number</Label> <Input id="label-state-rest" tabIndex={-1} defaultValue="INV-20417" className="pointer-events-none font-mono" /> </div> </div> <div className="flex flex-col gap-3"> <span className="text-xs text-muted-foreground">Disabled</span> <div className="group flex flex-col gap-2" data-disabled="true"> <Label htmlFor="label-state-disabled">Invoice number</Label> <Input id="label-state-disabled" disabled defaultValue="INV-20417" className="font-mono" /> </div> </div> <div className="flex flex-col gap-3"> <span className="text-xs text-muted-foreground">Invalid</span> <div className="flex flex-col gap-2"> <Label htmlFor="label-state-invalid">Invoice number</Label> <Input id="label-state-invalid" tabIndex={-1} defaultValue="INV" aria-invalid aria-describedby="label-state-invalid-error" className="pointer-events-none font-mono" /> <p id="label-state-invalid-error" className="text-13 text-destructive" > Use the full number, like INV-20417. </p> </div> </div> </div> );}| State | Treatment |
|---|---|
| Rest | Inherited ink at weight 500. Labels have no hover style. |
| Disabled | Drops to 50% opacity and stops pointer events when an ancestor with the group class carries data-disabled="true", or when a preceding peer control is :disabled. FieldLabel does this for you inside a disabled Field. |
| Invalid | Label itself stays ink. Inside a Field with data-invalid, the field turns its text Signal Red, and the error message below carries the reason. |
Behavior#
- A native
<label>: clicking it moves focus to the linked input or textarea, and clicks the linked checkbox, radio or switch. - Base UI's Checkbox, Radio and Switch keep a hidden input with the
idyou pass, so<Label htmlFor>links to them the same way it links to an Input. select-nonestops a double-click on the label from selecting its text, since the click already goes to the control.leading-nonekeeps a one-line label tight to its control. A label that wraps needsleading-snug, which FieldLabel sets.
Do and don't#
Content#
- Name the thing, not the action: Remit-to email, not Enter your remit-to email.
- Sentence case, no trailing colon, no punctuation.
- Keep it to one to four words. Put the explanation in a description below the field, not in the label.
- Use the words the supplier or the ERP uses: Tax ID (EIN), PO number, Payment terms.
- For a checkbox, write the label as the true statement the box turns on: Email remittance advice to the supplier.
Accessibility#
htmlFormust match the control'sid. Generate it withReact.useId()so two copies of the form on one page don't collide.- A label gives the control its accessible name; don't also set an
aria-labelthat says something different. - When a visible label would repeat a nearby heading, keep the name and hide it with
sr-onlyrather than dropping it. - Name a radio or checkbox group with a fieldset and legend, and each option with its own label.
- Controls that aren't labelable elements, such as the Slider's thumbs, take the label's
idthrougharia-labelledby.
Design tokens#
| Token | Used for |
|---|---|
text-sm | 14px Control type; override with text-[13px] in dense UI |
font-medium | Weight 500 |
--foreground | Inherited text color |
--muted-foreground | The (optional) marker |
API reference#
Label
A styled native label. It renders data-slot="label" and passes every prop through.
Other props spread onto <label> (React.ComponentProps<"label">).
| Prop | Type | Default | Description |
|---|---|---|---|
htmlFor | string | No default | The id of the control it names. Omit it when the label wraps the control. |
className | string | No default | Merged after the base classes. Use text-[13px], not text-13, to step down to dense type. |
children | React.ReactNode | No default | The label text, and the control itself when wrapping. |
Known gaps#
Where the implementation and the system disagree today. Follow the system, not the gap.
Label's own disabled styles rarely fire. peer-disabled: needs a native :disabled control before the label with the peer class, and Base UI's Checkbox, Radio and Switch are spans that are never :disabled. group-data-[disabled=true]: needs an unnamed group ancestor, and Field uses the named group/field. Dim the label yourself, or use FieldLabel in a disabled Field.
Product call sites pass text-13 through className. Label merges with the cn package, which treats text-13 as a color, so text-sm is kept alongside it and the size depends on stylesheet order. Use text-[13px].
leading-none gives a wrapping label zero leading, so a two-line label collides with itself. Add leading-snug or use FieldLabel.