Toggle group
A labelled set of toggle buttons with single or multiple selection: one hairline track with accent plates for the pressed items, or spaced buttons that wrap. Arrow-key roving focus, icon-only hints, no dependencies.
cmp_toggle_group_01 Preview
Fit to the available width. The frame follows the height of its content; previews taller than the maximum auto-height scroll inside it.
Give this component to your coding agent Copy a prompt that fetches this exact version and palette through the PageSugar MCP server.
Using the PageSugar MCP server, fetch component cmp_toggle_group_01 version 1.0.0 with variant "neutral", first inspect its requirements and license status and confirm this project uses Svelte 5 and Tailwind CSS 4. Retrieve every manifest file, including binary assets and any manifest-only response files, preserving relative paths. Then integrate the source and follow its usage notes. Run project checks, review the browser result and report anything unverified. Do not substitute another version or invent missing files.Not connected yet? Set up the MCP server
Code
- Palette
- Neutral
- Version
- 1.0.0
- Digest
Full digest
sha256-9bb99e058e2c9b7a76ede0aa2d09495a30085aefc95f39ab377cd36527871c11
<!--
A set of related toggle buttons with single or multiple selection. Attached, the items sit in
one hairline track with a 4 px inset and each pressed item is an accent plate set inside it;
in multiple mode, neighbouring pressed plates fuse into one run, so a single choice reads as
one plate and several choices read as runs. Spaced, the items are separate hairline buttons
that wrap. One tab stop, arrow keys between items (mirrored in a right-to-left page), Home and
End. It only reports the value; what the value changes is yours.
-->
<script lang="ts" module>
import type { Snippet } from 'svelte';
export interface ToggleGroupItem {
/** Stable value reported in onValueChange. Unique within the group. */
value: string;
/** Visible text, or the accessible name and hint when iconOnly is set. */
label: string;
/** A 16 px icon, rendered inside an aria-hidden box before the label. */
icon?: Snippet;
/** Skipped by the arrow keys and not pressable. */
disabled?: boolean;
}
export type ToggleGroupType = 'single' | 'multiple';
</script>
<script lang="ts">
import type { ClassValue, HTMLAttributes } from 'svelte/elements';
type Passthrough = Omit<
HTMLAttributes<HTMLDivElement>,
'class' | 'children' | 'role' | 'aria-label' | 'onkeydown'
>;
interface SharedProps extends Passthrough {
/** The options, in reading order. */
items: ToggleGroupItem[];
/** Accessible name of the group, e.g. 'Text alignment'. */
label: string;
/** Hide the labels visually; each becomes its item's aria-label and a hint on hover and focus. */
iconOnly?: boolean;
/** One track with pressed plates inside it (true), or separate buttons that wrap (false). */
attached?: boolean;
/** Disable every item. */
disabled?: boolean;
/** Extra classes on the group element, for margins or placement. */
class?: ClassValue;
}
interface SingleProps extends SharedProps {
type: 'single';
/** The pressed value, '' or undefined for none. Bindable. */
value?: string;
onValueChange?: (value: string) => void;
/** Pressing the pressed item clears it. Off, one item stays pressed once chosen. */
allowEmpty?: boolean;
}
interface MultipleProps extends SharedProps {
type: 'multiple';
/** The pressed values, in item order. Bindable. */
value?: string[];
onValueChange?: (value: string[]) => void;
allowEmpty?: never;
}
type Props = SingleProps | MultipleProps;
let {
type,
items,
label,
value = $bindable(),
onValueChange,
iconOnly = false,
attached = true,
allowEmpty = false,
disabled = false,
class: className,
...rest
}: Props = $props();
const uid = $props.id();
const multiple = $derived(type === 'multiple');
/*
* Items that cannot work are a bug in the caller, so they fail loudly (on the server too)
* rather than render a control nobody can name or press: '' is reserved for "nothing
* pressed", values key the list and the tab stop, and an icon-only label is the only name.
*/
const checked = $derived.by(() => {
const values = items.map((item) => item.value);
const names = items.map((item) => item.label?.trim() ?? '');
if (values.includes('')) throw new Error("ToggleGroup: '' is reserved for no selection.");
const twin = values.find((v, i) => values.indexOf(v) !== i);
if (twin !== undefined) throw new Error(`ToggleGroup: duplicate item value "${twin}".`);
if (names.includes('')) throw new Error('ToggleGroup: every item needs a label.');
if (iconOnly && names.some((n, i) => names.indexOf(n) !== i))
throw new Error('ToggleGroup: icon-only labels must be unique; they are the names.');
return items;
});
/* What is pressed, as a list in both modes. Anything that is not the mode's shape is none. */
const pressed = $derived<string[]>(
multiple
? Array.isArray(value)
? value
: []
: typeof value === 'string' && value
? [value]
: []
);
const isDisabled = (item: ToggleGroupItem) => disabled || !!item.disabled;
function toggle(item: ToggleGroupItem) {
if (isDisabled(item)) return;
const on = pressed.includes(item.value);
let next: string | string[];
if (multiple) {
const chosen = on ? pressed.filter((v) => v !== item.value) : [...pressed, item.value];
// Report in item order, so the value reads the way the strip does.
const known = items.map((i) => i.value).filter((v) => chosen.includes(v));
next = [...known, ...chosen.filter((v) => !known.includes(v))];
} else {
if (on && !allowEmpty) return;
next = on ? '' : item.value;
}
value = next;
(onValueChange as ((v: string | string[]) => void) | undefined)?.(next);
}
/*
* Roving tab stop: the item focused last, else the first pressed one, else the first enabled
* one. Disabled items are never the stop and the arrow keys skip them.
*/
let focused = $state<string | null>(null);
const tabStop = $derived.by(() => {
const enabled = items.filter((item) => !isDisabled(item));
return (
enabled.find((item) => item.value === focused)?.value ??
enabled.find((item) => pressed.includes(item.value))?.value ??
enabled[0]?.value
);
});
let root = $state<HTMLElement>();
let scroller = $state<HTMLElement>();
/* Escape hides an icon-only hint until focus moves on or the pointer leaves (WCAG 1.4.13). */
let dismissed = $state(false);
let hovering = $state(false);
/* A hover hint has no focus to receive the key, so listen on the window while one can show. */
$effect(() => {
if (!iconOnly || !hovering) return;
const dismiss = (event: KeyboardEvent) => {
if (event.key === 'Escape') dismissed = true;
};
window.addEventListener('keydown', dismiss);
return () => window.removeEventListener('keydown', dismiss);
});
/*
* If the focused item is removed or disabled, focus would fall to the page. Note before each
* update whether focus was inside the group, and afterwards hand it to the new tab stop.
*/
let hadFocus = false;
$effect.pre(() => {
void items;
void disabled;
hadFocus = !!root?.contains(document.activeElement);
});
$effect(() => {
void items;
void disabled;
const stop = tabStop;
if (!hadFocus || !root) return;
const active = document.activeElement as HTMLButtonElement | null;
if (active && root.contains(active) && !active.disabled) return;
[...root.querySelectorAll<HTMLButtonElement>('.toggle-group__item')]
.find((button) => button.dataset.value === stop)
?.focus();
});
function onkeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
dismissed = true;
return;
}
if (!root || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return;
const buttons = [
...root.querySelectorAll<HTMLButtonElement>('.toggle-group__item:not(:disabled)')
];
const index = buttons.indexOf(event.target as HTMLButtonElement);
if (index < 0) return;
// Arrows follow the screen: in a right-to-left page the next item is to the left.
const step = getComputedStyle(root).direction === 'rtl' ? -1 : 1;
const last = buttons.length - 1;
let next: number;
if (event.key === 'ArrowRight') next = index + step;
else if (event.key === 'ArrowLeft') next = index - step;
else if (event.key === 'Home') next = 0;
else if (event.key === 'End') next = last;
else return;
event.preventDefault();
// Focus wraps from one end to the other.
buttons[next > last ? 0 : next < 0 ? last : next].focus();
}
/*
* An attached strip that is wider than its column scrolls. Mark the edges that hide items so
* they fade, and keep a focused item clear of the fade without ever scrolling the page.
*/
let hiddenLeft = $state(false);
let hiddenRight = $state(false);
/*
* Edges are physical and read from the computed direction, so the fade lands on the side
* that hides items whether the page sets dir or CSS direction.
*/
function measure() {
if (!scroller) return;
const hidden = scroller.scrollWidth - scroller.clientWidth;
// scrollLeft runs from 0 towards negative values in right-to-left layouts.
const travelled = Math.abs(scroller.scrollLeft);
const atStart = hidden > 1 && travelled > 1;
const atEnd = hidden > 1 && travelled < hidden - 1;
const rtl = getComputedStyle(scroller).direction === 'rtl';
hiddenLeft = rtl ? atEnd : atStart;
hiddenRight = rtl ? atStart : atEnd;
}
/* Watch the strip and every item, so new items, longer labels and late fonts remeasure too. */
$effect(() => {
const el = scroller;
void items;
if (!el) return;
const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);
observer?.observe(el);
for (const child of el.children) observer?.observe(child);
el.addEventListener('scroll', measure, { passive: true });
measure();
document.fonts?.ready.then(measure);
return () => {
observer?.disconnect();
el.removeEventListener('scroll', measure);
};
});
function onfocusin(event: FocusEvent) {
const target = event.target as HTMLElement;
const item = items.find((i) => i.value === target.dataset.value);
if (item) focused = item.value;
dismissed = false;
if (!scroller || scroller.scrollWidth <= scroller.clientWidth) return;
// Clear of the 32 px fade, with room for the focus ring.
const edge = 40;
const row = scroller.getBoundingClientRect();
const box = target.getBoundingClientRect();
if (box.left < row.left + edge) scroller.scrollBy({ left: box.left - row.left - edge });
else if (box.right > row.right - edge)
scroller.scrollBy({ left: box.right - row.right + edge });
}
</script>
{#snippet item(option: ToggleGroupItem, index: number)}
{@const on = pressed.includes(option.value)}
{@const hinted = iconOnly && !!option.icon}
{@const anchor = `--toggle-group-${uid}-${index}`}
<button
type="button"
class={[
'toggle-group__item relative inline-flex cursor-pointer items-center justify-center gap-2 rounded-lg text-sm leading-5 font-medium text-(--_muted) select-none',
'transition-[color,background-color,box-shadow,scale] duration-150 ease-(--_ease) active:duration-[80ms] motion-reduce:transition-[color,background-color,box-shadow]',
/*
* Press: a separate button shrinks; one in the track, or one carrying a hint, deepens its
* tone instead, because a scale would open a gap in a fused run and would carry the hint
* down onto the button with it.
*/
attached || hinted
? 'enabled:active:bg-(--_fill-press) enabled:aria-pressed:active:bg-(--_accent-press)'
: 'active:scale-[.96] motion-reduce:active:scale-100',
'enabled:hover:bg-(--_fill-hover) enabled:hover:text-(--_ink)',
'aria-pressed:bg-(--_accent) aria-pressed:text-(--_on-accent) enabled:aria-pressed:hover:bg-(--_accent-hover) enabled:aria-pressed:hover:text-(--_on-accent)',
// The 2 px gap under the ring is painted in the surface, so a pressed neighbour never merges into it;
// z-30 keeps the ring above the overflow fades (z-20).
'outline-offset-2 outline-(--_accent) focus-visible:z-30 focus-visible:shadow-(--_focus-gap) focus-visible:outline-2',
'disabled:cursor-not-allowed disabled:opacity-50',
attached
? 'shrink-0 whitespace-nowrap'
: 'max-w-full min-w-0 bg-(--_surface) text-start break-words shadow-(--_edge) aria-pressed:shadow-none',
hinted && 'shrink-0',
hinted && (attached ? 'size-8 pointer-coarse:size-11' : 'size-9 pointer-coarse:size-11'),
!hinted &&
(attached ? 'h-8 px-3 pointer-coarse:h-11' : 'min-h-9 px-3 pointer-coarse:min-h-11')
]}
tabindex={option.value === tabStop ? 0 : -1}
aria-pressed={on ? 'true' : 'false'}
aria-label={hinted ? option.label : undefined}
disabled={isDisabled(option)}
data-value={option.value}
style:anchor-name={hinted ? anchor : undefined}
onclick={() => toggle(option)}
>
{#if option.icon}
<span class="toggle-group__icon grid size-4 shrink-0 place-items-center" aria-hidden="true">
{@render option.icon()}
</span>
{/if}
{#if hinted}
<!-- The name comes from aria-label; the hint repeats it for sighted users. -->
<span
class="toggle-group__hint w-max max-w-60 rounded-md bg-(--_ink) px-2 py-1 text-center text-xs leading-4 font-medium text-balance text-(--_surface) shadow-(--_shadow-hint)"
style:position-anchor={anchor}
aria-hidden="true">{option.label}</span
>
{:else}
<span class="min-w-0">{option.label}</span>
{/if}
</button>
{/snippet}
<!--
The group is the frame. Attached, an inner scroller holds the items with the 4 px inset, so
focus rings fit inside it. A hidden edge fades under an overlay on the frame rather than a
mask on the scroller, because a mask would also hide the hints that rise above the strip.
-->
<div
{...rest}
bind:this={root}
role="group"
aria-label={label}
aria-disabled={disabled ? 'true' : undefined}
class={[
'toggle-group',
attached
? 'relative inline-flex max-w-full min-w-0 rounded-xl align-middle'
: 'flex flex-wrap gap-2',
/* An empty attached group draws no frame, so a list still loading leaves no empty box. */
attached && checked.length > 0 && 'bg-(--_surface) shadow-(--_edge)',
className
]}
data-type={type}
data-attached={attached ? '' : undefined}
data-dismissed={dismissed ? '' : undefined}
data-hidden-left={hiddenLeft ? '' : undefined}
data-hidden-right={hiddenRight ? '' : undefined}
{onkeydown}
{onfocusin}
onpointerenter={() => (hovering = true)}
onpointerleave={() => {
hovering = false;
dismissed = false;
}}
>
{#if attached && checked.length > 0}
<div
bind:this={scroller}
class="toggle-group__scroller flex min-w-0 [scrollbar-width:none] overflow-x-auto p-1"
>
{#each checked as option, index (option.value)}
{@render item(option, index)}
{/each}
</div>
{:else}
{#each checked as option, index (option.value)}
{@render item(option, index)}
{/each}
{/if}
</div>
<style>
/* Public tokens: set --toggle-group-* on the group or any ancestor to retone it. */
.toggle-group {
--_accent: var(--toggle-group-accent, #18181b);
--_on-accent: var(--toggle-group-on-accent, #ffffff);
--_ink: var(--toggle-group-ink, #18181b);
--_muted: var(--toggle-group-muted, #52525b);
--_hairline: var(--toggle-group-hairline, rgb(0 0 0 / 0.12));
--_surface: var(--toggle-group-surface, #ffffff);
/* Formulas: the edge, the two hover tones, the popover elevation (DESIGN §3.5). */
--_ease: cubic-bezier(0.2, 0, 0, 1);
--_edge: inset 0 0 0 1px var(--_hairline);
--_fill-hover: color-mix(in oklab, var(--_ink) 6%, transparent);
--_accent-hover: color-mix(in oklab, var(--_accent) 88%, var(--_on-accent));
--_fill-press: color-mix(in oklab, var(--_ink) 10%, transparent);
/* Pressing a pressed plate deepens it from the lighter hover back to the accent: on-accent text keeps its full contrast. */
--_accent-press: var(--_accent);
--_focus-gap: 0 0 0 2px var(--_surface);
--_shadow-hint:
0 0 0 1px rgb(0 0 0 / 0.05), 0 4px 6px -1px rgb(0 0 0 / 0.07),
0 10px 15px -3px rgb(0 0 0 / 0.05);
}
/*
* The icon is the consumer's snippet, so its svg fills the 16 px box from a layered rule that
* any class on the svg itself still overrides.
*/
@layer components {
.toggle-group__icon :global(:where(svg)) {
width: 100%;
height: 100%;
}
}
/*
* Fused runs: in an attached multiple group, neighbouring pressed plates join into one, with a
* short seam where they meet. A relation between siblings, so CSS rather than utilities.
*/
.toggle-group[data-type='multiple']
.toggle-group__scroller
.toggle-group__item[aria-pressed='true']:has(+ .toggle-group__item[aria-pressed='true']) {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.toggle-group[data-type='multiple']
.toggle-group__scroller
.toggle-group__item[aria-pressed='true']
+ .toggle-group__item[aria-pressed='true'] {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.toggle-group[data-type='multiple']
.toggle-group__scroller
.toggle-group__item[aria-pressed='true']
+ .toggle-group__item[aria-pressed='true']::before {
content: '';
position: absolute;
inset-block: 8px;
inset-inline-start: 0;
width: 1px;
background: color-mix(in oklab, var(--_on-accent) 24%, transparent);
}
/*
* A hidden edge fades into the track, which is the hint that the strip scrolls. Two overlay
* boxes on the frame, inside its hairline, shown by the edges measure() marks.
*/
.toggle-group[data-attached]::before,
.toggle-group[data-attached]::after {
content: '';
position: absolute;
z-index: 20;
inset-block: 1px;
width: 32px;
pointer-events: none;
opacity: 0;
transition: opacity 150ms cubic-bezier(0.2, 0, 0, 1);
}
/* Physical sides on purpose: measure() reports left and right from the computed direction. */
.toggle-group[data-attached]::before {
left: 1px;
border-radius: 11px 0 0 11px;
background: linear-gradient(to right, var(--_surface), transparent);
}
.toggle-group[data-attached]::after {
right: 1px;
border-radius: 0 11px 11px 0;
background: linear-gradient(to left, var(--_surface), transparent);
}
.toggle-group[data-hidden-left]::before,
.toggle-group[data-hidden-right]::after {
opacity: 1;
}
/*
* The hint for icon-only items. Positioning has no utility spelling. The fallback sits above
* the item, centred; physical left is safe because a -50% translate centres either way.
*/
.toggle-group__hint {
position: absolute;
bottom: 100%;
left: 50%;
translate: -50% 0;
margin-block-end: 8px;
z-index: 50;
visibility: hidden;
opacity: 0;
pointer-events: none;
transition:
opacity 120ms cubic-bezier(0.4, 0, 1, 1),
visibility 0s linear 120ms;
}
/*
* With anchor positioning the hint is fixed, so the scrolling strip cannot clip it, kept in
* the viewport and flipped below when there is no room above.
*/
@supports (anchor-name: --toggle-group) and (position-area: block-start) and
(position-try-fallbacks: flip-block) {
.toggle-group__hint {
position: fixed;
inset: auto;
translate: none;
position-area: block-start;
position-try-fallbacks: flip-block;
margin: 8px;
}
}
/* A bridge across the gap, so the pointer can move onto the hint without losing it. */
.toggle-group__hint::before {
content: '';
position: absolute;
inset-block: -8px;
inset-inline: 0;
}
/* Hover shows it after a short delay, so sweeping along the strip stays quiet. */
@media (hover: hover) {
.toggle-group:not([data-dismissed]) .toggle-group__item:hover .toggle-group__hint {
visibility: visible;
opacity: 1;
pointer-events: auto;
transition:
opacity 150ms cubic-bezier(0.16, 1, 0.3, 1) 500ms,
visibility 0s linear 500ms;
}
}
/* Keyboard focus shows it at once. */
.toggle-group:not([data-dismissed]) .toggle-group__item:focus-visible .toggle-group__hint {
visibility: visible;
opacity: 1;
transition: opacity 150ms cubic-bezier(0.16, 1, 0.3, 1);
}
@media (prefers-reduced-motion: reduce) {
.toggle-group__hint,
.toggle-group[data-attached]::before,
.toggle-group[data-attached]::after {
transition: none;
}
}
</style>
Usage#
On this pageGive it a label, a type and the items, and bind the value. It draws the pressed state, moves focus with the arrow keys and reports the value. It does not filter or restyle anything itself, does not reveal panels (use tabs for that), and does not use radio semantics: every item is a toggle button with aria-pressed, in both modes.
- Suggested location
src/lib/components/toggle-group-01
Limitations
- Horizontal only. There is no vertical orientation, and ArrowUp and ArrowDown are left to the page.
- Disabled items are skipped by the arrow keys and taken out of the tab order, so a keyboard user does not land on them. Explain why an item is unavailable in text beside the group, as the disabled-item fixture does.
- In single mode without
allowEmpty, nothing is pressed until a value is given or an item is chosen; the group does not pick one for you. Pass an initial value when a choice is required. - A value that matches no item is kept in the value but draws nothing pressed.
- Spaced mode does not join pressed items into runs and draws single and multiple selection alike; the attached track is where the two read differently.
- The hint uses CSS anchor positioning where it is supported (Chrome 125 and later), so a scrolling strip cannot clip it. Elsewhere it sits above the item and can be clipped by the strip's scrolling edge.
- Icons are yours and are not mirrored for right-to-left pages; flip directional ones such as align-start yourself.
- Items with an empty value, a duplicate value or a blank label, and icon-only items with duplicate labels, throw an error rather than render a control that cannot be pressed or named.
Example
<script lang="ts">
import ToggleGroup from '$lib/components/toggle-group-01/ToggleGroup.svelte';
let align = $state('left');
let styles = $state<string[]>(['bold']);
</script>
{#snippet alignLeft()}
<svg viewBox="0 0 16 16" fill="none"><path d="M2.75 3.5h10.5M2.75 6.5h7M2.75 9.5h10.5M2.75 12.5h7" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" /></svg>
{/snippet}
{#snippet alignCenter()}
<svg viewBox="0 0 16 16" fill="none"><path d="M2.75 3.5h10.5M4.5 6.5h7M2.75 9.5h10.5M4.5 12.5h7" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" /></svg>
{/snippet}
{#snippet alignRight()}
<svg viewBox="0 0 16 16" fill="none"><path d="M2.75 3.5h10.5M6.25 6.5h7M2.75 9.5h10.5M6.25 12.5h7" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" /></svg>
{/snippet}
<div class="flex flex-wrap items-center gap-3">
<ToggleGroup
type="single"
label="Text alignment"
iconOnly
bind:value={align}
items={[
{ value: 'left', label: 'Align left', icon: alignLeft },
{ value: 'center', label: 'Align centre', icon: alignCenter },
{ value: 'right', label: 'Align right', icon: alignRight }
]}
/>
<ToggleGroup
type="multiple"
label="Text style"
bind:value={styles}
onValueChange={(next) => console.log(next)}
items={[
{ value: 'bold', label: 'Bold' },
{ value: 'italic', label: 'Italic' },
{ value: 'underline', label: 'Underline' }
]}
/>
</div>The source imports its own files by relative path only. Examples import from $lib/...; SvelteKit 3 projects have no $lib alias by default, so import from your #lib subpath (with the .svelte extension) or a relative path instead.
Toggle group#
A set of related toggle buttons that report one value or several. It draws the pressed state and handles the keyboard; what the value changes (alignment, a view, a filter) is up to you.
| Mode | Looks like | Use it for |
|---|---|---|
attached (default) |
one hairline track, pressed items as accent plates inside it | alignment, text styles, view switchers |
attached={false} |
separate hairline buttons, 8 px apart, wrapping | filter chips, longer sets |
type="single" |
one plate at a time; a new press replaces the old one | alignment, List / Grid / Map |
type="multiple" |
any number of plates; neighbours join into one run | bold / italic / underline, filters |
<ToggleGroup
type="single"
label="View locations as"
bind:value={view}
items={[
{ value: 'list', label: 'List', icon: listIcon },
{ value: 'grid', label: 'Grid', icon: gridIcon },
{ value: 'map', label: 'Map', icon: mapIcon }
]}
/>Value#
- Single: a string,
''when nothing is pressed. WithoutallowEmpty, pressing the pressed item leaves it pressed, so once a choice is made one always stays made. Pass an initialvalueif the choice is required from the start. - Multiple: an array, reported in item order (
['bold', 'italic'], never['italic', 'bold']because of the order they were pressed). onValueChangefires once per change and not for a press that changes nothing.bind:valueandonValueChangecan be used together.
Why toggle buttons and not radios#
Both modes render buttons with aria-pressed inside a role="group", the toolbar model. A
single-choice picker that should read as a form field (a billing period, a shipping speed)
is a segmented control with radio semantics, which is a different component. Keeping one
model here means a screen reader announces every item the same way, whichever mode it is in.
Keyboard#
| Key | Does |
|---|---|
| Tab | Enters the group on the pressed (or last focused) item, then leaves it |
| ArrowRight / ArrowLeft | Moves focus right or left on screen, in LTR and RTL; wraps |
| Home / End | First / last enabled item |
| Space / Enter | Presses the focused item |
| Escape | Hides an icon-only item's hint |
Narrow columns#
An attached track never wraps. When it is wider than its column it scrolls sideways, fades the
edge that hides items, and scrolls a focused item clear of that fade. For sets that should
wrap instead, use attached={false}.
Props and content inputs#
On this page| Name | Type | Required | Default | Description |
|---|---|---|---|---|
type | 'single' | 'multiple' | Yes | None | Selection mode. Single presses one item at a time and replaces the previous one; multiple toggles each item on its own. |
items | { value: string; label: string; icon?: Snippet; disabled?: boolean }[] | Yes | None | The options, in reading order. value is reported in onValueChange and must be unique and non-empty ('' means nothing pressed); label is the visible text, or the accessible name and hint when iconOnly is set, so icon-only labels must be unique; icon is a 16 px snippet. Invalid items throw. |
label | string | Yes | None | Accessible name of the group, written to aria-label, e.g. 'Text alignment'. |
value | string | string[] | No | None | Bindable. Single mode: the pressed value, '' for none. Multiple mode: the pressed values, reported in item order. The type follows type, as a discriminated union in the source. |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | No | None | Called once each time the selection changes: with a string in single mode, an array in multiple mode (the source types it per mode). Not called when a press changes nothing. |
iconOnly | boolean | No | false | Show only each item's icon. Its label becomes the aria-label and a hint on hover and keyboard focus. An item with no icon keeps its visible label. |
attached | boolean | No | true | Set the items in one hairline track that stays on one line and scrolls when narrow (true), or as separate buttons that wrap (false). |
allowEmpty | boolean | No | false | Single mode only (not accepted in multiple mode). When true, pressing the pressed item clears the value to ''. When false, it stays pressed. |
disabled | boolean | No | false | Disable every item. Pressed items still show as pressed. |
class | ClassValue | No | None | Extra classes on the group element, for margins or placement. |
Customization#
On this pageChoose the mode, layout and icons through props, and retone the track, the pressed plates, the hint and the focus ring through six --toggle-group-* CSS variables. Hover tones are mixed from ink and accent, so they follow any colour you set.
- Accent:
--toggle-group-accentfills the pressed plates and draws the focus ring;--toggle-group-on-accentis the icon and label colour on them. The neutral default is near-black on white. - Neutrals:
--toggle-group-mutedcolours items at rest,--toggle-group-inkon hover and as the hint's fill,--toggle-group-surfacethe track and spaced buttons (and the hint's text),--toggle-group-hairlinetheir edge. - Worked retone for a
#09090btoolbar:--toggle-group-surface:#18181b;--toggle-group-hairline: rgb(255 255 255 / 0.12);--toggle-group-ink:#fafafa;--toggle-group-muted:#a1a1aa;--toggle-group-accent:#fafafa;--toggle-group-on-accent:#09090b. - Icons: pass any 16 px snippet as items[].icon; the box sizes the svg to fit. Use
currentColorso the icon follows the pressed and hover tones. - Layout: attached suits short sets of three to five related options (alignment, view, text style). Use attached=
{false}for filter chips and sets that should wrap. - Required choice: in single mode, pass an initial value and leave
allowEmptyoff. For a filter that can show everything, setallowEmptyso pressing the pressed item clears it. - Spacing and size: items are 32 px tall in the track and 36 px spaced, 44 px on touch screens. Change
h-8,min-h-9andsize-8on the item to resize them.
Public CSS variables
| Variable | Token |
|---|---|
--toggle-group-accent | accent |
--toggle-group-on-accent | onAccent |
--toggle-group-ink | ink |
--toggle-group-muted | muted |
--toggle-group-hairline | hairline |
--toggle-group-surface | surface |
Accessibility#
On this page- Renders role="group" named by label. Every item is a native button with
aria-pressed, in single and multiple mode alike; there is no radio model to mix with it. - One item is in the tab order at a time: the one focused last, else the first pressed one, else the first enabled one. ArrowRight and ArrowLeft move focus in the direction they point, in left-to-right and right-to-left pages; Home and End jump to the ends; focus wraps. Space and Enter press the focused item.
- Icon-only items take their label as
aria-label. The hint that repeats it on hover and focus isaria-hidden, stays while the pointer is over it, and Escape hides it. - Disabled items use the native disabled attribute, so they are skipped. A disabled group also sets
aria-disabledon the group. - The pressed state is a filled plate, a change of tone and of shape, not of hue alone. Check that your accent clears 3:1 against the track and that on-accent clears 4.5:1 on the accent.
- The group only reports its value. If pressing an item changes content elsewhere, make sure that change is announced or visible where the user is.
Release details#
On this page- Integration
- Local interaction
- Requires client-side JavaScript to be interactive
- Server-side rendering supported
- Dependencies
- No additional runtime packages beyond Svelte and Tailwind CSS
- License
MIT. Default license approval is pending; see the license status before adopting the source.
- Version history
- 1.0.0 (Published) Current release · 22 October 2026
Only the current release is available. Keep downloaded source and its receipt if you need to use it again later.