Vanilla UI Kit Segmented v1.0.0 · tabs →

Segmented, one file.

The modern radio group — a sliding thumb, real radio semantics, and automatic light/dark. Every card below is live; try the arrow keys.

View switcher

Array shorthand; arrows move and select.

value: List
new Segmented('#basic', {
  options: ['List', 'Board', 'Calendar'],
  onChange: v => render(v)
})

Icons + icon-only

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 }

Full width

fullWidth stretches segments evenly; the thumb re-measures on resize.

new Segmented('#full', {
  options: ['Day', 'Week', 'Month', 'Year'],
  value: 'Week', fullWidth: true
})

Enhance existing buttons

Plain markup in, control out — destroy() gives the original buttons back.

<div id="enhance">
  <button data-value="dev">Dev</button> …
</div>
new Segmented('#enhance')

In a real form

name renders a hidden input, so the value submits like any field.

new Segmented('#plan', {
  options: ['Free', 'Pro', 'Team'],
  name: 'plan'
})

Disabled segment + sizes

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'

API reference

Constructor & usage

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:

PropertyTypeDescription
valueanyThe option's value. Defaults to label when omitted.
labelstringVisible text (always rendered as text, never HTML). Defaults to String(value).
iconstringTrusted SVG string rendered before the label (same trust model as Toast's built-in icons) — never pass user-generated content.
iconOnlybooleanRender the icon without visible text; the label/value becomes the segment's aria-label. Also implied by giving an icon with no label.
disabledbooleanSegment can't be selected; skipped by clicks and keyboard navigation.

Options

All constructor options with their defaults (Segmented.defaults, mutable to change them globally):

OptionTypeDefaultDescription
optionsarraynullArray 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.
valueanyundefinedInitial 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).
namestringnullRenders a hidden <input type="hidden"> with this name that always carries the current value, so the control submits in forms.
sizestring'md''sm' | 'md'. 'sm' adds the vsg-sm class (tighter padding, 13px text).
fullWidthbooleanfalseStretch segments evenly across the container (vsg-full).
labelstringnullaria-label for the radiogroup. An existing aria-label/aria-labelledby on the element is never clobbered; with none at all, labels.group is used.
themestring'auto''auto' | 'light' | 'dark'. 'auto' follows the page and re-resolves live (see Theming).
stylesbooleantruefalse = headless: no CSS is injected; you style the .vsg-* markup contract yourself.
onChangefunctionnullfn(value, instance) — called when the selection changes (see Events).
labelsobject{ 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.

Instance methods

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

MethodReturnsDescription
getValue()any | nullThe currently selected value, or null when nothing is selected (e.g. every option disabled).
setValue(value, opts?)instanceSelect 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()instanceRe-enable the whole control after disable(). Per-option disabled state survives; the hidden input resumes submitting.
disable()instanceDisable 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)instanceReplace 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()instanceUnbind 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.

Statics & helpers

StaticTypeDescription
Segmented.create(target, opts)functionSame as new Segmented(target, opts).
Segmented.get(target)functionThe live instance for a selector/element, or null.
Segmented.autoInit(root?)functionInitialize 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.defaultsobjectThe live defaults object (table above). Mutate before constructing to change defaults globally, e.g. Segmented.defaults.styles = false.
Segmented.versionstring'1.0.0'.
Segmented.saltstring | falseCSS isolation token, default 'vc1'. Set to your own token or false before the first instance (see Theming).
Segmented.cssstringThe full stylesheet, always rendered with the current salt (a live getter). Starting point for headless styling.
Segmented.displayNamestring'Segmented' — part of the VC family convergence contract.
Segmented.rootClassstring'vsg' — the root class the control renders with.
Segmented.themeVarsobjectMap of family theme keys to CSS custom properties: { accent: '--vsg-accent', radius: '--vsg-radius', font: '--vsg-font' } — used by VC.config().
Segmented.varScopesarray['.vsg', '.vsg[data-theme=dark]'] — the (deliberately unsalted) scopes where theme variables are defined; VC.config() writes its overrides there.

Events & callbacks

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().

HookKindArguments / detailNotes
onChangeoption callback(value, instance)Called before the DOM event is dispatched.
segmented:changeCustomEventdetail: { value, segmented } — the new value and the instanceDispatched 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.

Declarative init

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>
AttributeMaps toValue semantics
data-vsg—Marker: opts the container into autoInit().
data-valuevalueInitial value (string; matched loosely against option values). Ignored when empty.
data-namenameHidden form input name.
data-sizesize'sm' | 'md'.
data-full-widthfullWidthBoolean: "false" and "0" are false, any other value (including empty) is true.
data-labellabelAccessible name for the radiogroup.
data-themetheme'auto' | 'light' | 'dark'.
data-stylesstylesBoolean, same parsing as data-full-width; "false" = headless.

Theming

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

PropertyLight defaultDark defaultUsed for
--vsg-accent#5b5bd6#7b7beaChecked label color, focus ring.
--vsg-bg#ffffff#1b1d24The sliding thumb.
--vsg-text#1c1d21#e9eaf0Text / hover color.
--vsg-muted#72747e#989aa6Unchecked label color.
--vsg-faint#e7e7ec#31343fThe pill track background.
--vsg-shadowsubtle drop shadowdarker shadowThumb shadow.
--vsg-radius10px—Track radius (segments/thumb derive theirs from it).
--vsg-fontsystem-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

Accessibility

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.

KeyAction
→ / ↓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).
HomeJump to the first enabled segment and select it.
EndJump to the last enabled segment and select it.
Space / EnterSelect the focused segment (native button activation).
TabLeave the control — it occupies a single Tab stop.

Key presses with Alt, Ctrl, or Cmd held are left to the browser.

Headless mode

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.

Unstyled (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'
})

Bring your own design (Tailwind)

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>