SegmentedControl
A compact "choose one" row — Light / Dark / Auto. It is built like RadioGroup: a
role="radiogroup" of real <input type="radio"> sharing one name, each input hidden over its segment.
So the browser gives you the behavior — arrow keys that move and select, a single Tab stop on the checked
segment, native form submission and RTL — and the CSS layer carries no JavaScript at all.
Once a segment is picked, one stays picked: a radio cannot be unchecked, so there is no deselect. A row you can switch fully off is a toggle group — use the Toolbar's.
The examples are organized by layer: the class reference is the standalone
@oriui/css layer, and the Framework API is the @oriui/vue component. Every
example is live — flip its code between HTML (the standalone classes, also your htmx / Astro / Svelte /
plain-HTML usage), Vue; HTML is the default.
When to use
| Control | Pick it when |
|---|---|
| SegmentedControl | A short row (two to five) of one-word choices, all visible at once — a mode, a view, a range. |
| RadioGroup | A list of labeled circles of any length, stacked vertically, with room for a longer label. |
| Tabs | Segments that switch panels of content; the selection is navigation, not a setting. |
| Toolbar toggles | A row where nothing may be selected, or several things at once. |
Classes
The control is a block class plus single-class token utilities — one class repoints one token, no base class needed. The Vue props in Framework API map 1:1 to these.
| Class | Type | Description |
|---|---|---|
ori-segmented-control | Block | Required base class on the container; carries role=radiogroup. |
ori-color_* | Color | primary · secondary · success · warning · danger · info · surface — the checked fill, its edge tone and the focus ring. |
ori-segmented-control_* (size) | Size | inherit · xs · sm · md · lg · xl · xxl — the control height, from the action-size scale. |
ori-font-size_* | Font | xs · sm · md · lg · xl · xxl — scales the text; segment padding and gap follow in em. |
ori-size-radius_* | Radius | none · xs · sm · md · lg · xl · full — the track radius; segments sit 3px inside it. |
ori-segmented-control_fluid | Layout | Stretches to the container width; segments stay equal. |
ori-segmented-control__label | Part | Group label element; referenced by aria-labelledby. |
ori-segmented-control__track | Part | The tinted track; an equal-column grid that holds the segments. |
ori-segmented-control__item | Part | Wrapping |
ori-segmented-control__input · ori-segmented-control__icon · ori-segmented-control__text | Part | Hidden native radio laid over the segment / optional leading icon / segment text. |
disabled · checked · aria-required | State | Real attributes on the native input / container, not extra classes. |
À la carte: the classes above ship in @oriui/css/components/segmented-control.css. Import a foundation
(@oriui/css/base.css or @oriui/css/tokens.css) first — the token utilities (ori-color_*,
ori-size-radius_*, …) live there, not in the component file. The full bundle @oriui/css is the default and
already carries both; see à-la-carte imports.
Accent primary, size md and radius md are baked in at zero specificity, so a bare ori-segmented-control
is already complete and one utility class repoints any of them. The checked fill, the focus ring and the
disabled dimming read the input's real state through :has() — Chrome 105, Safari 15.4, Firefox 121 and later.
Anatomy
div.ori-segmented-control [role="radiogroup", aria-labelledby]
div.ori-segmented-control__label ← group heading (optional)
div.ori-segmented-control__track ← tinted track, equal-width grid
label.ori-segmented-control__item ← one per segment
input.ori-segmented-control__input ← visually hidden real radio, laid over the segment
i.ori-segmented-control__icon ← leading icon (optional, aria-hidden)
span.ori-segmented-control__text ← visible segment text
Basic
Equal-width segments, as wide as the widest one, on a tinted track. The checked segment is filled with the accent.
<!-- accent primary, size md and radius md are the baked defaults -->
<div class="ori-segmented-control" role="radiogroup" aria-labelledby="theme-label">
<div id="theme-label" class="ori-segmented-control__label">Theme</div>
<div class="ori-segmented-control__track">
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="theme" value="light" checked />
<span class="ori-segmented-control__text">Light</span>
</label>
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="theme" value="dark" />
<span class="ori-segmented-control__text">Dark</span>
</label>
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="theme" value="auto" />
<span class="ori-segmented-control__text">Auto</span>
</label>
</div>
</div>
Every group on a page needs its own name — it is what makes the radios one group. The component generates
one (useId) when you do not pass name; in plain HTML you write it.
Colors
Every semantic role. The accent paints the checked segment's fill and the focus ring.
The checked segment also gets an edge in the role's text tone (--ori-color-text). That edge is not
decoration: the fill alone does not stand out 3:1 from the track in every role and theme — a warning fill, or
any role in dark mode, measures under 3:1 — so without the edge the checked state would fail WCAG 1.4.11
(non-text contrast). Do not remove it when restyling.
<!-- swap the color: ori-color_primary → _secondary / _success / _warning / _danger / _info / _surface -->
<div class="ori-segmented-control ori-color_success" role="radiogroup" aria-labelledby="mode-label">…</div>
Sizes
xs → xxl. The size sets the control's height from the action-size scale — xs 1.25rem, sm 1.5rem, md
2.75rem, lg 3rem, xl 3.75rem, xxl 4.25rem — and the text scales with it.
<!-- the Vue size prop emits two classes: the height and the text scale. Set both. -->
<div
class="ori-segmented-control ori-segmented-control_lg ori-font-size_lg"
role="radiogroup"
aria-labelledby="range-label"
>
…
</div>
Fluid
fluid stretches the control to its container. The segments stay equal, so they share the width evenly
instead of hugging their text.
<!-- add ori-segmented-control_fluid to the container -->
<div class="ori-segmented-control ori-segmented-control_fluid" role="radiogroup" aria-labelledby="billing-label">
<div id="billing-label" class="ori-segmented-control__label">Billing</div>
<div class="ori-segmented-control__track">
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="billing" value="monthly" />
<span class="ori-segmented-control__text">Monthly</span>
</label>
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="billing" value="yearly" checked />
<span class="ori-segmented-control__text">Yearly</span>
</label>
</div>
</div>
Icons
Give an option an icon — an SVG path, drawn by OriIcon before the label.
<!-- the icon is an OriIcon: a decorative <i> carrying an inline SVG, laid before the text -->
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="theme" value="light" />
<i class="ori-icon ori-icon_inherit ori-segmented-control__icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM11 2h2v3h-2z…" /></svg>
</i>
<span class="ori-segmented-control__text">Light</span>
</label>
The icon is decorative (aria-hidden); the segment is still named by its text. Keep the text — see
Accessibility.
Disabled — whole group
Pass disabled to lock every segment. They dim and the arrows skip them.
<div class="ori-segmented-control" role="radiogroup" aria-labelledby="theme-label">
<div id="theme-label" class="ori-segmented-control__label">Theme (locked)</div>
<div class="ori-segmented-control__track">
<!-- the real disabled attribute on each input drives the dimming -->
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="theme" value="light" disabled />
<span class="ori-segmented-control__text">Light</span>
</label>
<!-- … Dark (checked), Auto -->
</div>
</div>
The dimming reads the real control state, so in plain HTML a <fieldset disabled> around the control dims it
too — no class to add.
Disabled — per segment
Set disabled: true on individual options to lock only those; the arrows step over a locked segment.
<!-- only the locked segment's input gets the disabled attribute -->
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="format" value="pdf" disabled />
<span class="ori-segmented-control__text">PDF (soon)</span>
</label>
Radius
The track radius is the shared ori-size-radius_* token and the segments follow it, three pixels inside. The
component has no radius prop — put the utility on the root, where a fall-through class lands.
<!-- ori-size-radius_*: none · xs · sm · md · lg · xl · full -->
<div class="ori-segmented-control ori-size-radius_full" role="radiogroup" aria-labelledby="pill-label">…</div>
In a Field
Wrap the control in OriField and the field owns the label, the hint and the error
wiring: the control drops its own label, names itself by the field's label (aria-labelledby), joins the
field's hint or error into aria-describedby, and takes the field's required, disabled, size and
invalid. Inside a field the control is always fluid.
Applies to this device only.
Choose an appearance.
<!-- a group cannot be targeted by for/id, so it names itself with aria-labelledby on the field label -->
<div class="ori-field ori-field_fluid ori-font-size_md">
<label id="appearance-label" class="ori-field__label">Appearance</label>
<div
class="ori-segmented-control ori-segmented-control_fluid"
role="radiogroup"
aria-labelledby="appearance-label"
aria-describedby="appearance-hint"
>
…
</div>
<p id="appearance-hint" class="ori-field__hint">Applies to this device only.</p>
</div>
Required and forms
required sets the native required attribute on every input and aria-required="true" on the group. The
inputs are real radios sharing one name, so the checked segment's value submits with the surrounding
<form> and FormData reads it — nothing to wire.
<form>
<div class="ori-segmented-control" role="radiogroup" aria-labelledby="delivery-label" aria-required="true">
<div id="delivery-label" class="ori-segmented-control__label">Delivery</div>
<div class="ori-segmented-control__track">
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="delivery" value="standard" required />
<span class="ori-segmented-control__text">Standard</span>
</label>
<!-- … Express, Pickup -->
</div>
</div>
<button type="submit">Order</button>
</form>
Custom segment content
The selection is plain v-model. The second control fills the #option slot to carry a count beside each
label — the slot replaces the segment's text and is scoped, so this demo is a small Vue component rather than
inline markup.
v-model: auto
<!-- custom segment content goes inside ori-segmented-control__text -->
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="filter" value="unread" />
<span class="ori-segmented-control__text">
Unread
<span style="margin-inline-start: 0.4em; font-variant-numeric: tabular-nums">3</span>
</span>
</label>
Common patterns
A view switcher: a small, pill-shaped, secondary-colored control with icons, named by aria-label because
the surrounding UI already says what it switches.
<div
class="ori-segmented-control ori-segmented-control_sm ori-font-size_sm ori-color_secondary ori-size-radius_full"
role="radiogroup"
aria-label="View"
>
<div class="ori-segmented-control__track">
<label class="ori-segmented-control__item">
<input class="ori-segmented-control__input" type="radio" name="view" value="list" checked />
<i class="ori-icon ori-icon_inherit ori-segmented-control__icon" aria-hidden="true">
<svg viewBox="0 0 24 24"><path d="M3 13h2v-2H3v2z…" /></svg>
</i>
<span class="ori-segmented-control__text">List</span>
</label>
<!-- … Grid -->
</div>
</div>
Accessibility
The accessibility contract holds across every layer — the standalone classes and the Vue component render the same attributes and keyboard behavior.
- The root carries
role="radiogroup"and must be named. Whenlabelis given,aria-labelledbypoints at the rendered label element; without alabel, passaria-label(or your ownaria-labelledby) as an attribute. An unnamed group is announced as just "radio group". - Every segment is a real
<input type="radio">sharing onename(auto-generated viauseIdwhen omitted). The browser natively enforces single-select, arrow-key movement that selects as it moves, one Tab stop for the whole group, form submission and RTL — no JS needed. - The segment's accessible name is its text, wired through the
<label>wrapper. Theiconisaria-hidden, so an icon never names a segment; keep the text. If you fill the#optionslot, the slot content must still read as the segment's name. - Selection is shown by more than the fill: the checked segment also gets an edge in the role's text tone and a shadow, so it clears 3:1 against the track (measured in every skin and both themes).
disabled(group-level or per segment) sets the nativedisabledattribute; disabled segments are skipped by the arrows and dim to 45% opacity.requiredsets the nativerequiredattribute on each input andaria-required="true"on the group.- Inside an OriField the field owns the label, hint and error: the group is named by the
field's label,
aria-describedbycarries the hint or error (aaria-describedbyyou add joins it rather than replacing it), and an error setsaria-invalid="true"on the group. - Focus is drawn on the visible segment, not the hidden input: a 2 px
outlinein the accent color, offset 2 px, on:focus-visible. Safari drops:focus-visiblefrom a radio an arrow key focused, so the component setsdata-ori-keyboardon the group while the keyboard is in use and the ring follows:focusunder it; in plain HTML, set that attribute yourself on keydown and remove it on pointerdown. - A new control starts with nothing checked unless you give it a value, and a user cannot clear a choice —
give
v-modelan initial value when one option is the sensible default.
| Key | Action |
|---|---|
Tab / Shift+Tab | Moves focus into / out of the group — one stop, on the checked segment (the browser picks the entry point if none is checked yet). |
ArrowRight ArrowDown | Checks and focuses the next enabled segment, wrapping at the end (native). In RTL, ArrowLeft is "next" — except in Safari, which keeps the physical arrows. |
ArrowLeft ArrowUp | Checks and focuses the previous enabled segment (native). In RTL, ArrowRight is "previous". |
Space | Checks the focused segment if it is not already checked (native). |
Framework API
The props, events, and slots of the Vue component. The standalone CSS layer has no component API — its surface is the classes above.
Props
| Prop | Type | Default | Description |
|---|---|---|---|
color | ThemeColor | 'primary' | Accent for the checked fill, its edge tone and the focus ring. |
disabled | boolean | false | Disables every segment; per-segment disable is also supported via options[].disabled. |
fluid | boolean | false | Stretches to the container's width; segments stay equal. Always on inside an OriField. |
label | string | — | Visible group label; the group is aria-labelledby this element. Not rendered inside an OriField (the field owns the label). Without it, pass aria-label. |
name | string | — | Shared name for all radio inputs. Auto-generated via useId when omitted. |
options | SegmentedOption[] | [] | Array of { label, value, disabled?, icon? } objects — the full options-array API. |
required | boolean | false | Sets aria-required="true" on the group and required on each input. |
size | ActionSize | 'md' | Control height from the action-size scale and the text scale (xs–xxl). Inside an OriField, the field's size wins. |
modelValue | string | number | — | The checked segment's value; bind with v-model. |
SegmentedOption is exported — import type { SegmentedOption } from '@oriui/vue':
import type { SegmentedOption } from '@oriui/vue'
const options: SegmentedOption[] = [
{ label: 'List', value: 'list', icon: listPath },
{ label: 'Grid', value: 'grid', icon: gridPath },
{ label: 'Map', value: 'map', disabled: true }
]
value is string | number and the model keeps the type you gave it. icon is an SVG path drawn before the
label.
Events & attributes
v-model is the public state binding — modelValue holds the checked value, and the component emits
update:modelValue when the user picks another segment. Without a v-model it keeps its own state.
The component sets inheritAttrs: false and binds the attributes itself, to the root
div.ori-segmented-control and not to the inputs, so class, style, aria-* and data-* land on the
group. An unlabeled group passes aria-label this way:
<OriSegmentedControl v-model="view" aria-label="View" :options="viewOptions" />
Slots
| Slot | Props | Description |
|---|---|---|
option | { option } | Scoped, rendered once per segment inside ori-segmented-control__text to replace its text — a count, a badge, rich markup. option is the { label, value, … } entry; falls back to option.label. The icon is drawn separately, before it. |