Skip to content
Download ZIP

Blue accent palette · 10.3 KB ZIP File receipt View as Markdown View code

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.
cmp_toggle_group_01 · version 1.0.0 · Blue accent palette
Using the PageSugar MCP server, fetch component cmp_toggle_group_01 version 1.0.0 with variant "blue", 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
Blue accent
Version
1.0.0
Digest
Full digest
sha256-3fc17d47c190100bcc77300e1694ba808ab2ce5d772c7b58de64cb463eb25fc9
ToggleGroup.svelte Svelte · 18.6 KB Raw
<!--
	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, #1d4ed8);
		--_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>

Give 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
Required props
typeitemslabel

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

Svelte
<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
Svelte
<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. Without allowEmpty, pressing the pressed item leaves it pressed, so once a choice is made one always stays made. Pass an initial value if 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).
  • onValueChange fires once per change and not for a press that changes nothing. bind:value and onValueChange can 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
NameTypeRequiredDefaultDescription
type'single' | 'multiple'YesNoneSelection 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 }[]YesNoneThe 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.
labelstringYesNoneAccessible name of the group, written to aria-label, e.g. 'Text alignment'.
valuestring | string[]NoNoneBindable. 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)NoNoneCalled 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.
iconOnlybooleanNofalseShow 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.
attachedbooleanNotrueSet the items in one hairline track that stays on one line and scrolls when narrow (true), or as separate buttons that wrap (false).
allowEmptybooleanNofalseSingle mode only (not accepted in multiple mode). When true, pressing the pressed item clears the value to ''. When false, it stays pressed.
disabledbooleanNofalseDisable every item. Pressed items still show as pressed.
classClassValueNoNoneExtra classes on the group element, for margins or placement.

Customization#

On this page

Choose 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-accent fills the pressed plates and draws the focus ring; --toggle-group-on-accent is the icon and label colour on them. The neutral default is near-black on white.
  • Neutrals: --toggle-group-muted colours items at rest, --toggle-group-ink on hover and as the hint's fill, --toggle-group-surface the track and spaced buttons (and the hint's text), --toggle-group-hairline their edge.
  • Worked retone for a #09090b toolbar: --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 currentColor so 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 allowEmpty off. For a filter that can show everything, set allowEmpty so 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-9 and size-8 on the item to resize them.

Public CSS variables

VariableToken
--toggle-group-accentaccent
--toggle-group-on-accentonAccent
--toggle-group-inkink
--toggle-group-mutedmuted
--toggle-group-hairlinehairline
--toggle-group-surfacesurface

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 is aria-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-disabled on 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.