Single selection

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

<div class="toggle-group" role="group" aria-label="Alignment" data-type="single">
  <button class="toggle" aria-pressed="true"><i data-lucide="align-left"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="align-center"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="align-right"></i></button>
</div>

Multiple selection

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

<div class="toggle-group" role="group" aria-label="Formatting" data-type="multiple">
  <button class="toggle" aria-pressed="true"><i data-lucide="bold"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="italic"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="underline"></i></button>
</div>

Outline variant

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

<div class="toggle-group" role="group" aria-label="View" data-type="single" data-variant="outline">
  <button class="toggle" aria-pressed="true"><i data-lucide="list"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="grid-2x2"></i></button>
  <button class="toggle" aria-pressed="false"><i data-lucide="kanban"></i></button>
</div>

Sizes

Use data-size="sm" or data-size="lg" on the group.

<div class="toggle-group" data-size="sm" data-type="single" ...>...</div>
<div class="toggle-group" data-type="single" ...>...</div>
<div class="toggle-group" data-size="lg" data-type="single" ...>...</div>

With spacing

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

<div class="toggle-group" data-variant="outline" data-spacing data-type="single" ...>
  <button class="toggle" aria-pressed="true">...</button>
  <button class="toggle" aria-pressed="false">...</button>
  <button class="toggle" aria-pressed="false">...</button>
</div>

Vertical

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

<div class="toggle-group" role="group" aria-label="Alignment" data-type="single" data-orientation="vertical">
  <button class="toggle" aria-pressed="true">...</button>
  <button class="toggle" aria-pressed="false">...</button>
  <button class="toggle" aria-pressed="false">...</button>
</div>

With text

Toggle items can include text alongside icons.

<div class="toggle-group" role="group" aria-label="View mode" data-type="single" data-variant="outline">
  <button class="toggle" aria-pressed="true"><i data-lucide="list"></i> List</button>
  <button class="toggle" aria-pressed="false"><i data-lucide="grid-2x2"></i> Grid</button>
  <button class="toggle" aria-pressed="false"><i data-lucide="columns-3"></i> Board</button>
</div>

Disabled

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

<div class="toggle-group" role="group" aria-label="Formatting" data-type="multiple" data-disabled>
  <button class="toggle" aria-pressed="true" disabled>...</button>
  <button class="toggle" aria-pressed="false" disabled>...</button>
  <button class="toggle" aria-pressed="false" disabled>...</button>
</div>

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="sm"] > .toggle {
      height: 2rem;
      padding: 0 0.375rem;
      min-width: 2rem;

      & svg { width: 0.875rem; height: 0.875rem; }
    }

    &[data-size="lg"] > .toggle {
      height: 2.5rem;
      padding: 0 0.625rem;
      min-width: 2.5rem;

      & svg { width: 1.125rem; height: 1.125rem; }
    }

    /* -- Vertical orientation ----------------------------------- */
    &[data-orientation="vertical"] {
      flex-direction: column;
    }

    /* -- Connected borders: merge adjacent toggle radii --------- */
    & > .toggle:not(:first-child) {
      border-start-start-radius: 0;
      border-end-start-radius: 0;
    }

    & > .toggle:not(:last-child) {
      border-start-end-radius: 0;
      border-end-end-radius: 0;
    }

    &[data-orientation="vertical"] {
      & > .toggle:not(:first-child) {
        border-start-start-radius: 0;
        border-start-end-radius: 0;
        border-end-start-radius: var(--radius-md);
      }

      & > .toggle:not(:last-child) {
        border-end-start-radius: 0;
        border-end-end-radius: 0;
        border-start-end-radius: var(--radius-md);
      }
    }

    /* -- Spacing: adds gap and restores individual radii --------- */
    &[data-spacing] {
      gap: 0.25rem;

      & > .toggle {
        border-radius: var(--radius-md);
      }
    }

    /* -- Disabled group ----------------------------------------- */
    &[data-disabled] {
      pointer-events: none;

      & > .toggle {
        pointer-events: none;
        opacity: 0.5;
      }
    }
  }

  /* -- 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.

function init() {
  document.querySelectorAll('.toggle-group:not([data-init])').forEach((group) => {
  group.dataset.init = '';
  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 });