The modern radio group — a sliding thumb, real radio semantics, and automatic light/dark. Every card below is live; try the arrow keys.
Array shorthand; arrows move and select.
new Segmented('#basic', {
options: ['List', 'Board', 'Calendar'],
onChange: v => render(v)
})
Icons ride next to labels; omit the label (it becomes the
aria-label) for icon-only.
{ value: 'grid', label: 'Grid', icon: SVG }
{ value: 'left', icon: SVG,
label: 'Align left', iconOnly: true }
fullWidth stretches segments evenly; the thumb
re-measures on resize.
new Segmented('#full', {
options: ['Day', 'Week', 'Month', 'Year'],
value: 'Week', fullWidth: true
})
Plain markup in, control out — destroy() gives the
original buttons back.
<div id="enhance">
<button data-value="dev">Dev</button> …
</div>
new Segmented('#enhance')
name renders a hidden input, so the value submits
like any field.
new Segmented('#plan', {
options: ['Free', 'Pro', 'Team'],
name: 'plan'
})
Disabled segments are skipped by clicks and arrows; the whole
control can be toggled too. size: 'sm' shrinks it.
options: [{ value: '4k', label: '4K',
disabled: true }, …]
size: 'sm'
new Segmented(target, options?) — target is a CSS
selector string or an element. Creating a second instance on the same element
destroys the first. Without a DOM (SSR), the constructor returns an inert
no-op instance instead of throwing.
Build into a container — pass options and the
control is rendered from scratch:
<div id="view"></div>
<script>
var seg = new Segmented('#view', {
options: ['List', 'Board', 'Calendar'], // shorthand: value = label
onChange: function (value, seg) { console.log(value) }
})
</script>
Enhance existing buttons — omit options and each
direct <button> child becomes a segment: label from its text,
value from data-value (falling back to the text), and
disabled respected. destroy() restores the original
children and attributes exactly as they were.
<div id="env">
<button data-value="dev">Dev</button>
<button data-value="staging">Staging</button>
<button disabled>Timeline</button>
</div>
<script>new Segmented('#env')</script>
Zero JS — add data-vsg to the container and it
is initialized automatically (see
Declarative init). Static helpers:
Segmented.create(el, opts) is the same as
new Segmented(...), and Segmented.get(el) returns the
live instance for an element (or null).
Entries in the options array are either primitives
(shorthand — the value doubles as the label) or objects:
| Property | Type | Description |
|---|---|---|
value | any | The option's value. Defaults to label when omitted. |
label | string | Visible text (always rendered as text, never HTML). Defaults to String(value). |
icon | string | Trusted SVG string rendered before the label (same trust model as Toast's built-in icons) — never pass user-generated content. |
iconOnly | boolean | Render the icon without visible text; the label/value becomes the segment's aria-label. Also implied by giving an icon with no label. |
disabled | boolean | Segment can't be selected; skipped by clicks and keyboard navigation. |
All constructor options with their defaults (Segmented.defaults,
mutable to change them globally):
| Option | Type | Default | Description |
|---|---|---|---|
options | array | null | Array of option objects (shape above) or ['a', 'b'] shorthand. When omitted or empty, enhance mode reads the container's <button> children instead; if neither yields options, the constructor throws. |
value | any | undefined | Initial value. When missing, unmatched, or pointing at a disabled option, the first enabled option is selected. Matching is exact first, then string-loose ('2' matches 2). |
name | string | null | Renders a hidden <input type="hidden"> with this name that always carries the current value, so the control submits in forms. |
size | string | 'md' | 'sm' | 'md'. 'sm' adds the vsg-sm class (tighter padding, 13px text). |
fullWidth | boolean | false | Stretch segments evenly across the container (vsg-full). |
label | string | null | aria-label for the radiogroup. An existing aria-label/aria-labelledby on the element is never clobbered; with none at all, labels.group is used. |
theme | string | 'auto' | 'auto' | 'light' | 'dark'. 'auto' follows the page and re-resolves live (see Theming). |
styles | boolean | true | false = headless: no CSS is injected; you style the .vsg-* markup contract yourself. |
onChange | function | null | fn(value, instance) — called when the selection changes (see Events). |
labels | object | { group: 'Options' } | Overridable UI strings. labels.group is the fallback accessible name for the radiogroup when no label option is given and the element has no aria-label/aria-labelledby of its own. |
There are no per-call-only constructor options beyond these. The one
method-level option is setValue(value, { silent: true }), covered
below.
All methods except getValue() return the instance, so calls
chain. Useful instance properties: el (the container),
options (normalized option objects), index (selected
index, -1 if none), segs (segment buttons), and
input (the hidden input, or null).
| Method | Returns | Description |
|---|---|---|
getValue() | any | null | The currently selected value, or null when nothing is selected (e.g. every option disabled). |
setValue(value, opts?) | instance | Select the option with this value (exact match first, then string-loose). Fires onChange and segmented:change when the selection actually changes — pass { silent: true } to suppress both. Unknown values and disabled options are no-ops. |
enable() | instance | Re-enable the whole control after disable(). Per-option disabled state survives; the hidden input resumes submitting. |
disable() | instance | Disable the whole control: adds vsg-disabled and aria-disabled="true", disables every segment button, and disables the hidden input so it stops submitting. |
update(options) | instance | Replace the option set. The current value is kept when it still exists and is enabled; otherwise selection falls back to the first enabled option. Never fires onChange. Whole-control disabled state is preserved. |
destroy() | instance | Unbind all listeners, remove everything the control built, restore the original children, every touched attribute, and drop the added classes — the element ends up exactly as it started. Safe to call twice. |
| Static | Type | Description |
|---|---|---|
Segmented.create(target, opts) | function | Same as new Segmented(target, opts). |
Segmented.get(target) | function | The live instance for a selector/element, or null. |
Segmented.autoInit(root?) | function | Initialize every [data-vsg] element under root (default: document); returns the array of created instances. Already-initialized elements are skipped; one bad container logs an error without aborting the rest. |
Segmented.defaults | object | The live defaults object (table above). Mutate before constructing to change defaults globally, e.g. Segmented.defaults.styles = false. |
Segmented.version | string | '1.0.0'. |
Segmented.salt | string | false | CSS isolation token, default 'vc1'. Set to your own token or false before the first instance (see Theming). |
Segmented.css | string | The full stylesheet, always rendered with the current salt (a live getter). Starting point for headless styling. |
Segmented.displayName | string | 'Segmented' — part of the VC family convergence contract. |
Segmented.rootClass | string | 'vsg' — the root class the control renders with. |
Segmented.themeVars | object | Map of family theme keys to CSS custom properties: { accent: '--vsg-accent', radius: '--vsg-radius', font: '--vsg-font' } — used by VC.config(). |
Segmented.varScopes | array | ['.vsg', '.vsg[data-theme=dark]'] — the (deliberately unsalted) scopes where theme variables are defined; VC.config() writes its overrides there. |
Two ways to hear about selection changes; both fire only when the selection
actually changes, and neither fires for the initial selection,
setValue(v, { silent: true }), or update().
| Hook | Kind | Arguments / detail | Notes |
|---|---|---|---|
onChange | option callback | (value, instance) | Called before the DOM event is dispatched. |
segmented:change | CustomEvent | detail: { value, segmented } — the new value and the instance | Dispatched from the container element with bubbles: true, so you can listen on any ancestor (or document). |
document.addEventListener('segmented:change', function (e) {
console.log(e.detail.value, e.detail.segmented)
})
No native change/input events are dispatched on the
hidden form input; its value is simply kept in sync.
Any element with data-vsg is auto-initialized on
DOMContentLoaded (or immediately, if the script loads after
parsing). Call Segmented.autoInit(root?) yourself for content added
later. Options come from data attributes on the container; segments come from
its <button> children (value from data-value on
each button, label from its text, disabled respected):
<div data-vsg data-name="view" data-size="sm" data-full-width="true"> <button data-value="list">List</button> <button data-value="board">Board</button> </div>
| Attribute | Maps to | Value semantics |
|---|---|---|
data-vsg | — | Marker: opts the container into autoInit(). |
data-value | value | Initial value (string; matched loosely against option values). Ignored when empty. |
data-name | name | Hidden form input name. |
data-size | size | 'sm' | 'md'. |
data-full-width | fullWidth | Boolean: "false" and "0" are false, any other value (including empty) is true. |
data-label | label | Accessible name for the radiogroup. |
data-theme | theme | 'auto' | 'light' | 'dark'. |
data-styles | styles | Boolean, same parsing as data-full-width; "false" = headless. |
With theme: 'auto' (the default) the control resolves its theme
in the family's order: <html data-theme> /
data-bs-theme → a .dark/.light class on
<html> → prefers-color-scheme — re-resolved live
when any of those change (via MutationObserver and a media-query
listener, or the shared VC theme engine when the core is loaded). Pin per
instance with theme: 'light' or 'dark'. The resolved
theme is stamped on the container as data-theme.
All colors are CSS custom properties, defined on .vsg (light)
and .vsg[data-theme=dark] (dark):
| Property | Light default | Dark default | Used for |
|---|---|---|---|
--vsg-accent | #5b5bd6 | #7b7bea | Checked label color, focus ring. |
--vsg-bg | #ffffff | #1b1d24 | The sliding thumb. |
--vsg-text | #1c1d21 | #e9eaf0 | Text / hover color. |
--vsg-muted | #72747e | #989aa6 | Unchecked label color. |
--vsg-faint | #e7e7ec | #31343f | The pill track background. |
--vsg-shadow | subtle drop shadow | darker shadow | Thumb shadow. |
--vsg-radius | 10px | — | Track radius (segments/thumb derive theirs from it). |
--vsg-font | system-ui stack | — | Font family. |
.vsg { --vsg-accent: #b45309; --vsg-radius: 8px; }
With the VC core loaded, VC.config({ accent: '#b45309' }) themes
this and every other family component in one call.
CSS isolation: the control renders as
class="vsg vc1" and all structural rules ship salted
(.vsg.vc1 .vsg-seg { … }) so host-page design systems can't
override it — while the --vsg-* variable definitions are
deliberately unsalted, so overrides like the one above keep working. The
injected stylesheet is inserted before the page's own CSS so your
overrides win the cascade. Custom token: Segmented.salt = 'acme'
before the first instance; disable salting with
Segmented.salt = false.
Headless: Segmented.defaults.styles = false (or
per instance { styles: false }) injects no CSS while keeping the
full behavior — thumb measurement, roving tabindex, ARIA, hidden input. Use
Segmented.css (string) or dist/segmented.css (file) as
a starting point, and style this markup contract:
.vsg[.vsg-sm][.vsg-full][.vsg-disabled][data-theme=dark] role=radiogroup
.vsg-thumb[.vsg-thumb-on] ← JS sets transform/width/height
.vsg-seg[aria-checked] ← role=radio button, data-value
.vsg-icon ← only when `icon` given
.vsg-label ← omitted when icon-only
input[type=hidden] ← only when `name` given
The control is a role="radiogroup" whose segments are
role="radio" buttons with aria-checked, using a roving
tabindex — the whole control is one Tab stop, and the checked segment is the
one that receives focus. The group gets an accessible name from the
label option; an author-provided aria-label /
aria-labelledby is never clobbered. Icon-only segments carry an
aria-label from their label/value, and icon spans plus the thumb
are aria-hidden="true". disable() sets
aria-disabled="true" on the group. The thumb re-measures on window
resize and animates only after the first placement;
prefers-reduced-motion disables the animation entirely.
| Key | Action |
|---|---|
| → / ↓ | Move focus to the next enabled segment and select it (wraps; disabled segments are skipped). |
| ← / ↑ | Move focus to the previous enabled segment and select it (wraps). |
| Home | Jump to the first enabled segment and select it. |
| End | Jump to the last enabled segment and select it. |
| Space / Enter | Select the focused segment (native button activation). |
| Tab | Leave the control — it occupies a single Tab stop. |
Key presses with Alt, Ctrl, or Cmd held are left to the browser.
styles: false injects nothing — you keep the full behavior
(thumb measurement, roving tabindex, hidden input), the radiogroup ARIA and
keyboard handling, and the stable .vsg-* class hooks; you
bring your own CSS. Each demo runs in its own <iframe>:
the styled examples above already injected the segmented stylesheet into
this page, so only a clean document can show truly unstyled output.
styles: false)The raw markup contract — role="radiogroup" with
role="radio" buttons and class hooks, zero CSS injected.
Arrow keys still move and select.
new Segmented('#view', {
styles: false, // zero CSS injected
options: [
{ value: 'list', label: 'List' },
{ value: 'board', label: 'Board' },
{ value: 'timeline', label: 'Timeline' }
],
value: 'board',
label: 'View'
})
The same headless control mapped to a different design — the JS keeps
driving the thumb's transform/size while Tailwind draws it —
loaded on demand from cdn.tailwindcss.com.
<style type="text/tailwindcss">
.vsg { @apply relative inline-flex rounded-full
bg-slate-900 p-1; }
.vsg-thumb { @apply absolute left-0 top-0 rounded-full
bg-emerald-400 opacity-0 shadow
transition-[transform,width] duration-200; }
.vsg-thumb.vsg-thumb-on { @apply opacity-100; }
.vsg-seg { @apply relative z-[1] cursor-pointer
rounded-full border-0 bg-transparent px-4 py-1.5
text-sm font-medium text-slate-400 transition-colors; }
.vsg-seg[aria-checked="true"] { @apply text-slate-950; }
.vsg-seg:focus-visible { @apply outline-none ring-2
ring-emerald-300; }
</style>