Agents (llms.txt)
Octocat

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"), drop aria-pressed and treat it as a regular Button. 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