DocumentationComponentsTheme CreatorGitHub
Theme CreatorGitHubIntroduction
InstallationUsageTheming
ComponentsAccordionAction SheetAlertAlertDialogArea ChartAspectRatioAvatarBadgeBannerBar ChartBottom NavBreadcrumbButtonButtonGroupCalendarCardCarouselChartChatBubbleChatBubbleNewCheckboxChipCoachMarkCodeBlockCollapsibleColor PickerComboboxCommandContainerContextMenuDate PickerDividerDrawerDropdown MenuEmptyStateFABFieldFieldsetFile UploadFlexFormGridHoverCardIconButtonLabelLine ChartLinkMenubarMeterModalNavigation MenuNumberFieldOTP InputPaginationPickerPie ChartPopoverProgressPromptInputRadar ChartRadial ChartRadioRatingScroll AreaSearchSegmentedControlSelectShortcutSidebarSkeletonSliderCircularSliderMediaTrimmerSpacerSpinnerSplit ButtonStackStatusStepperSurfaceSwitchTableTabsTextareaTime PickerToastToggleButtonToggleGroupTokenizerToolbarTooltipTop Header DesktopTop Header Mobile
Contributing
Components

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

Usage

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.

SizeText classUse case
xstext-xsDense forms, inline filters
smtext-xsCompact forms
mdtext-smDefault — matches md inputs
lgtext-smLarge forms, settings panels
xltext-baseHero 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.

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

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; 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 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 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

PropTypeDefaultDescription
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
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
IconButtonLine Chart

On this page

PlaygroundInstallationUsageExamplesDefaultSizesIntentsRequired and optionalWith iconWith descriptionWith info tooltipSizesIntentsTonesRequired and optional indicatorsDescriptionDisabledVisually hiddenRender — span and legend hostsRTLSkeletonAccessibilityAPI ReferenceLabelLabelSkeletonData attributes