Toggle
A two-state button. Use standalone for a single on/off action, or grouped for related toggles like a formatting toolbar.
import { StarIcon } from '@phosphor-icons/react/dist/ssr';
import { Toggle } from '@/components/toggle';
export default function TogglePreview() {
return (
<Toggle aria-label="Favorite" square>
<StarIcon />
</Toggle>
);
} Dependencies
Source Code
import type { VariantProps } from 'cva';
import {
Children,
createContext,
isValidElement,
use,
useCallback,
useLayoutEffect,
useMemo,
useRef,
useState,
} from 'react';
import { Slot } from '@/components/slot';
import { composeRefs } from '@/lib/compose-refs';
import { cn } from '@/lib/utils/classnames';
import { buttonStyle } from '../button';
type ToggleStyleProps = Pick<
VariantProps<typeof buttonStyle>,
'size' | 'square'
>;
interface ToggleProps
extends Omit<React.ComponentPropsWithRef<'button'>, 'onChange'>,
ToggleStyleProps {
pressed?: boolean;
defaultPressed?: boolean;
onPressedChange?: (pressed: boolean) => void;
asChild?: boolean;
}
const Toggle = ({
pressed: pressedProp,
defaultPressed = false,
onPressedChange,
size = 'md',
square,
asChild = false,
className,
type = 'button',
onClick,
ref,
children,
...props
}: ToggleProps) => {
const [internalPressed, setInternalPressed] = useState(defaultPressed);
const pressed = pressedProp ?? internalPressed;
const Comp = asChild ? Slot : 'button';
return (
<Comp
ref={ref}
type={type}
className={cn(buttonStyle({ variant: 'ghost', size, square }), className)}
aria-pressed={pressed}
data-pressed={pressed || undefined}
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
const next = !pressed;
if (pressedProp === undefined) setInternalPressed(next);
onPressedChange?.(next);
}}
{...props}
>
{children}
</Comp>
);
};
interface ToggleGroupContextValue {
isPressed: (value: string) => boolean;
toggle: (value: string) => void;
tabbableValue: string | undefined;
setTabbableValue: (value: string) => void;
focusItem: (value: string) => void;
registerItem: (value: string) => () => void;
items: string[];
orientation: 'horizontal' | 'vertical';
disabled: boolean;
size: ToggleStyleProps['size'];
}
const ToggleGroupContext = createContext<ToggleGroupContextValue | null>(null);
const useToggleGroupContext = () => {
const ctx = use(ToggleGroupContext);
if (!ctx) {
throw new Error('ToggleGroup.Item must be used within a ToggleGroup');
}
return ctx;
};
type ToggleGroupBaseProps = Omit<
React.ComponentPropsWithRef<'div'>,
'onChange' | 'defaultValue'
> &
Pick<ToggleStyleProps, 'size'> & {
orientation?: 'horizontal' | 'vertical';
disabled?: boolean;
};
type ToggleGroupSingleProps = ToggleGroupBaseProps & {
type: 'single';
value?: string;
defaultValue?: string;
onValueChange?: (value: string | undefined) => void;
};
type ToggleGroupMultipleProps = ToggleGroupBaseProps & {
type: 'multiple';
value?: string[];
defaultValue?: string[];
onValueChange?: (value: string[]) => void;
};
type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps;
const ToggleGroup = (props: ToggleGroupProps) => {
return props.type === 'single' ? (
<ToggleGroupSingle {...props} />
) : (
<ToggleGroupMultiple {...props} />
);
};
const ToggleGroupSingle = ({
type: _type,
value: valueProp,
defaultValue,
onValueChange,
...rest
}: ToggleGroupSingleProps) => {
const [internal, setInternal] = useState<string | undefined>(defaultValue);
const value = valueProp ?? internal;
const isPressed = useCallback((v: string) => value === v, [value]);
const toggle = useCallback(
(v: string) => {
const next = value === v ? undefined : v;
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
},
[value, valueProp, onValueChange]
);
return <ToggleGroupRoot {...rest} isPressed={isPressed} toggle={toggle} />;
};
const ToggleGroupMultiple = ({
type: _type,
value: valueProp,
defaultValue,
onValueChange,
...rest
}: ToggleGroupMultipleProps) => {
const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
const value = valueProp ?? internal;
const isPressed = useCallback((v: string) => value.includes(v), [value]);
const toggle = useCallback(
(v: string) => {
const next = value.includes(v)
? value.filter((x) => x !== v)
: [...value, v];
if (valueProp === undefined) setInternal(next);
onValueChange?.(next);
},
[value, valueProp, onValueChange]
);
return <ToggleGroupRoot {...rest} isPressed={isPressed} toggle={toggle} />;
};
interface ToggleGroupRootProps extends ToggleGroupBaseProps {
isPressed: (value: string) => boolean;
toggle: (value: string) => void;
}
const ToggleGroupRoot = ({
isPressed,
toggle,
orientation = 'horizontal',
disabled = false,
size = 'md',
className,
children,
ref,
...divProps
}: ToggleGroupRootProps) => {
const [items, setItems] = useState<string[]>([]);
const registerItem = useCallback((value: string) => {
setItems((prev) => (prev.includes(value) ? prev : [...prev, value]));
return () => {
setItems((prev) => prev.filter((v) => v !== value));
};
}, []);
const containerRef = useRef<HTMLDivElement>(null);
const focusItem = useCallback((value: string) => {
const el = containerRef.current?.querySelector<HTMLElement>(
`[data-toggle-group-value="${CSS.escape(value)}"]`
);
el?.focus();
}, []);
const [tabbable, setTabbable] = useState<string | undefined>(undefined);
const tabbableValue = useMemo(() => {
if (items.length > 0) {
if (tabbable && items.includes(tabbable)) return tabbable;
const firstPressed = items.find((v) => isPressed(v));
return firstPressed ?? items[0];
}
// Pre-registration (SSR / first paint): items register via useLayoutEffect,
// so on the server they're empty. Walk children directly so the right item
// is the tab stop on the very first render.
const childValues: string[] = [];
Children.forEach(children, (child) => {
if (
isValidElement<{ value?: unknown }>(child) &&
typeof child.props.value === 'string'
) {
childValues.push(child.props.value);
}
});
const firstPressed = childValues.find((v) => isPressed(v));
return firstPressed ?? childValues[0];
}, [tabbable, items, isPressed, children]);
const setTabbableValue = useCallback((value: string) => {
setTabbable(value);
}, []);
const ctx = useMemo<ToggleGroupContextValue>(
() => ({
isPressed,
toggle,
tabbableValue,
setTabbableValue,
focusItem,
registerItem,
items,
orientation,
disabled,
size,
}),
[
isPressed,
toggle,
tabbableValue,
setTabbableValue,
focusItem,
registerItem,
items,
orientation,
disabled,
size,
]
);
return (
<ToggleGroupContext value={ctx}>
{/* biome-ignore lint/a11y/useSemanticElements: <fieldset> is form-only semantics; this is a toolbar-style grouping. */}
<div
ref={composeRefs(containerRef, ref)}
role="group"
data-orientation={orientation}
className={cn(
'flex items-center *:focus-visible:z-2',
orientation === 'vertical' && 'flex-col',
className
)}
data-ui-button-group
{...divProps}
>
{children}
</div>
</ToggleGroupContext>
);
};
interface ToggleGroupItemProps
extends Omit<
React.ComponentPropsWithRef<'button'>,
'value' | 'type' | 'disabled' | 'onChange'
>,
ToggleStyleProps {
value: string;
asChild?: boolean;
disabled?: boolean;
}
const ToggleGroupItem = ({
value,
size: sizeProp,
square,
asChild = false,
className,
disabled: disabledProp,
onClick,
onKeyDown,
onFocus,
ref,
children,
...props
}: ToggleGroupItemProps) => {
const ctx = useToggleGroupContext();
const Comp = asChild ? Slot : 'button';
useLayoutEffect(() => {
return ctx.registerItem(value);
}, [value, ctx.registerItem]);
const pressed = ctx.isPressed(value);
const isTabbable = ctx.tabbableValue === value;
const disabled = disabledProp || ctx.disabled;
const size = sizeProp ?? ctx.size;
const handleKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {
const orientation = ctx.orientation;
const nextKey = orientation === 'horizontal' ? 'ArrowRight' : 'ArrowDown';
const prevKey = orientation === 'horizontal' ? 'ArrowLeft' : 'ArrowUp';
if (
e.key !== nextKey &&
e.key !== prevKey &&
e.key !== 'Home' &&
e.key !== 'End'
) {
return;
}
e.preventDefault();
const idx = ctx.items.indexOf(value);
if (idx === -1) return;
let nextIdx = idx;
if (e.key === nextKey) nextIdx = (idx + 1) % ctx.items.length;
else if (e.key === prevKey)
nextIdx = (idx - 1 + ctx.items.length) % ctx.items.length;
else if (e.key === 'Home') nextIdx = 0;
else if (e.key === 'End') nextIdx = ctx.items.length - 1;
const nextValue = ctx.items[nextIdx];
if (!nextValue) return;
ctx.setTabbableValue(nextValue);
ctx.focusItem(nextValue);
};
return (
<Comp
ref={ref}
type="button"
data-toggle-group-value={value}
className={cn(buttonStyle({ variant: 'ghost', size, square }), className)}
aria-pressed={pressed}
data-pressed={pressed || undefined}
data-disabled={disabled || undefined}
disabled={disabled}
tabIndex={isTabbable ? 0 : -1}
onClick={(e) => {
onClick?.(e);
if (e.defaultPrevented) return;
ctx.toggle(value);
ctx.setTabbableValue(value);
}}
onKeyDown={(e) => {
onKeyDown?.(e);
if (!e.defaultPrevented) handleKeyDown(e);
}}
onFocus={(e) => {
onFocus?.(e);
if (!e.defaultPrevented) ctx.setTabbableValue(value);
}}
{...props}
>
{children}
</Comp>
);
};
const CompoundToggleGroup = Object.assign(ToggleGroup, {
Item: ToggleGroupItem,
});
export type { ToggleGroupItemProps, ToggleGroupProps, ToggleProps };
export { CompoundToggleGroup as ToggleGroup, Toggle }; Anatomy
// Standalone
<Toggle>...</Toggle>
// Grouped
<ToggleGroup>
<ToggleGroup.Item value="...">...</ToggleGroup.Item>
</ToggleGroup>
API Reference
Toggle
A button with an on/off state. Reuses Button’s sizing tokens; the visual is a single ghost-style affordance with a subtle pressed tint. If you need a bordered frame, pass className="border border-border" at the call site.
| Prop | Default | Type | Description |
|---|---|---|---|
pressed | - | boolean | The controlled pressed state. |
defaultPressed | false | boolean | The initial pressed state when uncontrolled. |
onPressedChange | - | (pressed: boolean) => void | Callback fired when the pressed state changes. |
size | "md" | "xs""sm""md""lg" | |
square | false | boolean | |
asChild | - | boolean |
ToggleGroup
A set of related toggles. type="single" enforces at most one pressed item; type="multiple" allows any combination.
| Prop | Default | Type | Description |
|---|---|---|---|
type * | - | "single""multiple" | Whether at most one item or any number of items can be pressed. |
value | - | string | string[] | The controlled value. `string` for `single`, `string[]` for `multiple`. |
defaultValue | - | string | string[] | The initial value when uncontrolled. |
onValueChange | - | (value: string | undefined) => void | (value: string[]) => void | Callback fired when the value changes. Signature depends on `type`. |
orientation | "horizontal" | "horizontal""vertical" | Affects which arrow keys move focus between items. |
disabled | false | boolean | Disables every item in the group. |
size | "md" | "xs""sm""md""lg" | Default size for items. Each item can override. |
ToggleGroup.Item
| Prop | Default | Type | Description |
|---|---|---|---|
value * | - | string | Identifies this item in the group's value. |
disabled | false | boolean | |
size | - | "xs""sm""md""lg" | Overrides the group's `size` for this item. |
square | - | boolean | |
asChild | - | boolean |
Accessibility
Toggles render as <button> with aria-pressed, following the WAI-ARIA Button pattern. Icon-only toggles must include an aria-label.
ToggleGroup uses roving tabindex: the group is a single tab stop, and arrow keys move focus between items (Home/End jump to the ends). The container has role="group".
Don’t change the label between states. If a toggle’s label flips with its state (e.g.
"Mute"↔"Unmute"), droparia-pressedand treat it as a regularButton. Otherwise screen readers announce the state twice.
Examples
Basic
A standalone toggle. Click to flip the pressed state.
import { StarIcon } from '@phosphor-icons/react/dist/ssr';
import { Toggle } from '@/components/toggle';
export default function TogglePreview() {
return (
<Toggle aria-label="Favorite" square>
<StarIcon />
</Toggle>
);
} Sizes
Toggles share Button’s size scale. Use xs/sm for dense toolbars, md (default) for typical inline use, lg when standing alone.
import { StarIcon } from '@phosphor-icons/react/dist/ssr';
import { Toggle } from '@/components/toggle';
export default function ToggleSizesPreview() {
return (
<div className="flex items-center gap-3">
<Toggle aria-label="xs" size="xs" defaultPressed square>
<StarIcon />
</Toggle>
<Toggle aria-label="sm" size="sm" defaultPressed square>
<StarIcon />
</Toggle>
<Toggle aria-label="md" size="md" defaultPressed square>
<StarIcon />
</Toggle>
<Toggle aria-label="lg" size="lg" defaultPressed square>
<StarIcon />
</Toggle>
</div>
);
} Single selection
type="single" — at most one item pressed. Common for mutually-exclusive options like text alignment.
import {
TextAlignCenterIcon,
TextAlignJustifyIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
} from '@phosphor-icons/react/dist/ssr';
import { ToggleGroup } from '@/components/toggle';
export default function ToggleGroupPreview() {
return (
<ToggleGroup type="single" defaultValue="left">
<ToggleGroup.Item value="left" square aria-label="Align left">
<TextAlignLeftIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="center" square aria-label="Align center">
<TextAlignCenterIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="right" square aria-label="Align right">
<TextAlignRightIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="justify" square aria-label="Justify">
<TextAlignJustifyIcon />
</ToggleGroup.Item>
</ToggleGroup>
);
} Multiple selection
type="multiple" — any combination of items pressed. Common for independent options like text formatting.
import {
TextBIcon,
TextItalicIcon,
TextStrikethroughIcon,
TextUnderlineIcon,
} from '@phosphor-icons/react/dist/ssr';
import { ToggleGroup } from '@/components/toggle';
export default function ToggleGroupMultiplePreview() {
return (
<ToggleGroup
type="multiple"
defaultValue={['bold']}
aria-label="Text formatting"
>
<ToggleGroup.Item value="bold" square aria-label="Bold">
<TextBIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="italic" square aria-label="Italic">
<TextItalicIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="underline" square aria-label="Underline">
<TextUnderlineIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="strikethrough" square aria-label="Strikethrough">
<TextStrikethroughIcon />
</ToggleGroup.Item>
</ToggleGroup>
);
} Vertical orientation
Pass orientation="vertical" to stack items. Arrow-key navigation switches to ArrowUp / ArrowDown, and the corner/border merge runs along the vertical axis.
import {
AlignBottomIcon,
AlignCenterVerticalIcon,
AlignTopIcon,
} from '@phosphor-icons/react/dist/ssr';
import { ToggleGroup } from '@/components/toggle';
export default function ToggleGroupVerticalPreview() {
return (
<ToggleGroup
type="single"
defaultValue="top"
orientation="vertical"
aria-label="Vertical alignment"
>
<ToggleGroup.Item value="top" square aria-label="Align top">
<AlignTopIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="middle" square aria-label="Align middle">
<AlignCenterVerticalIcon />
</ToggleGroup.Item>
<ToggleGroup.Item value="bottom" square aria-label="Align bottom">
<AlignBottomIcon />
</ToggleGroup.Item>
</ToggleGroup>
);
} Toolbar
Compose ToggleGroup with Tooltip for a labelled icon toolbar. Multiple groups can sit side-by-side.
import {
TextAlignCenterIcon,
TextAlignLeftIcon,
TextAlignRightIcon,
TextBIcon,
TextItalicIcon,
TextUnderlineIcon,
} from '@phosphor-icons/react/dist/ssr';
import { ToggleGroup } from '@/components/toggle';
import { Tooltip } from '@/components/tooltip';
export default function ToggleToolbarPreview() {
return (
<Tooltip.Group>
<div className="flex items-center gap-2">
<ToggleGroup type="multiple" aria-label="Text formatting">
{[
{ value: 'bold', label: 'Bold', icon: <TextBIcon /> },
{ value: 'italic', label: 'Italic', icon: <TextItalicIcon /> },
{
value: 'underline',
label: 'Underline',
icon: <TextUnderlineIcon />,
},
].map(({ value, label, icon }) => (
<Tooltip key={value}>
<Tooltip.Trigger asChild>
<ToggleGroup.Item value={value} square aria-label={label}>
{icon}
</ToggleGroup.Item>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
))}
</ToggleGroup>
<ToggleGroup
type="single"
defaultValue="left"
aria-label="Text alignment"
>
{[
{ value: 'left', label: 'Align left', icon: <TextAlignLeftIcon /> },
{
value: 'center',
label: 'Align center',
icon: <TextAlignCenterIcon />,
},
{
value: 'right',
label: 'Align right',
icon: <TextAlignRightIcon />,
},
].map(({ value, label, icon }) => (
<Tooltip key={value}>
<Tooltip.Trigger asChild>
<ToggleGroup.Item value={value} square aria-label={label}>
{icon}
</ToggleGroup.Item>
</Tooltip.Trigger>
<Tooltip.Content>{label}</Tooltip.Content>
</Tooltip>
))}
</ToggleGroup>
</div>
</Tooltip.Group>
);
} Previous
Toaster
Next
Tooltip