Theme
On this page (11)
Component Skill — components/toggle-group/component-skill.md

Native basis

role="group" container with <button class="toggle"> items using aria-pressed for state. Supports single and multiple selection with roving tabindex.

Web Platform APIs

role="group"aria-pressedaria-label:focus-visiblecolor-mix()prefers-reduced-motionforced-colors

Classes

.toggle-group.toggle

Variants (data-variant)

(default)Transparent background, no borderoutline1px border + shadow on each item

Sizes (data-size)

xs1.75rem heightsm2rem height, 0.875rem iconsmd2.25rem height, 1rem icons(default)2.25rem height, 1rem iconslg2.5rem height, 1.125rem iconsxl3rem height

Data attributes

• data-type - single (default) or multiple

• data-orientation - vertical for vertical layout

• data-spacing - adds gap, restores individual radii

• data-disabled - disables all items in the group

Keyboard

TabMove into/out of the group (single tab stop)←/→Move focus between items (horizontal)↑/↓Move focus between items (vertical)Home/EndMove to first/last itemSpace/EnterToggle the focused item

Notes

• Uses roving tabindex: only one item has tabindex="0"; others have tabindex="-1".

• data-type="single" enforces one-at-a-time selection (radio-like). Pressing the active item deselects it.

• data-type="multiple" allows any combination of pressed states (checkbox-like).

• CSS uses logical properties for RTL support.

• Connected mode collapses adjacent radii; outline variant collapses double borders via negative margin.

§Single selection

Only one item can be active at a time. Use data-type='single'.

§Multiple selection

Any combination of items can be active. Use data-type='multiple'.

§Outline variant

Use data-variant='outline' for bordered toggles with shadow.

§Sizes

The full five-step scale - set data-size on the group and every child .toggle follows.

§With spacing

Add data-spacing for gaps between items with individual border-radius.

§Vertical

Use data-orientation='vertical' for a vertical layout. Arrow Up/Down navigate.

§With text

Toggle items can include text alongside icons.

§Disabled

Add data-disabled to the group and disabled on each button.

§States

Named states via the shared State API, driven per instance through the bound api:

  • default - enabled (items clickable and focusable)
  • disabled - sets the documented data-disabled attribute (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:

StateTypeValuesDefaultDescription
disabledbooleantrue, falsefalseWhole group disabled (data-disabled on .toggle-group - all toggles inert).

§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 } from '../../shared/state-api.js';
const df$ = defussGlobals();
const toggleGroupStates = ['default', 'disabled'];
/**
 * 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 = {
  setState(group, stateName, config = {}) {
    if (!toggleGroupStates.includes(stateName)) {
      throw new Error(`toggle-group: unknown state "${stateName}" (supported: ${toggleGroupStates.join(', ')})`);
    }
    triggerStateChange(group, stateName, config);
    // state lives on the ELEMENT, not the module (many groups per page)
    group.dataset.stateName = stateName;
    group._stateConfig = config;
  },
  getState(group) {
    return {
      name: group.hasAttribute('data-disabled') ? 'disabled' : 'default',
      config: group._stateConfig ?? {},
    };
  },
};
df$.toggleGroupApi = toggleGroupApi;
df$.toggleGroupStates = toggleGroupStates;
function init() {
  document.querySelectorAll('.toggle-group:not([data-init])').forEach((group) => {
  group.dataset.init = '';
  // bind-scope the api per group: `$('#align').api.setState('disabled')`
  group.api = {
    setState: (stateName, config) => toggleGroupApi.setState(group, stateName, config),
    getState: () => toggleGroupApi.getState(group),
  };
  const type = group.getAttribute('data-type') || 'single';
  const getToggles = () => Array.from(group.querySelectorAll('.toggle:not(:disabled)'));
  // 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.closest('.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.closest('.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