DocumentationComponentsTheme Creator
Theme CreatorGitHubIntroduction
InstallationUsageThemingMotion
Components
ButtonIconButtonLinkLabelBadgeStatusChipAvatarShortcutSpinnerSkeletonProgressMeter
AI agentsContributing
  1. Components
  2. Label

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/ui

When to use

  • Every form control that takes input. Pair a visible Label with the control through htmlFor, 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 required or optional, 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. description renders under the label and, wired through descriptionId to the control's aria-describedby, is read after the name.
  • A caption for a group or a non-labelable widget. Use render={<legend />} inside a <fieldset>, or render={<span />} with aria-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 Label does not name a group; use Fieldset, or the legend host 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 in description.
  • To hold a paragraph. The label is the name, not the explanation; put longer text in the description or in the page.

Installation

Terminal
pnpm add @tessinaui/ui

Usage

example.tsx
import { Label } from "@tessinaui/ui";
example.tsx
<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.

example.tsx
<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.

SizeTextIconsUse
sm12px14pxDense forms; beside xs–md Field-family controls
md14px16pxDefault; beside lg–xl Field-family controls
lg16px20pxLarger 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.

IntentTokenTypical use
noneinherits toneDefault
errortext-errorField failed validation
warningtext-warning-tinted-foregroundField needs attention
successtext-success-tinted-foregroundField passed validation
infotext-infoInformational 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".

ToneTokenUse case
defaulttext-foregroundStandard on light/background surfaces
mutedtext-muted-foregroundDe-emphasised fields (e.g. optional)
on-colortext-currentInside 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

example.tsx
<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:

example.tsx
<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:

example.tsx
<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

example.tsx
<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:

example.tsx
{/* 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:

example.tsx
<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:

example.tsx
{/* 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:

example.tsx
<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

PropTypeDefaultDescription
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"inheritedBase colour tone. Inherited from a wrapping <Surface> ("on-color"), else "default"; an explicit prop wins
weight"normal" | "medium" | "semibold""normal"Font weight
requiredbooleanfalseShow a required indicator after the label
optionalbooleanfalseShow an "(optional)" indicator (ignored if required)
requiredIndicatorReactNode"*"Custom required marker
optionalIndicatorReactNode"(optional)"Custom optional marker
disabledbooleanfalseVisually mute the label
leadingIconReactNode—Icon before the label text
trailingIconReactNode—Icon after the label text (and after indicators)
descriptionReactNode—Helper text below the label. Aria-hidden — wire via descriptionId.
descriptionIdstringautoId on the description element, for the control's aria-describedby
visuallyHiddenbooleanfalsesr-only — hidden visually, present in the accessibility tree
renderBase UI render prop<label />Swap the host element (<span />, <legend />). Drops htmlFor.
htmlForstring—Native for attribute — associates the label with a control
dir"ltr" | "rtl"—Text direction
classNamestring—Extra classes on the root element

The component extends all standard <label> HTML attributes.

LabelSkeleton

PropTypeDefaultDescription
sizeLabelSize"md"Same line box as the real label
charsnumber12Label text length the bar stands in for
showDescriptionbooleanfalseSecond bar in the description line box
descriptionCharsnumber24Description text length
labelstring | nullnullAnnouncement. null = silent (decorative)

Data attributes

AttributeOnValues
data-slotroot"label"
data-slotindicator"label-indicator"
data-slotdescription"label-description"
data-slotskeleton root"label-skeleton"
data-sizeroot"xs" | "sm" | "md" | "lg" | "xl"
data-intentroot"none" | "error" | "warning" | "success" | "info"
data-toneroot"default" | "muted" | "on-color"
data-disabledrootpresent when disabled
data-requiredrootpresent when the required indicator shows
data-optionalrootpresent when the optional indicator shows

Accessibility

  • Pattern: native <label> — clicking it focuses the control referenced by htmlFor. 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 via descriptionId → aria-describedby.
  • Required state belongs on the control (required / aria-required), not on the label. The indicator is decorative.
  • Group labels: a standalone Label does not label a group — use <fieldset> + render={<legend />} (checkbox/radio clusters), or aria-labelledby pointing at render={<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.
LinkBadge

On this page

When to useWhen not to useInstallationUsageExamplesDefaultSizesIntentsRequired and optionalWith iconWith descriptionWith info tooltipSizesIntentsTonesOn colorRequired and optional indicatorsDescriptionDisabledVisually hiddenRender — span and legend hostsRTLSkeletonAPI ReferenceLabelLabelSkeletonData attributesAccessibility