Skip to main content

Choice

A reusable indicator-left / content-right layout for a single choice: a control (checkbox, radio, switch) beside an emphasized title and an optional de-emphasized description. Use it to pair a Checkbox or RadioGroup option with a rich, titled label, or reach for it directly to build your own choice-like rows.

Choice is a pure layout + association primitive. It owns the control's id and the description's id and wires them together, but it does not own the control's value: you drop your own control into Choice.Indicator. Pick Choice.Label (a real <label>) when the Choice owns the labeling, or Choice.Title (label-less text) when an ancestor already does.

Get notified by email when a deploy finishes.

import { Checkbox } from "@ngrok/mantle/checkbox";import { Choice } from "@ngrok/mantle/choice"; <Choice.Root name="notify-email">	<Choice.Indicator>		<Checkbox defaultChecked />	</Choice.Indicator>	<Choice.Content>		<Choice.Label>Email</Choice.Label>		<Choice.Description>Get notified by email when a deploy finishes.</Choice.Description>	</Choice.Content></Choice.Root>;

When to use

  • A checkbox, radio, or switch that needs an emphasized title with a supplementary description beneath it.
  • A fully-clickable row (e.g. a list item) where a control sits beside titled text.
  • Anywhere you'd otherwise hand-roll the "control + label + helper text" flexbox and htmlFor / aria-describedby wiring.

When not to use

  • Any layout that isn't a labeled checkbox or radio. Choice is specifically for choice-like rows: a checkbox or radio input with an associated label and optional description. For general content layout (icon + text, thumbnails, etc.), use MediaObject or a plain flex / grid.
  • A bare control with a single-line caption: use Label with the control directly.
  • A field row with no focusable control (a read-only value): use Field.LabelText.

Inside a Field

Drop Choice.Root inside a Field.Control and the field's id / name / aria-invalid and description/error aria-describedby flow onto the control automatically: the field's aria-describedby is merged with the Choice's own description rather than replaced. Because the control adopts the field's id, Choice.Label's htmlFor resolves to that same control, so the title stays a real, clickable label (clicking Email below toggles the checkbox), sharing the control with Field.Label. Reach for Choice.Title only when you specifically want the title to not be a label (e.g. a clickable row that owns the click itself).

Sent to your primary address.

Choose how we reach you.

import { Checkbox } from "@ngrok/mantle/checkbox";import { Choice } from "@ngrok/mantle/choice";import { Field } from "@ngrok/mantle/field"; <Field.Item name="notify">	<Field.Label>Notifications</Field.Label>	<Field.Control>		<Choice.Root>			<Choice.Indicator>				<Checkbox />			</Choice.Indicator>			<Choice.Content>				<Choice.Label>Email</Choice.Label>				<Choice.Description>Sent to your primary address.</Choice.Description>			</Choice.Content>		</Choice.Root>	</Field.Control>	<Field.Description>Choose how we reach you.</Field.Description></Field.Item>;

Choice.Label vs Choice.Title

Both render the emphasized title with identical typography; they differ in who labels the control:

  • Choice.Label renders a real <label htmlFor>: the title is the control's accessible name, and clicking it toggles the control. Use it for a standalone control (no Field.Label, no clickable row).
  • Choice.Title renders label-less text (a <p>): use it when an ancestor already owns the labeling and click target, such as a fully-clickable row or a Headless radio item. This avoids nesting two <label>s or splitting the click target. (Inside a Field you usually still want Choice.Label, since it merges with the field's id and stays clickable.)

The example below is a fully-clickable row: the row's own <label> (sharing the control's id) owns the click, so the title is a Choice.Title.

// The row's <label> (sharing the control id) owns the click, so the title is text.<label htmlFor="onboarding-key" className="border-form flex cursor-pointer rounded-md border p-3">	<Choice.Root id="onboarding-key">		<Choice.Indicator>			<Checkbox defaultChecked />		</Choice.Indicator>		<Choice.Content>			<Choice.Title>Onboarding Key</Choice.Title>			<Choice.Description translate="no">ng-3FaThZL***8xiA</Choice.Description>		</Choice.Content>	</Choice.Root></label>

Composition

