Skip to content

Label

The visible name of a form control, linked to it for pointer and screen reader.

Status
Stable
Level
Atom
Category
Inputs
Adoption
Not used yet
import { Label } from "@oration/canon/components/label";
packages/canon/src/components/label.tsx
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 htmlFor set to the control's id.
  • 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 label prop should carry, with htmlFor.
  • 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] through className.

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

Every control carries a name, visible or screen-reader only. A placeholder is not a label: it disappears as soon as someone types and is read inconsistently by assistive tech.

Mark the optional, not the required

Most fields in Oration are required, so mark the exceptions with a muted (optional) after the label. No red asterisks.

Anatomy#

  1. 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.
  2. Optional marker. A muted (optional) in weight 400 after the text, for the few fields that can stay empty.
  3. 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.
  4. 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.

40 a day
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#

Rest
Disabled
Invalid

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>    );}
States
StateTreatment
RestInherited ink at weight 500. Labels have no hover style.
DisabledDrops 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.
InvalidLabel 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 id you pass, so <Label htmlFor> links to them the same way it links to an Input.
  • select-none stops a double-click on the label from selecting its text, since the click already goes to the control.
  • leading-none keeps a one-line label tight to its control. A label that wraps needs leading-snug, which FieldLabel sets.

Do and don't#

Do. Put a visible label above the field and use the placeholder for an example value.
Don't. Use the placeholder as the only label. It vanishes on the first keystroke.
Do. Wrap the checkbox and its text in one Label, so the whole line toggles.
Email remittance advice
Don't. Set the text in a plain span beside the checkbox. It looks the same and does nothing when clicked.
Do. Mark the one optional field with a muted (optional).
Don't. Put a red asterisk on every required field. It adds noise to nearly every label on the page.

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#

  • htmlFor must match the control's id. Generate it with React.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-label that says something different.
  • When a visible label would repeat a nearby heading, keep the name and hide it with sr-only rather 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 id through aria-labelledby.

Design tokens#

Design tokens
TokenUsed for
text-sm14px Control type; override with text-[13px] in dense UI
font-mediumWeight 500
--foregroundInherited text color
--muted-foregroundThe (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">).

Props of Label
PropTypeDefaultDescription
htmlForstringNo defaultThe id of the control it names. Omit it when the label wraps the control.
classNamestringNo defaultMerged after the base classes. Use text-[13px], not text-13, to step down to dense type.
childrenReact.ReactNodeNo defaultThe 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.