Toggle Group
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.
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 });