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={asChild ? undefined : 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={asChild ? undefined : "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.

PropDefaultTypeDescription
pressed-booleanThe controlled pressed state.
defaultPressedfalsebooleanThe initial pressed state when uncontrolled.
onPressedChange-(pressed: boolean) => voidCallback fired when the pressed state changes.
size"md""xs""sm""md""lg"
squarefalseboolean
asChild-boolean

ToggleGroup

A set of related toggles. type="single" enforces at most one pressed item; type="multiple" allows any combination.

PropDefaultTypeDescription
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[]) => voidCallback fired when the value changes. Signature depends on `type`.
orientation"horizontal""horizontal""vertical"Affects which arrow keys move focus between items.
disabledfalsebooleanDisables every item in the group.
size"md""xs""sm""md""lg"Default size for items. Each item can override.

ToggleGroup.Item

PropDefaultTypeDescription
value *-stringIdentifies this item in the group's value.
disabledfalseboolean
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