Choice.Root├── Choice.Indicator└── Choice.Content    ├── Choice.Label        (or Choice.Title — pick one)    └── Choice.Description

Polymorphism

Choice.Content, Choice.Title, and Choice.Description each accept asChild to render as your own element while keeping the styling and wiring. For example, render the description as a <div> so it can contain block content. Choice.Label is the mantle Label (it always renders a real <label>, like Field.Label); reach for Choice.Title (which accepts asChild) when you need a different element. Choice.Indicator is itself a slot: it clones whatever single control element you place inside it, injecting the shared id / name / aria-* / disabled.

Supports rich content when rendered as a div.
import { Checkbox } from "@ngrok/mantle/checkbox";import { Choice } from "@ngrok/mantle/choice"; <Choice.Root name="notify-rich">	<Choice.Indicator>		<Checkbox defaultChecked />	</Choice.Indicator>	<Choice.Content>		<Choice.Label>Email</Choice.Label>		<Choice.Description asChild>			<div>				Supports <a href="/docs">rich content</a> when rendered as a div.			</div>		</Choice.Description>	</Choice.Content></Choice.Root>;

Accessibility

  • The control placed in Choice.Indicator receives the generated id; Choice.Label's htmlFor and the description's aria-describedby resolve to it without manual wiring.
  • Choice.Description associates via aria-describedby only; it is never a second label. A click on it forwards to Choice.Label, so the whole content column toggles the control while the accessible name stays the label alone.
  • Use exactly one of Choice.Label / Choice.Title per choice. Choice.Title is label-less text for when an ancestor (clickable row, radio item, or Field.Label) carries the accessible name, so labels never nest.
  • Inside a RadioGroup item, Choice.Title and Choice.Description register their ids with the radio. The option's aria-labelledby names it by the title alone, and its aria-describedby points at the description.
  • Inside a Field, the control adopts the field's id and its aria-describedby is merged (never duplicated) with the field's description and error ids.

API Reference

Choice.Root

The layout and id/association owner. Renders a <div>. When composed inside a Field.Control, the field's id / name / aria-* take precedence over these props.

All props from div (except id, typed below), plus:

PropTypeDefaultDescription
disabled?booleanfalseMarks the choice disabled — dims the title/description and is injected onto the control.
id?string—Explicit control id. Defaults to a generated id, or the surrounding Field's id when composed in one.
name?string—Form value name injected onto the control. Inherited from Field when composed within one.

Choice.Indicator

The left control slot. Renders a one-line-tall (h-lh) <span> and clones its single element child with the shared id / name / aria-describedby / aria-invalid / aria-errormessage / disabled.

All props from span.

Choice.Content

The right column that stacks the title over the description. Renders a <div>.

All props from div, plus:

PropTypeDefaultDescription
asChild?booleanfalseRender the content column as your own element instead of a <div>.

Choice.Label

The title as a real <label htmlFor> wired to the control. Use when the Choice owns the labeling. This is the mantle Label (not a re-implementation), so it keeps all of its styling and behavior — mirroring how Field.Label reuses Label.

All props from Label / label, except htmlFor and disabled — those are owned by Choice.Root (the control id and the choice's disabled state).

Choice.Title

The title as label-less text. Renders a <p>. Use when an ancestor (clickable row, radio item, or Field.Label) owns the control's labeling. Inside a RadioGroup item, it names the radio through aria-labelledby; the id defaults to a generated one.

All props from p, plus:

PropTypeDefaultDescription
asChild?booleanfalseRender the title as your own element instead of a <p>.

Choice.Description

The supplementary description, wired to the control via aria-describedby. Renders a <p>. A click on it forwards to the sibling Choice.Label, so the description extends the label's click target. The forward skips a click on a link or other interactive content inside the description. After it forwards, it marks the click handled with preventDefault(), so a click-to-activate ancestor (a SelectableList row) does not toggle the control a second time. When the choice uses Choice.Title, the description forwards nothing, because an ancestor owns that click. Inside a RadioGroup item, it describes the radio through aria-describedby.

All props from p except id, which Choice.Root owns so the control's aria-describedby always resolves, plus:

PropTypeDefaultDescription
asChild?booleanfalseRender the description as your own element instead of a <p>.