Toggle GroupMOL
A set of two-state buttons that can be toggled on or off. Supports single (radio-like) and multiple (checkbox-like) selection modes with roving tabindex keyboard navigation.
On this page (12)
§States
Named states via the shared State API, driven per instance through the bound api:
default- enabled (items clickable and focusable)disabled- sets the documenteddata-disabledattribute (CSS dims items,pointer-events: none)
The demo element carries data-state-demo; bun run screenshots drives it via el.api.setState(name) and captures screenshots/{mode}/toggle-group-{state}.png.
Machine contract - verified against toggle-group.schema.json by bun run verify:
| State | Type | Values | Default | Description |
|---|---|---|---|---|
disabled | boolean | true, false | false | Whole group disabled (data-disabled on .toggle-group - all toggles inert). |
§API
Generated from toggle-group.ts and the shared State API - the descriptions are their JSDoc, the types are checked by the compiler.
States
type ToggleGroupState = 'default' | 'disabled' - setState(name, config) takes the config of the state it names.
| State | Description |
|---|---|
default | Enabled. No config. |
disabled | Every item disabled (data-disabled on the group). No config. |
Every element
| Member | Description | |||||||||
|---|---|---|---|---|---|---|---|---|---|---|
el.api.setState<S extends ToggleGroupState>(name: S, config?: ToggleGroupStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | |||||||||
el.api.getState(): { name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed. Returns | |||||||||
el.api.render(state?: { name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | |||||||||
el.api.settled(): Promise<void> | Wait for the last state's DOM work (async states: a diagram rendering, a chart mounting). Returns | |||||||||
el.store: Store<{ name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState] }> | A defuss-store store of the element's state - subscribe to follow every change (also the user's), set it to drive the component. |
Registry
| Member | Description | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
df$.shadcn.toggleGroupApi.setState<S extends ToggleGroupState>(el: HTMLElement, name: S, config?: ToggleGroupStateConfigs[S]): unknown | Enter a state: the DOM work runs (also when it is the current state), the store records it.
Returns | ||||||||||||
df$.shadcn.toggleGroupApi.getState(el: HTMLElement): { name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState]; model?: ElementModel } | The state the element shows now - read back from the DOM, so it includes what the user changed.
Returns | ||||||||||||
df$.shadcn.toggleGroupApi.render(state: { name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState]; model?: ElementModel }): string | The element's markup in a state - the authored markup with that state applied; a pure function of the state.
Returns | ||||||||||||
df$.shadcn.toggleGroupApi.store(el: HTMLElement): Store<{ name: ToggleGroupState; config: ToggleGroupStateConfigs[ToggleGroupState] }> | The element's store (bindComponent made it).
Returns | ||||||||||||
df$.shadcn.toggleGroupApi.commit<S extends ToggleGroupState>(el: HTMLElement, name: S, config?: ToggleGroupStateConfigs[S]): void | Record a state the element reached on its own (no DOM work) - for a component's own handlers.
| ||||||||||||
df$.shadcn.toggleGroupStates: ToggleGroupState[] | The declared states, 'default' first: default, disabled. |
§CSS view file
/* -- Toggle Group component ------------------------------------- */@layer components { .toggle-group { display: inline-flex; align-items: center; gap: 0.0625rem; /* -- Outline variant: propagate border + shadow to children -- */ &[data-variant="outline"] > .toggle { border: 1px solid var(--input); background: transparent; box-shadow: var(--shadow-xs); &:hover { background-color: var(--accent); color: var(--accent-foreground); } &[aria-pressed="true"] { background-color: var(--accent); color: var(--accent-foreground); } } /* Collapse double borders on connected outline items */ &[data-variant="outline"]:not([data-spacing]) > .toggle:not(:first-child) { margin-inline-start: -1px; } &[data-variant="outline"]:not([data-spacing])[data-orientation="vertical"] > .toggle:not(:first-child) { margin-inline-start: 0; margin-block-start: -1px; } /* -- Sizes: propagate to child toggles ---------------------- */ &[data-size="xs"] > .toggle { height: 1.75rem; padding: 0 0.25rem; min-width: 1.75rem; font-size: 0.75rem; } &[data-size="sm"] > .toggle { height: 2rem; padding: 0 0.375rem; min-width: 2rem; & svg { width: 0.875rem; height: 0.875rem; } } &[data-size="md"] > .toggle { height: 2.25rem; padding: 0 0.5rem; min-width: 2.25rem; } &[data-size="lg"] > .toggle { height: 2.5rem; padding: 0 0.625rem; min-width: 2.5rem; } &[data-size="xl"] > .toggle { height: 3rem; padding: 0 0.75rem; min-width: 3rem; font-size: 1rem; & svg { width: 1.125rem; height: 1.125rem; } } /* -- Vertical orientation ----------------------------------- */ &[data-orientation="vertical"] { flex-direction: column; } /* -- Connected borders: merge adjacent toggle radii --------- Square only the corners that actually touch a neighbour: inline corners horizontally, block corners vertically. The orientation attribute is wrapped in :where() so every rule keeps one-class-plus-one-pseudo specificity - the later [data-spacing] radius restore must still win the tie. (The previous per-corner "restore" overrides let the :not(:last-child) block re-round a middle item's top-right corner - issue #12.) */ &:where(:not([data-orientation="vertical"])) > .toggle:not(:first-child) { border-start-start-radius: 0; border-end-start-radius: 0; } &:where(:not([data-orientation="vertical"])) > .toggle:not(:last-child) { border-start-end-radius: 0; border-end-end-radius: 0; } &:where([data-orientation="vertical"]) > .toggle:not(:first-child) { border-start-start-radius: 0; border-start-end-radius: 0; } &:where([data-orientation="vertical"]) > .toggle:not(:last-child) { border-end-start-radius: 0; border-end-end-radius: 0; } /* -- Spacing: adds gap and restores individual radii --------- */ &[data-spacing] { gap: 0.25rem; & > .toggle { border-radius: var(--radius-md); } } /* -- Disabled group ----------------------------------------- */ /* the runtime ignores clicks in a disabled group; the cursor says so */ &[data-disabled] { & > .toggle { opacity: 0.5; cursor: not-allowed; &:hover { background-color: transparent; color: inherit; } } } } /* -- Accessibility -------------------------------------------- */ @media (prefers-reduced-motion: reduce) { .toggle-group .toggle { transition: none; } } @media (forced-colors: active) { .toggle-group > .toggle { border-color: ButtonBorder; &[aria-pressed="true"] { background-color: Highlight; color: HighlightText; border-color: Highlight; } &:focus-visible { outline-color: Highlight; } } }}§JavaScript view file
Manages single/multiple selection and roving tabindex keyboard navigation.
// -- Toggle Group ---------------------------------------------// Manages single/multiple selection and roving tabindex across .toggle// buttons, plus the named-state API bound per group, so agents/tests can// enable/disable a whole group by name (AGENTS.md "State API").// Shared preamble (AGENTS.md "State API"); the implementation lives in core.js —// build.ts rewrites this import into a df$.shadcn.shared binding in dist/.import { defussGlobals, defussQuery, componentState, bindComponent } from '../../../shared/state-api.js';const df$ = defussGlobals();const dfDollar = defussQuery();const toggleGroupStates = ['default', 'disabled'];// VERIFIED: (verify's API docs gate) the states below are exactly the declared ones, each// described, and every config field typed, described and named in the code./** setState() configs per state - the toggle group's states take none. */export interface ToggleGroupStateConfigs { /** Enabled. */ default: {}; /** Every item disabled (data-disabled on the group). */ disabled: {};}/** * The markup of a state, for render(): the attributes every state writes - * the same as triggerStateChange does on the live element - applied to a * detached copy of the authored markup. The e2e render round trip proves * the two agree. */function applyMarkup(el, stateName) { dfDollar(el).attr('data-disabled', stateName === 'disabled' ? '' : null);}/** * UI side of setState: 'disabled' mirrors the documented data-disabled * attribute (CSS kills pointer events + dims items); 'default' removes it. */function triggerStateChange(group, stateName, _config) { switch (stateName) { case 'default': group.removeAttribute('data-disabled'); break; case 'disabled': group.setAttribute('data-disabled', ''); break; }}/** Registry-level API; pass the group element explicitly. Unknown names throw. */export const toggleGroupApi = componentState({ component: 'toggle-group', states: toggleGroupStates, apply: (group, state) => triggerStateChange(group, state.name, state.config), read: (group, state) => { return { name: group.hasAttribute('data-disabled') ? 'disabled' : 'default', config: state.config, }; }, markup: (el, state) => applyMarkup(el, state.name),});df$.toggleGroupApi = toggleGroupApi;df$.toggleGroupStates = toggleGroupStates;function init() { dfDollar('.toggle-group:not([data-init])').toArray().forEach((group) => { group.dataset.init = ''; // el.store + el.api (AGENTS.md "State through stores") bindComponent(group, toggleGroupApi); const type = group.getAttribute('data-type') || 'single'; const getToggles = () => Array.from(dfDollar(group).find('.toggle:not(:disabled)').toArray()); // Roving tabindex: only one item tabbable at a time const initTabindex = () => { const toggles = getToggles(); if (toggles.length === 0) return; const pressed = toggles.find((t) => t.getAttribute('aria-pressed') === 'true'); const active = pressed || toggles[0]; toggles.forEach((t) => { t.setAttribute('tabindex', t === active ? '0' : '-1'); }); }; initTabindex(); group.addEventListener('click', (e) => { const toggle = (e.target as HTMLElement).closest<HTMLButtonElement>('.toggle'); if (!toggle || toggle.disabled || group.hasAttribute('data-disabled')) return; const toggles = getToggles(); const pressed = toggle.getAttribute('aria-pressed') === 'true'; if (type === 'single') { toggles.forEach((t) => t.setAttribute('aria-pressed', 'false')); if (!pressed) toggle.setAttribute('aria-pressed', 'true'); } else { toggle.setAttribute('aria-pressed', String(!pressed)); } // Update roving tabindex to current item toggles.forEach((t) => t.setAttribute('tabindex', t === toggle ? '0' : '-1')); }); group.addEventListener('keydown', (e) => { const toggle = (e.target as HTMLElement).closest<HTMLButtonElement>('.toggle'); if (!toggle || group.hasAttribute('data-disabled')) return; const toggles = getToggles(); const idx = toggles.indexOf(toggle); if (idx === -1) return; const vertical = group.getAttribute('data-orientation') === 'vertical'; const fwd = vertical ? 'ArrowDown' : 'ArrowRight'; const bwd = vertical ? 'ArrowUp' : 'ArrowLeft'; let next; if (e.key === fwd) { e.preventDefault(); next = (idx + 1) % toggles.length; } else if (e.key === bwd) { e.preventDefault(); next = (idx - 1 + toggles.length) % toggles.length; } else if (e.key === 'Home') { e.preventDefault(); next = 0; } else if (e.key === 'End') { e.preventDefault(); next = toggles.length - 1; } if (next !== undefined) { toggles[idx].setAttribute('tabindex', '-1'); toggles[next].setAttribute('tabindex', '0'); toggles[next].focus(); } });});}init();new MutationObserver(init).observe(document, { childList: true, subtree: true });Comments, ideas or improvements? Edit this page's source on GitHub