Label
A foundational form label primitive. Three sizes, five intents with AA-passing inks, three tones, three weights, required/optional indicators, icon slots, a description line that never leaks into the control's name, and RTL support.
pnpm add @tessinaui/uiWhen to use
- Every form control that takes input. Pair a visible
Labelwith the control throughhtmlFor, so clicking the text focuses the field and the text becomes its accessible name. - A short name that reads well out of context: "Email address", not "Please enter the email address you would like us to use". Long labels wrap and never truncate; shorten the copy and move context into
description. - Marking required or optional fields. Use
requiredoroptional, and pick one convention per product: mark the minority, and explain the asterisk once at the top of the form. - Helper text tied to one field.
descriptionrenders under the label and, wired throughdescriptionIdto the control'saria-describedby, is read after the name. - A caption for a group or a non-labelable widget. Use
render={<legend />}inside a<fieldset>, orrender={<span />}witharia-labelledby. - A control whose context already names it, such as a lone search field: keep the label for assistive technology with
visuallyHidden.
When not to use
- For a fully wired field with label, helper, error and counter. Use Field, which connects all of them for you.
- As a placeholder. A placeholder disappears on input and is not a reliable name; keep a visible label and use the placeholder, if at all, for an example value.
- To group related controls on its own. A standalone
Labeldoes not name a group; use Fieldset, or thelegendhost above. - To carry essential information in an info tip. An interactive trigger inside a
<label>joins the control's name; keep the trigger outside it and keep anything required indescription. - To hold a paragraph. The label is the name, not the explanation; put longer text in the description or in the page.
Installation
pnpm add @tessinaui/uiUsage
import { Label } from "@tessinaui/ui";<Label htmlFor="email">Email address</Label>
<input id="email" type="email" />Examples
Default
A basic label associated with a control via htmlFor.
Sizes
Three sizes — sm, md (default) and lg — each a distinct type step.
Intents
Use intent to communicate validation state — error, warning, success, and info.
Required and optional
Mark fields with required or optional indicators after the label text.
With icon
Add a leadingIcon (or trailingIcon) beside the label text.
With description
Helper text below the label via description, exposed to assistive tech through
descriptionId → aria-describedby on the control.
With info tooltip
An info trigger composes as a sibling of the label — never inside it. An
interactive element inside a <label> joins the control's accessible name and
fights native label activation, which is why the info button belongs outside
the <label>. Reserve it for supplemental context; anything essential belongs
in description.
openOnHover on the trigger opens it on hover as well as click/keyboard.
There is a second, quieter variant when the label also has a description
and the tip must sit between the text and the description: a
non-focusable hover trigger in the trailingIcon slot. The icon slots
are aria-hidden, so nothing inside them can leak into the control's
accessible name — which is exactly what makes this safe where a nested
button would not be. The cost: no tab stop, so the
tip is hover/click-only — keep its content supplemental, or prefer the
sibling-button variant above when keyboard access to the tip matters.
<Label
className="w-full"
description="We'll use this to send you confirmations."
trailingIcon={
<Popover>
{/* role/tabIndex restated: without them Base UI stamps role="button" +
tabindex="0" on the span — a focusable node inside the aria-hidden
icon slot. This keeps the trigger truly passive. */}
<PopoverTrigger
openOnHover
delay={200}
nativeButton={false}
render={<span className="inline-flex cursor-help" role="presentation" tabIndex={-1} />}
>
<Info />
</PopoverTrigger>
<PopoverContent>…</PopoverContent>
</Popover>
}
>
Email address
</Label>The icon inherits the label's ink — sizes, tone="on-color" and disabled
all follow automatically.
Sizes
Every size is a distinct type step. The text is 12px at sm and never
smaller, because 12px is the floor for all text in the system.
| Size | Text | Icons | Use |
|---|---|---|---|
sm | 12px | 14px | Dense forms; beside xs–md Field-family controls |
md | 14px | 16px | Default; beside lg–xl Field-family controls |
lg | 16px | 20px | Larger standalone forms, settings, auth flows |
Field, Select, NumberField and the rest of the input family render their own
label at 12px through md and 14px at lg and xl. When you put a Label
beside one of those controls yourself, use sm or md to match it.
Long labels wrap — they never truncate. If a label is too long, shorten
the copy, don't ellipsize it. Keep labels to a few words; move context into
description.
Intents
Use intent to communicate validation state. Any non-none intent overrides the tone.
| Intent | Token | Typical use |
|---|---|---|
none | inherits tone | Default |
error | text-error | Field failed validation |
warning | text-warning-tinted-foreground | Field needs attention |
success | text-success-tinted-foreground | Field passed validation |
info | text-info | Informational hint |
Warning and success deliberately do not paint their raw tokens: raw amber
is 2.2:1 on white and raw green 3.2:1 — below the AA 4.5:1 text floor. The
inks above measure 7.14:1 and 7.13:1 in light, higher in dark, and are
hard-gated by pnpm audit:contrast.
Tones
Controls the base colour when intent="none".
| Tone | Token | Use case |
|---|---|---|
default | text-foreground | Standard on light/background surfaces |
muted | text-muted-foreground | De-emphasised fields (e.g. optional) |
on-color | text-current | Inside a coloured Surface — inherits the surface's own ink; the description takes the plate's muted tier (full ink on the error, info and success plates), and intents and the required star take the shared adaptive --on-ink-* inks; an intent colours only the text and icons, so the indicators keep the plate's inks |
On color
Every intent on a primary plate, a hand-painted dark plate, a pale plate
(ink="dark", where every ink darkens instead of lightening) and a plate
painted without a Surface. The intent inks colour only the text and icon; the
star and "(optional)" keep the plate's inks. A disabled label dims to 40% of
the plate ink, below AA on purpose. That is acceptable only because the control
it names is disabled too, so always pair it with one.
Required and optional indicators
<Label required>Full name</Label>
<Label optional>Middle name</Label>Pass requiredIndicator or optionalIndicator to override the default markers
(* and (optional)) — e.g. a localized word: optionalIndicator="(valgfri)".
Indicators are aria-hidden — the control itself carries the semantic, so
screen readers announce "required" exactly once:
<Label htmlFor="pwd" required>Password</Label>
<input id="pwd" type="password" required />Two conventions, both expressible here: mark only required fields with an
asterisk, or follow the majority rule — if most fields are required, mark only
the optional ones with (optional), and vice versa. Pick one per product and
stay consistent. If you mark required fields, explain the asterisk once at the
top of the form ("Required fields are marked with *").
Description
Helper text renders on its own line below the label. It is aria-hidden, so
it is never part of the control's accessible name — a name should read as
a name ("Email address"), not a paragraph. Expose it as a description by
pointing the control at descriptionId:
<Label htmlFor="email" description="We'll never share your email." descriptionId="email-desc">
Email address
</Label>
<input id="email" type="email" aria-describedby="email-desc" />Screen readers then announce: name "Email address", then the description —
the correct order per the accname spec. aria-describedby resolves hidden
referents, so the aria-hidden costs nothing. Without the wiring the
description is visual-only; for a fully-wired field (label + helper + error +
counter) reach for Field, which does this for you.
Disabled
<Label disabled>Unavailable option</Label>disabled visually mutes the label, indicators and description. It does NOT
disable the underlying input — pair it with a disabled control.
Two zero-config paths style the label automatically from the control's own state, no prop threading:
{/* peer: input before label, sibling selector */}
<input id="a" disabled className="peer" />
<Label htmlFor="a">Auto-muted</Label>
{/* group: any wrapper stamped data-disabled */}
<div className="group" data-disabled={isDisabled || undefined}>
<Label htmlFor="b">Auto-muted</Label>
<input id="b" disabled={isDisabled} />
</div>(peer-* only reaches siblings after the input, so for the common
label-above-input layout use the group pattern or the prop.)
Visually hidden
visuallyHidden keeps the label in the accessibility tree but removes it from
view (sr-only) — for controls whose context makes a visible label redundant,
like a lone search field:
<Label htmlFor="q" visuallyHidden>Search</Label>
<input id="q" type="search" placeholder="Search…" />A placeholder is not a label — it disappears on input and isn't announced as a name. Prefer visible labels everywhere else.
Render — span and legend hosts
render swaps the host element (Base UI render prop) for the two cases where
a <label> is wrong:
{/* caption for a non-labelable widget — wire with aria-labelledby */}
<Label render={<span />} id="volume-label">Volume</Label>
<div role="slider" aria-labelledby="volume-label" … />
{/* legend inside a fieldset (group label) */}
<fieldset>
<Label render={<legend />}>Notification channels</Label>
…
</fieldset>htmlFor is dropped on non-label hosts (a for attribute is invalid there)
and native label activation does not transfer. For a checkbox/radio group,
<fieldset> + legend is the correct group-labelling structure.
RTL
Pass dir="rtl" to flip icon and indicator placement. The spacing is logical (ms-*). The "(optional)" text is not translated for you, so pass optionalIndicator.
Skeleton
LabelSkeleton derives its line box from the label's own cva (§2b) — chars
sizes the bar to the real text length, showDescription adds the second line:
<LabelSkeleton size="md" chars={13} showDescription descriptionChars={32} />It is silent by default — a form of eight label skeletons must not fire
eight live regions. Give the one wrapper that owns the loading region the
announcement, or pass label="Loading" here when this skeleton is the
region.
API Reference
Label
| Prop | Type | Default | Description |
|---|---|---|---|
size | "sm" | "md" | "lg" | "md" | Type step: 12 / 14 / 16px |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Validation state. Overrides tone when not none. |
tone | "default" | "muted" | "on-color" | inherited | Base colour tone. Inherited from a wrapping <Surface> ("on-color"), else "default"; an explicit prop wins |
weight | "normal" | "medium" | "semibold" | "normal" | Font weight |
required | boolean | false | Show a required indicator after the label |
optional | boolean | false | Show an "(optional)" indicator (ignored if required) |
requiredIndicator | ReactNode | "*" | Custom required marker |
optionalIndicator | ReactNode | "(optional)" | Custom optional marker |
disabled | boolean | false | Visually mute the label |
leadingIcon | ReactNode | — | Icon before the label text |
trailingIcon | ReactNode | — | Icon after the label text (and after indicators) |
description | ReactNode | — | Helper text below the label. Aria-hidden — wire via descriptionId. |
descriptionId | string | auto | Id on the description element, for the control's aria-describedby |
visuallyHidden | boolean | false | sr-only — hidden visually, present in the accessibility tree |
render | Base UI render prop | <label /> | Swap the host element (<span />, <legend />). Drops htmlFor. |
htmlFor | string | — | Native for attribute — associates the label with a control |
dir | "ltr" | "rtl" | — | Text direction |
className | string | — | Extra classes on the root element |
The component extends all standard <label> HTML attributes.
LabelSkeleton
| Prop | Type | Default | Description |
|---|---|---|---|
size | LabelSize | "md" | Same line box as the real label |
chars | number | 12 | Label text length the bar stands in for |
showDescription | boolean | false | Second bar in the description line box |
descriptionChars | number | 24 | Description text length |
label | string | null | null | Announcement. null = silent (decorative) |
Data attributes
| Attribute | On | Values |
|---|---|---|
data-slot | root | "label" |
data-slot | indicator | "label-indicator" |
data-slot | description | "label-description" |
data-slot | skeleton root | "label-skeleton" |
data-size | root | "xs" | "sm" | "md" | "lg" | "xl" |
data-intent | root | "none" | "error" | "warning" | "success" | "info" |
data-tone | root | "default" | "muted" | "on-color" |
data-disabled | root | present when disabled |
data-required | root | present when the required indicator shows |
data-optional | root | present when the optional indicator shows |
Accessibility
- Pattern: native
<label>— clicking it focuses the control referenced byhtmlFor. No ARIA role is added or needed; there is no keyboard interaction of its own (the label is not focusable; activation is the browser's native behavior). - Name computation: the control's accessible name = label text only.
Indicators and description are
aria-hidden; the description re-enters as an accessible description viadescriptionId→aria-describedby. - Required state belongs on the control (
required/aria-required), not on the label. The indicator is decorative. - Group labels: a standalone
Labeldoes not label a group — use<fieldset>+render={<legend />}(checkbox/radio clusters), oraria-labelledbypointing atrender={<span />}. - Contrast (measured, audit-gated): error 4.77:1 · warning 7.14:1 ·
success 7.13:1 · info 5.20:1 · description 4.74:1 — light theme on
--background; dark theme higher; on-color intents use the adaptive--on-ink-*inks. - AT matrix: axe clean both themes (qa:a11y); accessibility-tree read
verified (name/description separation). VoiceOver + Safari passed 2026-09-14:
the field reads "Password, Must be at least 12 characters and include one
number., required, secure text field" — name from the label, description
from
descriptionId. NVDA: rides the next release pass.