Toggle button group
Pressable buttons for one or many values with aria-pressed and one tab stop. Five looks, tooltips on icons, limits for multiple choice, crossed out sizes and a toolbar that walks across several groups.
Pressable groups for one or many values
A toggle group joins pressable buttons for one value or for many. It uses aria-pressed with a single roving tab stop, so a group of ten buttons costs one Tab, and comes in five looks, attached, separate, ghost, round and cards.
Text format toolbar holds three groups in one toolbar after the APG pattern, styles stack, alignment takes one, lists can be cleared, and tooltips name each icon with its shortcut. View switcher joins icon segments that switch a project list between list, gallery, board and calendar. Weekdays offers round day buttons for a repeating plan, any number but at least one, with presets and a sentence that reads the choice back. Sizes uses separate buttons for a product size, sold out sizes stay in reach and read as unavailable. Vertical cards stacks map layers as cards with icon, description and a check, up to three stay on.
Set a minimum or a maximum where the choice needs it, at least one weekday, at most three layers. Keep sold out options visible and crossed out, people want to know a size exists. Give every icon only button a tooltip with its name.
Variants
Details
What Toggle group brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 5
- Version
- 1.0.0 · since 1.2.0
- File
- components/ui/toggle-group.tsx
- npm packages
- None
- Languages
- English and German, 4 texts of its own
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ToggleGroupProps interface from components/ui/toggle-group.tsx, with the comments from the code.
itemsrequiredToggleGroupItem[]typeoptional'single' | 'multiple'single: one value like a radio set. multiple: any number like checkboxes.
valueoptionalstring[]defaultValueoptionalstring[]onValueChangeoptional(value: string[]) => voidlabelrequiredstringAccessible name of the group
showLabeloptionalbooleanShow the label above the group
lookoptionalToggleGroupLookattachedseparateghostroundcards
sizeoptionalToggleGroupSizesmmdlg
orientationoptional'horizontal' | 'vertical'allowEmptyoptionalbooleansingle: the chosen item can be pressed again to clear it
minoptionalnumbermultiple: fewest and most chosen values
maxoptionalnumbertooltipsoptionalbooleanTooltips on icon-only items, on by default
fullWidthoptionalbooleanItems share the full width
Built-in texts
Toggle group writes these texts itself. The language follows the KitProvider, the labels prop replaces single ones.
Show all 4 texts
| Key | English | German |
|---|---|---|
| atMost | English Up to {n} can be chosen | German Bis zu {n} sind wählbar |
| atLeast | English At least {n} stays chosen | German Mindestens {n} bleibt gewählt |
| unavailable | English unavailable | German nicht verfügbar |
| selected | English {n} chosen | German {n} gewählt |
Accessibility
Read from the source of the component.
- Toggle group handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-describedby, aria-disabled, aria-label, aria-labelledby, aria-live, aria-orientation and aria-pressed, so screen readers announce purpose and state.
- Uses the roles group, separator and toolbar.
- Announces changes through a live region without moving focus.
- Adds text for screen readers where icons and graphics stand alone.
- Shows a visible focus ring when used with the keyboard.
- The server renders the starting state in full, JavaScript only adds the interaction.
Motion
- With reduced motion (prefers-reduced-motion) it stays still. 2 places in the code switch motion off then.
- No animations, it stands still from the start.
Source
The file aiw add puts into your project. From then on it is yours.
Show the file, 352 lines
'use client';
import { createContext, useContext, useEffect, useId, useRef, useState } from 'react';
import { cn, prefersReducedMotion, useTexts } from '@/lib/kit';
import { Icon, type IconName } from '@/components/ui/icon';
import { Tooltip } from '@/components/ui/tooltip';
/*
* Name note: toggle.tsx also exports a ToggleGroup (and ToggleGroupProps), the simple segmented one.
* The CLI installs each component into its own file and neither imports the other, so both live
* side by side in one project. To use both in one file, rename on import:
* import { ToggleGroup as RichToggleGroup } from '@/components/ui/toggle-group';
* This one adds looks, sizes, min and max choices, unavailable items and the toolbar pattern.
*/
const TEXTS = {
en: { atMost: 'Up to {n} can be chosen', atLeast: 'At least {n} stays chosen', unavailable: 'unavailable', selected: '{n} chosen' },
de: { atMost: 'Bis zu {n} sind wählbar', atLeast: 'Mindestens {n} bleibt gewählt', unavailable: 'nicht verfügbar', selected: '{n} gewählt' },
};
export interface ToggleGroupItem {
value: string;
label: string;
icon?: IconName;
/** Only the icon shows, the label becomes the name and the tooltip */
iconOnly?: boolean;
/** A fuller name for screen readers when the visible label is short, e.g. Monday for Mo */
srLabel?: string;
/** A second line, for the cards look */
description?: string;
/** A small count or tag after the label, e.g. 24 */
badge?: string;
/** Keyboard shortcut shown in the tooltip, e.g. ["⌘", "B"] */
shortcut?: string[];
disabled?: boolean;
/** Crossed out and not choosable but still reachable, e.g. a sold out size */
unavailable?: boolean;
}
/**
* attached: joined segments with shared borders. separate: single outlined buttons (sizes).
* ghost: quiet toolbar buttons. round: circles (weekdays). cards: larger rows with icon,
* description and a check (layers, channels).
*/
export type ToggleGroupLook = 'attached' | 'separate' | 'ghost' | 'round' | 'cards';
export type ToggleGroupSize = 'sm' | 'md' | 'lg';
export interface ToggleGroupProps {
items: ToggleGroupItem[];
/** single: one value like a radio set. multiple: any number like checkboxes. */
type?: 'single' | 'multiple';
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
/** Accessible name of the group */
label: string;
/** Show the label above the group */
showLabel?: boolean;
look?: ToggleGroupLook;
size?: ToggleGroupSize;
orientation?: 'horizontal' | 'vertical';
/** single: the chosen item can be pressed again to clear it */
allowEmpty?: boolean;
/** multiple: fewest and most chosen values */
min?: number;
max?: number;
/** Tooltips on icon-only items, on by default */
tooltips?: boolean;
/** Items share the full width */
fullWidth?: boolean;
labels?: Partial<(typeof TEXTS)['en']>;
className?: string;
}
/* Inside a ToggleToolbar the toolbar owns the arrow keys across all its groups */
const ToolbarContext = createContext(false);
const fill = (s: string, n: number) => s.replace('{n}', String(n));
function popIcon(button: HTMLElement) {
const svg = button.querySelector('svg[data-tg-icon]');
if (!svg || prefersReducedMotion() || typeof svg.animate !== 'function') return;
svg.animate([{ transform: 'scale(1)' }, { transform: 'scale(0.72)', offset: 0.25 }, { transform: 'scale(1.16)', offset: 0.65 }, { transform: 'scale(1)' }], { duration: 440, easing: 'cubic-bezier(0.2,0.7,0.2,1)' });
}
function nudge(button: HTMLElement) {
if (prefersReducedMotion() || typeof button.animate !== 'function') return;
button.animate([{ transform: 'translateX(0)' }, { transform: 'translateX(-3px)' }, { transform: 'translateX(3px)' }, { transform: 'translateX(-2px)' }, { transform: 'translateX(0)' }], { duration: 300, easing: 'ease-out' });
}
/** Moves focus between the enabled items of a container with arrows, Home and End (roving tabindex) */
function rove(e: React.KeyboardEvent<HTMLElement>, container: HTMLElement | null, orientation: 'horizontal' | 'vertical' | 'both') {
const next = orientation === 'vertical' ? ['ArrowDown'] : orientation === 'horizontal' ? ['ArrowRight'] : ['ArrowRight', 'ArrowDown'];
const prev = orientation === 'vertical' ? ['ArrowUp'] : orientation === 'horizontal' ? ['ArrowLeft'] : ['ArrowLeft', 'ArrowUp'];
if (![...next, ...prev, 'Home', 'End'].includes(e.key) || !container) return;
const list = Array.from(container.querySelectorAll<HTMLElement>('[data-tg-item]:not(:disabled)'));
const i = list.indexOf(document.activeElement as HTMLElement);
if (i < 0) return;
e.preventDefault();
const rtl = getComputedStyle(container).direction === 'rtl' && orientation !== 'vertical';
let step = next.includes(e.key) ? 1 : -1;
if (rtl && (e.key === 'ArrowRight' || e.key === 'ArrowLeft')) step = -step;
const to = e.key === 'Home' ? 0 : e.key === 'End' ? list.length - 1 : (i + step + list.length) % list.length;
list.forEach((el, j) => (el.tabIndex = j === to ? 0 : -1));
list[to].focus();
}
const SIZE: Record<ToggleGroupSize, { h: string; px: string; sq: string; text: string; icon: number; round: string }> = {
sm: { h: 'h-11 sm:h-9', px: 'px-3', sq: 'w-11 sm:w-9', text: 'text-[13px]', icon: 16, round: 'size-11 sm:size-9 text-[13px]' },
md: { h: 'h-11', px: 'px-3.5', sq: 'w-11', text: 'text-sm', icon: 18, // Seven days fit one row on a phone: 38 px circles whose hit area reaches 44 px
round: "size-[38px] text-sm after:absolute after:-inset-[3px] after:content-[''] sm:size-11 sm:after:inset-0" },
lg: { h: 'h-12', px: 'px-4', sq: 'w-12', text: 'text-[15px]', icon: 19, round: 'size-12 text-[15px]' },
};
const FOCUS = 'outline-none focus-visible:z-10 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-ring';
const ITEM: Record<ToggleGroupLook, string> = {
attached: cn(
'text-muted-foreground hover:bg-accent hover:text-foreground',
'aria-pressed:bg-[color-mix(in_oklab,var(--primary)_14%,var(--background))] aria-pressed:text-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_18%,var(--background))] dark:aria-pressed:bg-[color-mix(in_oklab,var(--primary)_24%,var(--background))]',
),
separate: cn(
'rounded-lg border border-border-strong bg-background text-foreground shadow-[var(--shadow-sm)] hover:border-[color-mix(in_oklab,var(--foreground)_40%,var(--border-strong))]',
'aria-pressed:border-primary aria-pressed:bg-[color-mix(in_oklab,var(--primary)_8%,var(--background))] aria-pressed:shadow-[0_0_0_1px_var(--primary),var(--shadow-sm)]',
),
ghost: 'rounded-md text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
round: cn(
'rounded-full border border-border-strong bg-background font-medium text-muted-foreground hover:border-[color-mix(in_oklab,var(--foreground)_40%,var(--border-strong))] hover:text-foreground',
'aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--primary)_88%,var(--foreground))]',
),
cards: cn(
'rounded-xl border border-border bg-card text-left text-foreground shadow-[var(--shadow-sm)] hover:border-border-strong',
'aria-pressed:border-[color-mix(in_oklab,var(--primary)_55%,var(--border))] aria-pressed:bg-[color-mix(in_oklab,var(--primary)_6%,var(--card))]',
),
};
/**
* A group of pressable buttons for one or many values, each with aria-pressed and one tab stop for the
* whole group (roving tabindex). Five looks, horizontal or vertical, tooltips on icon-only items, limits
* for multiple choice, crossed out unavailable items. Several groups form one toolbar in ToggleToolbar.
* <ToggleGroup label="Days" type="multiple" look="round" min={1} items={[{ value: 'mon', label: 'Mo' }, …]} />
*/
export function ToggleGroup({
items,
type = 'single',
value: valueProp,
defaultValue,
onValueChange,
label,
showLabel,
look = 'attached',
size = 'md',
orientation = 'horizontal',
allowEmpty,
min = 0,
max,
tooltips = true,
fullWidth,
labels,
className,
}: ToggleGroupProps) {
const t = useTexts(TEXTS, labels);
const inToolbar = useContext(ToolbarContext);
const id = useId();
const ref = useRef<HTMLDivElement>(null);
const multiple = type === 'multiple';
const [inner, setInner] = useState<string[]>(defaultValue ?? (multiple || allowEmpty ? [] : items[0] ? [items[0].value] : []));
const value = valueProp ?? inner;
const [notice, setNotice] = useState('');
const vertical = orientation === 'vertical';
const s = SIZE[size];
// The tab stop: the first chosen item, else the first that can take focus
const usable = items.map((it, i) => (it.disabled ? -1 : i)).filter((i) => i >= 0);
const firstOn = items.findIndex((it) => !it.disabled && value.includes(it.value));
const [stop, setStop] = useState(firstOn >= 0 ? firstOn : usable[0] ?? 0);
const press = (it: ToggleGroupItem, el: HTMLElement) => {
if (it.disabled || it.unavailable) return nudge(el);
const on = value.includes(it.value);
let next: string[];
if (multiple) {
if (on && value.length <= min) {
setNotice(fill(t.atLeast, min));
return nudge(el);
}
if (!on && max !== undefined && value.length >= max) {
setNotice(fill(t.atMost, max));
return nudge(el);
}
next = on ? value.filter((v) => v !== it.value) : items.filter((x) => x.value === it.value || value.includes(x.value)).map((x) => x.value);
} else {
if (on && !allowEmpty) return;
next = on ? [] : [it.value];
}
setNotice('');
if (!on) popIcon(el);
if (valueProp === undefined) setInner(next);
onValueChange?.(next);
};
useEffect(() => {
if (!notice) return;
const timer = window.setTimeout(() => setNotice(''), 2600);
return () => window.clearTimeout(timer);
}, [notice]);
const attached = look === 'attached';
const cards = look === 'cards';
const button = (it: ToggleGroupItem, i: number) => {
const on = value.includes(it.value);
const iconOnly = Boolean(it.iconOnly && it.icon);
const el = (
<button
key={it.value}
type="button"
data-tg-item=""
aria-pressed={on}
aria-label={iconOnly ? it.label : it.srLabel}
aria-disabled={it.unavailable || undefined}
aria-describedby={it.unavailable ? `${id}-na` : undefined}
disabled={it.disabled}
tabIndex={inToolbar ? undefined : i === stop ? 0 : -1}
onFocus={() => !inToolbar && setStop(i)}
onClick={(e) => press(it, e.currentTarget)}
className={cn(
'group/tg relative inline-flex shrink-0 cursor-pointer select-none items-center justify-center gap-2 whitespace-nowrap font-medium',
'transition-[background-color,color,border-color,box-shadow,scale] duration-200 ease-[var(--ease-kit)] active:scale-[0.96] active:duration-100 motion-reduce:active:scale-100',
'disabled:cursor-not-allowed disabled:opacity-45 aria-disabled:cursor-not-allowed',
FOCUS,
ITEM[look],
look === 'round' ? s.round : cards ? 'min-h-14 w-full justify-start gap-3 px-3.5 py-3 text-sm' : cn(s.h, s.text, iconOnly ? cn(s.sq, 'px-0') : s.px),
fullWidth && !cards && 'flex-1',
attached && (vertical ? 'w-full justify-start' : ''),
it.unavailable && 'text-subtle-foreground hover:text-subtle-foreground',
)}
>
{it.icon && (
<span className={cn(cards && 'grid size-9 shrink-0 place-items-center rounded-lg bg-muted text-muted-foreground transition-colors duration-200 group-aria-pressed/tg:bg-[color-mix(in_oklab,var(--primary)_14%,var(--card))] group-aria-pressed/tg:text-[color-mix(in_oklab,var(--primary)_80%,var(--foreground))]')}>
<Icon data-tg-icon="" name={it.icon} size={cards ? 18 : s.icon} strokeWidth={on ? 1.9 : 1.6} />
</span>
)}
{!iconOnly &&
(cards ? (
<span className="min-w-0 flex-1">
<span className="block truncate font-medium text-foreground">{it.label}</span>
{it.description && <span className="mt-0.5 block whitespace-normal text-[13px] font-normal leading-snug text-muted-foreground">{it.description}</span>}
</span>
) : (
<span className={cn(it.unavailable && 'line-through decoration-[1.5px]')}>{it.label}</span>
))}
{it.badge && !cards && (
<span className={cn('rounded-[5px] px-1.5 py-px text-[11px] font-medium tabular-nums', look === 'round' ? 'hidden' : 'bg-muted text-muted-foreground group-aria-pressed/tg:bg-[color-mix(in_oklab,var(--primary)_16%,var(--background))] group-aria-pressed/tg:text-foreground')}>
{it.badge}
</span>
)}
{cards && (
<span aria-hidden className="grid size-5 shrink-0 place-items-center rounded-full border border-border-strong transition-colors duration-200 group-aria-pressed/tg:border-primary group-aria-pressed/tg:bg-primary group-aria-pressed/tg:text-primary-foreground">
<Icon name="check" size={13} strokeWidth={2.4} className="scale-50 opacity-0 transition-[scale,opacity] duration-300 ease-[cubic-bezier(0.34,1.56,0.64,1)] group-aria-pressed/tg:scale-100 group-aria-pressed/tg:opacity-100 motion-reduce:transition-none" />
</span>
)}
{it.unavailable && <span className="sr-only">, {t.unavailable}</span>}
</button>
);
return iconOnly && tooltips ? (
<Tooltip key={it.value} content={it.label} keys={it.shortcut} side={vertical ? 'right' : 'top'}>
{el}
</Tooltip>
) : (
el
);
};
return (
<div className={cn('grid min-w-0 gap-2.5', fullWidth && 'w-full', className)}>
{showLabel && (
<span id={`${id}-l`} className="text-sm font-medium text-foreground">
{label}
</span>
)}
<div
ref={ref}
role="group"
aria-label={showLabel ? undefined : label}
aria-labelledby={showLabel ? `${id}-l` : undefined}
onKeyDown={inToolbar ? undefined : (e) => rove(e, ref.current, vertical ? 'vertical' : 'horizontal')}
className={cn(
'relative min-w-0',
cards ? 'grid grid-cols-[minmax(0,1fr)] gap-2' : 'flex',
!cards && (vertical ? 'flex-col items-stretch' : 'flex-wrap items-center'),
fullWidth || cards ? 'w-full' : 'w-fit max-w-full',
attached &&
cn(
'flex-nowrap overflow-hidden rounded-lg border border-border-strong bg-background shadow-[var(--shadow-sm)]',
// Hairlines between the segments as shadows on the items, the frame clips the first one
vertical ? '[&_[data-tg-item]]:shadow-[0_-1px_0_var(--border)]' : '[&_[data-tg-item]]:shadow-[-1px_0_0_var(--border)]',
'[&>*]:rounded-none',
),
look === 'separate' && 'gap-2',
look === 'ghost' && 'gap-0.5',
look === 'round' && 'gap-0.5 sm:gap-2',
)}
>
{items.map(button)}
</div>
<span id={`${id}-na`} hidden>
{t.unavailable}
</span>
<p aria-live="polite" className={cn('min-h-0 text-[13px] text-muted-foreground transition-opacity duration-300', notice ? 'opacity-100' : 'sr-only')}>
{notice}
</p>
</div>
);
}
/**
* A toolbar after the APG pattern that holds several groups: one tab stop for all of them,
* arrow keys walk across every item, Home and End jump to the ends.
* <ToggleToolbar label="Formatting"><ToggleGroup … /><ToolbarSeparator /><ToggleGroup … /></ToggleToolbar>
*/
export function ToggleToolbar({ label, orientation = 'horizontal', className, children }: { label: string; orientation?: 'horizontal' | 'vertical'; className?: string; children: React.ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
// One tab stop: the first pressed item, else the first item. Set after mount, so without JavaScript every button stays reachable.
useEffect(() => {
const list = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-tg-item]:not(:disabled)') ?? []);
const start = Math.max(0, list.findIndex((el) => el.getAttribute('aria-pressed') === 'true'));
list.forEach((el, i) => (el.tabIndex = i === start ? 0 : -1));
}, []);
return (
<ToolbarContext.Provider value>
<div
ref={ref}
role="toolbar"
aria-label={label}
aria-orientation={orientation}
onKeyDown={(e) => rove(e, ref.current, orientation)}
onFocus={(e) => {
const list = Array.from(ref.current?.querySelectorAll<HTMLElement>('[data-tg-item]') ?? []);
if (list.includes(e.target as HTMLElement)) list.forEach((el) => (el.tabIndex = el === e.target ? 0 : -1));
}}
className={cn('flex min-w-0 items-center gap-1', orientation === 'vertical' ? 'flex-col' : 'flex-wrap', className)}
>
{children}
</div>
</ToolbarContext.Provider>
);
}
/** A thin line between the groups of a toolbar */
export function ToolbarSeparator({ orientation = 'vertical', className }: { orientation?: 'horizontal' | 'vertical'; className?: string }) {
return <span role="separator" aria-orientation={orientation} className={cn('shrink-0 bg-border', orientation === 'vertical' ? 'mx-1 h-6 w-px' : 'my-1 h-px w-6', className)} />;
}Frequent questions
How do I install Toggle group?
With aiw add @ai-website/toggle-group. The command puts components/ui/toggle-group.tsx into your project, the code is yours from then on. It brings along icon and tooltip. It needs no further npm packages.
Which variants does Toggle group have?
5 variants, Text format toolbar, View switcher, Weekdays, Sizes and Vertical cards.
Can I translate the texts of Toggle group?
Yes. Toggle group carries 4 texts of its own in English and German, such as “unavailable”. The language follows the KitProvider, single texts change through the labels prop.
Can an AI assistant build with Toggle group?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Toggle group with aiw_components and install it with aiw_add @ai-website/toggle-group.