oriUI

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

ControlPick it when
SegmentedControlA short row (two to five) of one-word choices, all visible at once — a mode, a view, a range.
RadioGroupA list of labeled circles of any length, stacked vertically, with room for a longer label.
TabsSegments that switch panels of content; the selection is navigation, not a setting.
Toolbar togglesA 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.

ClassTypeDescription
ori-segmented-controlBlockRequired base class on the container; carries role=radiogroup.
ori-color_*Colorprimary · secondary · success · warning · danger · info · surface — the checked fill, its edge tone and the focus ring.
ori-segmented-control_* (size)Sizeinherit · xs · sm · md · lg · xl · xxl — the control height, from the action-size scale.
ori-font-size_*Fontxs · sm · md · lg · xl · xxl — scales the text; segment padding and gap follow in em.
ori-size-radius_*Radiusnone · xs · sm · md · lg · xl · full — the track radius; segments sit 3px inside it.
ori-segmented-control_fluidLayoutStretches to the container width; segments stay equal.
ori-segmented-control__labelPartGroup label element; referenced by aria-labelledby.
ori-segmented-control__trackPartThe tinted track; an equal-column grid that holds the segments.
ori-segmented-control__itemPartWrapping
ori-segmented-control__input · ori-segmented-control__icon · ori-segmented-control__textPartHidden native radio laid over the segment / optional leading icon / segment text.
disabled · checked · aria-requiredStateReal 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

text
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.

Theme
html
<!-- 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>
vue
<OriSegmentedControl
    v-model="theme"
    label="Theme"
    :options="[
        { label: 'Light', value: 'light' },
        { label: 'Dark', value: 'dark' },
        { label: 'Auto', value: 'auto' }
    ]"
/>

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.

Primary
Secondary
Success
Warning
Danger
Info
Surface
html
<!-- 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>
vue
<OriSegmentedControl v-model="val" label="Primary" color="primary" :options="opts" />
<OriSegmentedControl v-model="val" label="Success" color="success" :options="opts" />
<OriSegmentedControl v-model="val" label="Warning" color="warning" :options="opts" />
<OriSegmentedControl v-model="val" label="Danger" color="danger" :options="opts" />

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.

xs
sm
md (default)
lg
xl
xxl
html
<!-- 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>
vue
<OriSegmentedControl v-model="range" label="xs" size="xs" :options="ranges" />
<OriSegmentedControl v-model="range" label="md (default)" size="md" :options="ranges" />
<OriSegmentedControl v-model="range" label="xxl" size="xxl" :options="ranges" />

Fluid

fluid stretches the control to its container. The segments stay equal, so they share the width evenly instead of hugging their text.

Billing
html
<!-- 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>
vue
<OriSegmentedControl
    v-model="billing"
    label="Billing"
    fluid
    :options="[
        { label: 'Monthly', value: 'monthly' },
        { label: 'Yearly', value: 'yearly' }
    ]"
/>

Icons

Give an option an icon — an SVG path, drawn by OriIcon before the label.

Theme
html
<!-- 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>
vue
<script setup lang="ts">
import type { SegmentedOption } from '@oriui/vue'

const sun = 'M12 8a4 4 0 1 0 0 8 4 4 0 0 0 0-8zM11 2h2v3h-2zm0 17h2v3h-2zM2 11h3v2H2zm17 0h3v2h-3z'
const moon =
    'M12 3a9 9 0 1 0 9 9c0-.46-.04-.92-.1-1.36a5.389 5.389 0 0 1-4.4 2.26 5.403 5.403 0 0 1-3.14-9.8c-.44-.06-.9-.1-1.36-.1z'
const auto = 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18V4c4.41 0 8 3.59 8 8s-3.59 8-8 8z'

const options: SegmentedOption[] = [
    { label: 'Light', value: 'light', icon: sun },
    { label: 'Dark', value: 'dark', icon: moon },
    { label: 'Auto', value: 'auto', icon: auto }
]
</script>

<template>
    <OriSegmentedControl v-model="theme" label="Theme" :options="options" />
</template>

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.

Theme (locked)
html
<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>
vue
<OriSegmentedControl v-model="theme" label="Theme (locked)" disabled :options="themes" />

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.

Export as
html
<!-- 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>
vue
<OriSegmentedControl
    v-model="format"
    label="Export as"
    :options="[
        { label: 'CSV', value: 'csv' },
        { label: 'JSON', value: 'json' },
        { label: 'PDF (soon)', value: 'pdf', disabled: true }
    ]"
