Label
A foundational form label primitive. Five 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.
Playground
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
Five size scales — xs, sm, md, lg, and xl — matching Button and Field sizing.
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 Carbon bans it and Fluent's
InfoLabel keeps its button 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 (Astryx ships this shape). 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
Matches Button / Field sizing so labels line up with adjacent inputs.
| Size | Text class | Use case |
|---|---|---|
xs | text-xs | Dense forms, inline filters |
sm | text-xs | Compact forms |
md | text-sm | Default — matches md inputs |
lg | text-sm | Large forms, settings panels |
xl | text-base | Hero inputs, auth flows |
Long labels wrap — they never truncate. Every reference system agrees
(Material, Fluent, HIG): 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-hover | Field passed validation |
info | text-info | Informational hint |
Warning and success deliberately do not paint their raw tokens: raw amber
is 2.1:1 on white and raw green 3.9:1 — below the AA 4.5:1 text floor. The
inks above measure 7.14:1 and 4.95: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; intents switch to the adaptive --on-ink-* variables |
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 from the reference systems, both expressible here: mark only
required fields with an asterisk (Atlassian/Fluent), or follow the majority
rule — if most fields are required, mark only the optional ones with
(optional), and vice versa (Carbon). 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 alignment via logical spacing (ms-*).
<Label dir="rtl" required leadingIcon={<Mail />}>
البريد الإلكتروني
</Label>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.
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 4.95: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 smoke: pending (§5.6); NVDA: rides the next release pass. Status badge stays beta until both are on record.
API Reference
Label
| Prop | Type | Default | Description |
|---|---|---|---|
size | "xs" | "sm" | "md" | "lg" | "xl" | "md" | Size scale |
intent | "none" | "error" | "warning" | "success" | "info" | "none" | Validation state. Overrides tone when not none. |
tone | "default" | "muted" | "on-color" | "default" | Base colour tone |
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 |