Toggle
Buttons that stay pressed, toggle groups for one or many values and a segmented control with a sliding thumb.
One of 6 components in Buttons
Details
What Toggle brings along, read from the registry and the source.
- Category
- Buttons
- Area
- Elements
- Variants
- 3
- Version
- 1.0.0 · since 1.0.0
- File
- components/ui/toggle.tsx
- Brings along
- utilskiticon
- npm packages
- None
- Languages
- Texts come through props
- Studio
- Adjust in code
- Build with AI
- Over MCP in Claude, Cursor, Copilot and Codex
Props
The ToggleProps interface from components/ui/toggle.tsx, with the comments from the code. Also takes every prop of Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'>.
pressedoptionalbooleandefaultPressedoptionalbooleanonPressedChangeoptional(pressed: boolean) => voidiconoptionalIconNamelabeloptionalstringRequired when there is no visible text
sizeoptionalToggleSizesmmdlg
variantoptionalToggleVariantghostoutline
Accessibility
Read from the source of the component.
- Toggle handles the keys Arrow up, Arrow down, Arrow left, Arrow right, Home, End and Space itself.
- Sets aria-checked, aria-label and aria-pressed, so screen readers announce purpose and state.
- Uses the roles radio and segmented.
- 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. 3 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, 257 lines
'use client';
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { cn, prefersReducedMotion } from '@/lib/kit';
// useLayoutEffect warns on the server, the measurement only matters in the browser
const useIsoLayoutEffect = typeof window === 'undefined' ? useEffect : useLayoutEffect;
/** A spring pop on the icon of a toggle that was just pressed */
function popIcon(button: HTMLElement) {
const svg = button.querySelector('svg');
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)' });
}
import { Icon, type IconName } from '@/components/ui/icon';
export type ToggleSize = 'sm' | 'md' | 'lg';
export type ToggleVariant = 'ghost' | 'outline';
const SIZE: Record<ToggleSize, { box: string; square: string; icon: number }> = {
sm: { box: 'h-9 gap-1.5 rounded-md px-2.5 text-[13px] after:-inset-y-1', square: 'w-9 px-0', icon: 16 },
md: { box: 'h-11 gap-2 rounded-lg px-3.5 text-sm', square: 'w-11 px-0', icon: 18 },
lg: { box: 'h-12 gap-2 rounded-lg px-4 text-[15px]', square: 'w-12 px-0', icon: 19 },
};
const VARIANT: Record<ToggleVariant, string> = {
ghost: 'text-muted-foreground hover:bg-accent hover:text-foreground aria-pressed:bg-accent aria-pressed:text-foreground',
outline:
'border border-border-strong bg-background text-muted-foreground shadow-[var(--shadow-sm)] hover:text-foreground aria-pressed:border-[color-mix(in_oklab,var(--primary)_45%,var(--border-strong))] aria-pressed:bg-[color-mix(in_oklab,var(--primary)_8%,var(--background))] aria-pressed:text-foreground',
};
export interface ToggleProps extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'onChange'> {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
icon?: IconName;
/** Required when there is no visible text */
label?: string;
size?: ToggleSize;
variant?: ToggleVariant;
}
/** A button that stays pressed: bold on/off, favorite, mute. Uses aria-pressed. */
export function Toggle({ pressed, defaultPressed, onPressedChange, icon, label, size = 'md', variant = 'ghost', className, children, onClick, ...rest }: ToggleProps) {
const [inner, setInner] = useState(defaultPressed ?? false);
const on = pressed ?? inner;
return (
<button
type="button"
{...rest}
aria-pressed={on}
aria-label={children ? undefined : label}
onClick={(e) => {
onClick?.(e);
if (!on) popIcon(e.currentTarget);
setInner(!on);
onPressedChange?.(!on);
}}
className={cn(
'relative inline-flex shrink-0 select-none items-center justify-center font-medium transition-[background-color,color,border-color,box-shadow,scale] duration-200 ease-[var(--ease-kit)] after:absolute after:inset-x-0 after:content-[""] active:scale-[0.94] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
SIZE[size].box,
!children && SIZE[size].square,
VARIANT[variant],
className,
)}
>
{icon && <Icon name={icon} size={SIZE[size].icon} strokeWidth={on ? 1.9 : 1.6} />}
{children}
</button>
);
}
export interface ToggleOption {
value: string;
label: string;
icon?: IconName;
/** Show only the icon, the label becomes the accessible name */
iconOnly?: boolean;
disabled?: boolean;
}
interface GroupBase {
options: ToggleOption[];
/** Accessible name of the group */
label: string;
size?: ToggleSize;
/** segmented: one track with a sliding thumb. outline: separate pressed buttons. ghost: quiet toolbar (single choice gets a sliding mark too). */
variant?: 'segmented' | 'outline' | 'ghost';
/** Stretch over the full width, options share it equally */
fullWidth?: boolean;
className?: string;
}
export type ToggleGroupProps =
| (GroupBase & { type?: 'single'; value?: string; defaultValue?: string; onValueChange?: (value: string) => void })
| (GroupBase & { type: 'multiple'; value?: string[]; defaultValue?: string[]; onValueChange?: (value: string[]) => void });
const SEG_SIZE: Record<ToggleSize, { track: string; item: string; square: string; icon: number }> = {
sm: { track: 'h-9 rounded-md p-[3px]', item: 'h-full gap-1.5 rounded-[5px] px-2.5 text-[13px]', square: 'w-[30px] px-0', icon: 15 },
md: { track: 'h-11 rounded-lg p-1', item: 'h-full gap-2 rounded-md px-3.5 text-sm', square: 'w-9 px-0', icon: 17 },
lg: { track: 'h-12 rounded-lg p-1', item: 'h-full gap-2 rounded-md px-4 text-[15px]', square: 'w-10 px-0', icon: 18 },
};
/**
* A set of toggles. single: like radio buttons (view switch, alignment). multiple: like checkboxes (bold, italic).
* Arrow keys move between options, the group is one tab stop.
*/
export function ToggleGroup(props: ToggleGroupProps) {
const { options, label, size = 'md', variant = 'segmented', fullWidth, className } = props;
const multiple = props.type === 'multiple';
const [inner, setInner] = useState<string[]>(() => {
const d = props.defaultValue;
return d === undefined ? (multiple ? [] : options[0] ? [options[0].value] : []) : Array.isArray(d) ? d : [d];
});
const controlled = props.value !== undefined;
const selected = controlled ? (Array.isArray(props.value) ? props.value : [props.value as string]) : inner;
const trackRef = useRef<HTMLDivElement>(null);
const [thumb, setThumb] = useState<{ x: number; w: number; ready: boolean; animate: boolean } | null>(null);
const [focusIndex, setFocusIndex] = useState(() => Math.max(0, options.findIndex((o) => selected.includes(o.value))));
const set = (value: string) => {
let next: string[];
if (multiple) next = selected.includes(value) ? selected.filter((v) => v !== value) : [...selected, value];
else next = [value];
if (!controlled) setInner(next);
if (multiple) (props.onValueChange as ((v: string[]) => void) | undefined)?.(next);
else (props.onValueChange as ((v: string) => void) | undefined)?.(value);
};
const segmented = variant === 'segmented' && !multiple;
// One mark slides between the options: the thumb of the segmented control, the pressed surface of a single ghost group
const sliding = !multiple && (variant === 'segmented' || variant === 'ghost');
const active = sliding ? selected[0] : undefined;
const measure = useCallback(
(animate: boolean) => {
const el = trackRef.current?.querySelector<HTMLElement>(`[data-value="${CSS.escape(active ?? '')}"]`);
if (!el) return setThumb(null);
setThumb({ x: el.offsetLeft, w: el.offsetWidth, ready: true, animate });
},
[active],
);
const first = useRef(true);
useIsoLayoutEffect(() => {
if (!sliding) return;
measure(!first.current);
first.current = false;
const ro = new ResizeObserver(() => measure(false));
if (trackRef.current) ro.observe(trackRef.current);
return () => ro.disconnect();
}, [sliding, measure]);
const onKeyDown = (e: React.KeyboardEvent) => {
const keys = ['ArrowRight', 'ArrowLeft', 'ArrowDown', 'ArrowUp', 'Home', 'End'];
if (!keys.includes(e.key)) return;
e.preventDefault();
const enabled = options.map((o, i) => (o.disabled ? -1 : i)).filter((i) => i >= 0);
const pos = enabled.indexOf(focusIndex);
const next =
e.key === 'Home' ? enabled[0] : e.key === 'End' ? enabled[enabled.length - 1] : enabled[(pos + (e.key === 'ArrowRight' || e.key === 'ArrowDown' ? 1 : -1) + enabled.length) % enabled.length];
setFocusIndex(next);
trackRef.current?.querySelectorAll<HTMLButtonElement>('button')[next]?.focus();
if (segmented) set(options[next].value);
};
const s = SEG_SIZE[size];
return (
<div
ref={trackRef}
role={segmented ? 'radiogroup' : 'group'}
aria-label={label}
onKeyDown={onKeyDown}
className={cn(
'relative isolate inline-flex items-center',
fullWidth && 'flex w-full',
variant === 'segmented' && cn('bg-muted shadow-[inset_0_0_0_1px_var(--border)]', s.track),
variant === 'outline' && 'gap-2',
variant === 'ghost' && 'gap-1',
className,
)}
>
{sliding && thumb?.ready && (
<span
aria-hidden
className={cn(
'absolute left-0 -z-10',
segmented
? cn(
'bottom-[var(--pad)] top-[var(--pad)] bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)] dark:bg-[color-mix(in_oklab,var(--foreground)_10%,var(--background))]',
size === 'sm' ? 'rounded-[5px] [--pad:3px]' : 'rounded-md [--pad:4px]',
)
: cn('inset-y-0 bg-accent', size === 'sm' ? 'rounded-md' : 'rounded-lg'),
// A light overshoot, the thumb arrives like a physical slider
thumb.animate && 'transition-[translate,width] duration-[420ms] ease-[cubic-bezier(0.3,1.3,0.5,1)] motion-reduce:transition-none',
)}
style={{ translate: `${thumb.x}px 0`, width: thumb.w }}
/>
)}
{options.map((o, i) => {
const on = selected.includes(o.value);
const iconOnly = o.iconOnly && o.icon;
if (variant !== 'segmented' || multiple) {
return (
<Toggle
key={o.value}
data-value={o.value}
pressed={on}
onPressedChange={() => set(o.value)}
icon={o.icon}
label={o.label}
size={size}
variant={variant === 'outline' ? 'outline' : 'ghost'}
disabled={o.disabled}
tabIndex={i === focusIndex ? 0 : -1}
onFocus={() => setFocusIndex(i)}
// The sliding mark carries the pressed surface once it is measured
className={cn(fullWidth && 'flex-1', sliding && thumb?.ready && 'aria-pressed:bg-transparent')}
>
{iconOnly ? undefined : o.label}
</Toggle>
);
}
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={on}
aria-label={iconOnly ? o.label : undefined}
data-value={o.value}
disabled={o.disabled}
tabIndex={i === focusIndex ? 0 : -1}
onFocus={() => setFocusIndex(i)}
onClick={(e) => {
if (!on) popIcon(e.currentTarget);
set(o.value);
}}
className={cn(
'relative inline-flex select-none items-center justify-center whitespace-nowrap font-medium text-muted-foreground outline-none transition-[color,scale] duration-200 hover:text-foreground active:scale-[0.96] active:duration-100 disabled:pointer-events-none disabled:opacity-45 motion-reduce:active:scale-100',
'focus-visible:ring-2 focus-visible:ring-ring',
s.item,
iconOnly && s.square,
fullWidth && 'flex-1',
on && 'text-foreground',
// Until the thumb is measured (server render, no JavaScript) the selected item carries the surface itself
on && !thumb?.ready && 'bg-background shadow-[0_0_0_1px_var(--border),var(--shadow-sm)]',
)}
>
{o.icon && <Icon name={o.icon} size={s.icon} />}
{!iconOnly && o.label}
</button>
);
})}
</div>
);
}Frequent questions
How do I install Toggle?
With aiw add @ai-website/toggle. The command puts components/ui/toggle.tsx into your project, the code is yours from then on. It brings along icon. It needs no further npm packages.
Which variants does Toggle have?
3 variants, Segmented, Toolbar and Single toggles. You pick one with the variant prop, for example variant="outline".
Can an AI assistant build with Toggle?
Yes. Through the MCP server of the aiw CLI, Claude, Cursor, GitHub Copilot, Windsurf, Codex and Gemini find Toggle with aiw_components and install it with aiw_add @ai-website/toggle. Name the variant in your prompt, for example variant="ghost", and the assistant writes your content straight into the props.