/>

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.

Square
Pill
html
<!-- 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>
vue
<OriSegmentedControl v-model="val" label="Square" class="ori-size-radius_none" :options="opts" />
<OriSegmentedControl v-model="val" label="Pill" class="ori-size-radius_full" :options="opts" />

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.

html
<!-- 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>
vue
<OriField label="Appearance" hint="Applies to this device only.">
    <OriSegmentedControl v-model="theme" :options="themes" />
</OriField>

<OriField label="Appearance" required error="Choose an appearance.">
    <OriSegmentedControl v-model="theme" :options="themes" />
</OriField>

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.

Delivery
html
<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>
vue
<form @submit.prevent="send">
    <OriSegmentedControl
        v-model="delivery"
        label="Delivery"
        name="delivery"
        required
        :options="deliveryOptions"
    />
    <OriButton type="submit" label="Order" />
</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.

Theme

v-model: auto

Show
html
<!-- 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>
vue
<script setup lang="ts">
import { ref } from 'vue'

const theme = ref('auto')
const themes = [
    { label: 'Light', value: 'light' },
    { label: 'Dark', value: 'dark' },
    { label: 'Auto', value: 'auto' }
]

const filter = ref('all')
const filters = [
    { label: 'All', value: 'all' },
    { label: 'Unread', value: 'unread' },
    { label: 'Flagged', value: 'flagged' }
]
const counts: Record<string, number> = { all: 24, unread: 3, flagged: 1 }
</script>

<template>
    <OriSegmentedControl v-model="theme" label="Theme" :options="themes" />
    <p>v-model: {{ theme }}</p>

    <OriSegmentedControl v-model="filter" label="Show" :options="filters">
        <template #option="{ option }">
            {{ option.label }}
            <span style="margin-inline-start: 0.4em; font-variant-numeric: tabular-nums">{{
                counts[option.value]
            }}</span>
        </template>
    </OriSegmentedControl>
</template>

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.

html
<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>
vue
<OriSegmentedControl
    v-model="view"
    aria-label="View"
    size="sm"
    color="secondary"
    class="ori-size-radius_full"
    :options="[
        { label: 'List', value: 'list', icon: listPath },
        { label: 'Grid', value: 'grid', icon: gridPath }
    ]"
/>

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. When label is given, aria-labelledby points at the rendered label element; without a label, pass aria-label (or your own aria-labelledby) as an attribute. An unnamed group is announced as just "radio group".
  • Every segment is a real <input type="radio"> sharing one name (auto-generated via useId when 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. The icon is aria-hidden, so an icon never names a segment; keep the text. If you fill the #option slot, 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 native disabled attribute; disabled segments are skipped by the arrows and dim to 45% opacity.
  • required sets the native required attribute on each input and aria-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-describedby carries the hint or error (a aria-describedby you add joins it rather than replacing it), and an error sets aria-invalid="true" on the group.
  • Focus is drawn on the visible segment, not the hidden input: a 2 px outline in the accent color, offset 2 px, on :focus-visible. Safari drops :focus-visible from a radio an arrow key focused, so the component sets data-ori-keyboard on the group while the keyboard is in use and the ring follows :focus under 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-model an initial value when one option is the sensible default.
KeyAction
Tab / Shift+TabMoves focus into / out of the group — one stop, on the checked segment (the browser picks the entry point if none is checked yet).
ArrowRight ArrowDownChecks 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 ArrowUpChecks and focuses the previous enabled segment (native). In RTL, ArrowRight is "previous".
SpaceChecks 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

PropTypeDefaultDescription
colorThemeColor'primary'Accent for the checked fill, its edge tone and the focus ring.
disabledbooleanfalseDisables every segment; per-segment disable is also supported via options[].disabled.
fluidbooleanfalseStretches to the container's width; segments stay equal. Always on inside an OriField.
labelstring—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.
namestring—Shared name for all radio inputs. Auto-generated via useId when omitted.
optionsSegmentedOption[][]Array of { label, value, disabled?, icon? } objects — the full options-array API.
requiredbooleanfalseSets aria-required="true" on the group and required on each input.
sizeActionSize'md'Control height from the action-size scale and the text scale (xs–xxl). Inside an OriField, the field's size wins.
modelValuestring | number—The checked segment's value; bind with v-model.

SegmentedOption is exported — import type { SegmentedOption } from '@oriui/vue':

ts
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:

vue
<OriSegmentedControl v-model="view" aria-label="View" :options="viewOptions" />

Slots

SlotPropsDescription
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.