# Toggle

> Turn a pressed state on or off, alone or within a related group.

Source: https://www.honestui.com/docs/components/toggle

```tsx
import { Bold as BoldIcon, Italic as ItalicIcon } from "honestui/icons";

import { Toggle, ToggleGroup } from "@/components/honest-ui/ui/toggle";

export function ToggleDemo() {
  return (
    <div className="grid gap-4">
      <div className="flex flex-wrap items-center gap-3">
        <Toggle defaultPressed>Preview</Toggle>
        <Toggle size="sm">Compact</Toggle>
        <Toggle size="lg" variant="outline">
          Outline
        </Toggle>
        <Toggle disabled>Unavailable</Toggle>
      </div>
      <ToggleGroup
        defaultValue={["bold", "italic"]}
        multiple
        aria-label="Text formatting"
      >
        <Toggle value="bold" aria-label="Bold">
          <BoldIcon aria-hidden="true" />
        </Toggle>
        <Toggle value="italic" aria-label="Italic">
          <ItalicIcon aria-hidden="true" />
        </Toggle>
      </ToggleGroup>
    </div>
  );
}

```

## Overview [#overview]

Use Toggle for a button whose on/off state is visible in the surrounding content: text formatting, layer visibility, mute controls, view options. The state is the point — pressing changes something the person can see right where they are.

Use Switch instead when the control reads as a setting that applies elsewhere, and Button for actions that run once. A toggle is a question of state ("is bold on?"), not an order ("do this now").

## Anatomy [#anatomy]

A single toggle is a native `<button>` wrapping a content span that carries the pressed surface treatment. Styling reacts to a `data-pressed` attribute, and the underlying Base UI primitive always emits `aria-pressed`, so assistive technology hears "pressed" or "not pressed" without you writing any ARIA yourself.

A toggle group is a container (`role="group"`) holding several toggles plus optional separators. Inside a group, items inherit the group's `variant` and `size` through context and lose their own borders and radius so they read as one segmented control.

## Pressed state [#pressed-state]

Standalone toggles accept `pressed` / `defaultPressed` and report `onPressedChange(pressed, eventDetails)`. In a group, each item gets a `value`; the group holds an array of currently pressed values and exposes `value` / `defaultValue` / `onValueChange(groupValue, eventDetails)` for controlled use.

**Single mode** (the default, `multiple={false}`) keeps at most one item pressed — but empty is allowed. Pressing the pressed item clears it, which makes it a filter, not a radio group.

**Multiple mode** (`multiple`) lets any combination stay pressed independently, like formatting buttons.

## Sizes and variants [#sizes-and-variants]

Sizes map to the design system's space scale: `"1"` is 12 px tall, `"2"` is 16 px, `"3"` is 20 px, and `"4"` is 24 px. The named aliases are `sm` (matches `"3"` at 20 px) and `default` (24 px) — and `lg` is currently identical in height to `default`, a quirk we document rather than hide, so don't reach for `lg` expecting a bigger control. The `outline` variant swaps the filled surface for a bordered one; inside a group it outlines the whole container instead of each item.

## Accessibility [#accessibility]

Keyboard follows native button behavior: <kbd>Tab</kbd> reaches a standalone toggle and <kbd>Enter</kbd>/<kbd>Space</kbd> press it. Focus rings appear only for keyboard focus.

In a group, Base UI implements roving focus: only one item is in the tab order, and <kbd>←</kbd><kbd>→</kbd> (horizontal groups) or <kbd>↑</kbd><kbd>↓</kbd> (vertical, `orientation="vertical"`) move focus between items, looping by default (`loopFocus`). Pressing still uses <kbd>Enter</kbd>/<kbd>Space</kbd>, and each press updates every item's `aria-pressed`.

Icon-only toggles have no text content, so give each one an `aria-label`. Because pressed state is also conveyed visually through background and inset shadow — not color alone — the state survives color-vision differences. Colors come from theme tokens, so dark mode needs no extra work, and inline-flex layout mirrors automatically in right-to-left locales. Long labels grow the button rather than wrap.

Target sizes are honest about their limits: sizes `"1"`–`"3"` render 12–20 px tall, below the WCAG 2.2 minimum target of 24 × 24 px. Reserve them for dense desktop toolbars paired with tooltips; use `default` or larger on touch surfaces.

## Installation [#installation]


  

  
    <CliBlock commands="[&#x22;toggle&#x22;]" />
  

  
    
      
        Install the following dependencies:
      

      ```bash
      npm install @base-ui-components/react
      ```

      
        Copy and paste the following code into your project.
      

      ### components/ui/toggle.tsx

```tsx
"use client"

import * as React from "react"
import { Toggle as TogglePrimitive } from "@base-ui-components/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui-components/react/toggle-group"
import { cva, type VariantProps } from "class-variance-authority"

import { cn } from "@/lib/utils"
import { Separator } from "@/components/ui/separator"

const toggleVariants = cva(
  "box-border inline-flex w-auto shrink-0 cursor-pointer items-center justify-center rounded-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-secondary)] p-[var(--hui-space-1)] text-[var(--hui-color-foreground-base-secondary)] outline-none [font-size:var(--hui-font-size-small)] [font-weight:var(--hui-font-weight-medium)] [letter-spacing:var(--hui-letter-spacing-small)] [line-height:var(--hui-line-height-small)] [transition:color_var(--hui-duration-normal)_var(--hui-ease-out)] hover:text-[var(--hui-color-foreground-base-primary)] data-pressed:text-[var(--hui-color-foreground-base-primary)] data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:opacity-50 focus-visible:[outline:var(--hui-focus-ring)] [&:hover>[data-slot=toggle-content]]:bg-[var(--hui-color-background-base-primary-hover)] data-pressed:[&>[data-slot=toggle-content]]:bg-[var(--hui-color-background-neutral-secondary)] data-pressed:[&>[data-slot=toggle-content]]:shadow-[var(--hui-shadow-inset)] data-pressed:[&:hover>[data-slot=toggle-content]]:bg-[var(--hui-color-background-neutral-secondary-hover)] [&[data-disabled]:not([data-pressed]):hover]:text-[var(--hui-color-foreground-base-secondary)] [&[data-disabled]:not([data-pressed]):hover>[data-slot=toggle-content]]:bg-transparent [&[data-disabled]:not([data-pressed]):hover>[data-slot=toggle-content]]:shadow-none [&[data-disabled][data-pressed]:hover>[data-slot=toggle-content]]:bg-[var(--hui-color-background-neutral-secondary)]",
  {
    variants: {
      variant: {
        default: null,
        outline: null,
      },
      size: {
        "1": "h-[var(--hui-space-4)] min-w-[var(--hui-space-4)]",
        "2": "h-[var(--hui-space-5)] min-w-[var(--hui-space-5)]",
        "3": "h-[var(--hui-space-6)] min-w-[var(--hui-space-6)]",
        "4": "h-[var(--hui-space-7)] min-w-[var(--hui-space-7)]",
        sm: "h-[var(--hui-space-6)] min-w-[var(--hui-space-6)]",
        default: "h-[var(--hui-space-7)] min-w-[var(--hui-space-7)]",
        lg: "h-[var(--hui-space-7)] min-w-[var(--hui-space-7)]",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

const ToggleGroupContext =
  React.createContext<VariantProps<typeof toggleVariants> | null>(null)

function Toggle({
  className,
  children,
  variant,
  size,
  ...props
}: TogglePrimitive.Props & VariantProps<typeof toggleVariants>) {
  const context = React.useContext(ToggleGroupContext)

  const resolvedVariant = context?.variant ?? variant
  const resolvedSize = context?.size ?? size

  return (
    <TogglePrimitive
      data-slot="toggle"
      data-variant={resolvedVariant}
      data-size={resolvedSize}
      className={cn(
        toggleVariants({ variant: resolvedVariant, size: resolvedSize }),
        context &&
          "rounded-none border-0 bg-transparent focus-visible:outline-offset-[var(--hui-focus-ring-offset-inset-border)] hover:bg-[var(--hui-color-background-base-primary-hover)] data-pressed:bg-[var(--hui-color-background-base-primary)] data-pressed:shadow-[var(--hui-shadow-inset)] data-pressed:[&>[data-slot=toggle-content]]:bg-transparent data-pressed:[&>[data-slot=toggle-content]]:shadow-none data-pressed:[&:hover>[data-slot=toggle-content]]:bg-transparent data-disabled:not-data-pressed:hover:bg-transparent data-disabled:not-data-pressed:hover:shadow-none motion-safe:[transition:color_var(--hui-duration-normal)_var(--hui-ease-out),background-color_var(--hui-duration-fast)_var(--hui-ease-out),box-shadow_var(--hui-duration-fast)_var(--hui-ease-out)]",
        className
      )}
      {...props}
    >
      <span
        data-slot="toggle-content"
        className="flex h-full min-w-0 flex-1 items-center justify-center rounded-[var(--hui-radius-1)] motion-safe:[transition:background-color_var(--hui-duration-fast)_var(--hui-ease-out),box-shadow_var(--hui-duration-fast)_var(--hui-ease-out)] [&>svg]:max-h-full [&>svg]:max-w-full [&>svg]:shrink-0"
      >
        {children}
      </span>
    </TogglePrimitive>
  )
}

function ToggleGroup({
  className,
  variant = "default",
  size = "default",
  children,
  ...props
}: ToggleGroupPrimitive.Props & VariantProps<typeof toggleVariants>) {
  return (
    <ToggleGroupPrimitive
      data-slot="toggle-group"
      data-variant={variant}
      data-size={size}
      className={cn(
        "inline-flex w-fit items-center gap-px overflow-clip rounded-[var(--hui-radius-1)] border-[0.5px] border-[var(--hui-color-border-base-primary)] bg-[var(--hui-color-background-base-secondary)] p-[var(--hui-space-1)] data-[orientation=vertical]:flex-col data-disabled:pointer-events-auto data-disabled:cursor-not-allowed data-disabled:opacity-50",
        className
      )}
      {...props}
    >
      <ToggleGroupContext.Provider value={{ variant, size }}>
        {children}
      </ToggleGroupContext.Provider>
    </ToggleGroupPrimitive>
  )
}

function ToggleGroupSeparator({ className, ...props }: { className?: string }) {
  return <Separator orientation="vertical" className={className} {...props} />
}

export {
  Toggle,
  ToggleGroup,
  Toggle as ToggleGroupItem,
  ToggleGroupSeparator,
  toggleVariants,
}

```

      
        Update the import paths to match your project setup.
      
    
  


## Usage [#usage]

```tsx
import { Toggle, ToggleGroup } from "@/components/ui/toggle";
```

```tsx
<Toggle defaultPressed>Bold</Toggle>
```

```tsx
<ToggleGroup defaultValue={["bold"]} multiple>
  <Toggle value="bold" aria-label="Bold">
    <BoldIcon />
  </Toggle>
  <Toggle value="italic" aria-label="Italic">
    <ItalicIcon />
  </Toggle>
</ToggleGroup>
```

`ToggleGroupItem` is an alias of `Toggle`, and `toggleVariants` is exported for composing custom toggle-styled elements.

## Don't do this [#dont-do-this]

### Hand-rolling a toggle from a plain button [#hand-rolling-a-toggle-from-a-plain-button]

```tsx
// Bad
const [on, setOn] = useState(false);
<Button variant={on ? "secondary" : "ghost"} onClick={() => setOn(!on)}>
  Bold
</Button>
```

```tsx
// Good
<Toggle pressed={on} onPressedChange={setOn}>Bold</Toggle>
```

The hand-rolled version announces nothing: no `aria-pressed`, no state change for screen readers — people activate it and hear silence. It also loses the disabled-cursor handling and keyboard guarantees the primitive provides. Base UI's Toggle already emits `aria-pressed` on every render; let it.

### Treating a single-selection group as a radio choice [#treating-a-single-selection-group-as-a-radio-choice]

```tsx
// Bad
<ToggleGroup value={plan} onValueChange={setPlan} aria-label="Plan">
  <Toggle value="starter">Starter</Toggle>
  <Toggle value="pro">Pro</Toggle>
</ToggleGroup>
```

```tsx
// Good
<RadioGroup value={plan} onValueChange={setPlan} aria-label="Plan">
  <Radio value="starter">Starter</Radio>
  <Radio value="pro">Pro</Radio>
</RadioGroup>
```

Single mode permits an empty group — clicking the pressed item unpresses it. That is correct for filters ("clear this facet") and wrong for required choices like plan selection, where "no answer" must not be one click away. Use a RadioGroup when exactly one option must always hold.

### Icon-only toggles without names [#icon-only-toggles-without-names]

```tsx
// Bad
<Toggle value="bold"><BoldIcon /></Toggle>
```

```tsx
// Good
<Toggle value="bold" aria-label="Bold">
  <BoldIcon aria-hidden="true" />
</Toggle>
```

An unnamed icon-only toggle reads as just "toggle button, not pressed" — which button? Voice-control users have nothing meaningful to say, and screen-reader users guess from position alone. Name every icon-only toggle; mark its icon decorative.

## Examples [#examples]

### View mode switcher [#view-mode-switcher]

A single-selection group where exactly one view can be active. Remember the empty-selection caveat: if an unpressed group would break your grid, enforce a value in `onValueChange`.

```tsx
import { Grid2x2 as Grid2X2Icon, List as ListIcon } from "honestui/icons"

import { Toggle, ToggleGroup } from "@/components/honest-ui/ui/toggle"

export function ToggleViewMode() {
  return (
    <ToggleGroup defaultValue={["grid"]} variant="outline">
      <Toggle value="grid" aria-label="Grid view"><Grid2X2Icon /></Toggle>
      <Toggle value="list" aria-label="List view"><ListIcon /></Toggle>
    </ToggleGroup>
  )
}

```

### Multiple selection [#multiple-selection]

Independent pressed values, like formatting controls.

```tsx
import { Bold as BoldIcon, Italic as ItalicIcon, Underline as UnderlineIcon } from "honestui/icons"

import { Toggle, ToggleGroup } from "@/components/honest-ui/ui/toggle"

export function ToggleGroupMultiple() {
  return (
    <ToggleGroup defaultValue={["bold"]} multiple>
      <Toggle value="bold" aria-label="Toggle bold">
        <BoldIcon />
      </Toggle>
      <Toggle value="italic" aria-label="Toggle italic">
        <ItalicIcon />
      </Toggle>
      <Toggle value="underline" aria-label="Toggle underline">
        <UnderlineIcon />
      </Toggle>
    </ToggleGroup>
  )
}

```

### Outline variant with separators [#outline-variant-with-separators]

The outline style draws one border around the whole group; separators divide items that need visual distinction.

```tsx
import { Bold as BoldIcon, Italic as ItalicIcon, Underline as UnderlineIcon } from "honestui/icons"

import {
  Toggle,
  ToggleGroup,
  ToggleGroupSeparator,
} from "@/components/honest-ui/ui/toggle"

export function ToggleGroupOutlineWithSeparator() {
  return (
    <ToggleGroup variant="outline" defaultValue={["bold"]}>
      <Toggle value="bold" aria-label="Toggle bold">
        <BoldIcon />
      </Toggle>
      <ToggleGroupSeparator />
      <Toggle value="italic" aria-label="Toggle italic">
        <ItalicIcon />
      </Toggle>
      <ToggleGroupSeparator />
      <Toggle value="underline" aria-label="Toggle underline">
        <UnderlineIcon />
      </Toggle>
    </ToggleGroup>
  )
}

```

### One disabled item [#one-disabled-item]

A group where one choice is temporarily unavailable; the rest keep working.

```tsx
import { Bold as BoldIcon, Italic as ItalicIcon, Underline as UnderlineIcon } from "honestui/icons"

import { Toggle, ToggleGroup } from "@/components/honest-ui/ui/toggle"

export function ToggleGroupWithDisabledItem() {
  return (
    <ToggleGroup defaultValue={["bold"]}>
      <Toggle value="bold" aria-label="Toggle bold">
        <BoldIcon />
      </Toggle>
      <Toggle value="italic" aria-label="Toggle italic" disabled>
        <ItalicIcon />
      </Toggle>
      <Toggle value="underline" aria-label="Toggle underline">
        <UnderlineIcon />
      </Toggle>
    </ToggleGroup>
  )
}

```

### Icon labels with tooltips [#icon-labels-with-tooltips]

Tooltips explain icon-only toggles on hover and focus, while `aria-label` remains the accessible name.

```tsx
import { Bold as BoldIcon, Italic as ItalicIcon, Underline as UnderlineIcon } from "honestui/icons"

import { Toggle, ToggleGroup } from "@/components/honest-ui/ui/toggle"
import {
  Tooltip,
  TooltipPopup,
  TooltipProvider,
  TooltipTrigger,
} from "@/components/honest-ui/ui/tooltip"

export function TooltipDemo() {
  return (
    <TooltipProvider>
      <ToggleGroup defaultValue={["bold"]} multiple>
        <Tooltip>
          <TooltipTrigger
            render={<Toggle value="bold" aria-label="Toggle bold" />}
          >
            <BoldIcon />
          </TooltipTrigger>
          <TooltipPopup>Bold</TooltipPopup>
        </Tooltip>
        <Tooltip>
          <TooltipTrigger
            render={
              <Toggle value="italic" aria-label="Toggle italic" />
            }
          >
            <ItalicIcon />
          </TooltipTrigger>
          <TooltipPopup>Italic</TooltipPopup>
        </Tooltip>
        <Tooltip>
          <TooltipTrigger
            render={
              <Toggle value="underline" aria-label="Toggle underline" />
            }
          >
            <UnderlineIcon />
          </TooltipTrigger>
          <TooltipPopup>Underline</TooltipPopup>
        </Tooltip>
      </ToggleGroup>
    </TooltipProvider>
  )
}

```

## API reference [#api-reference]

`Toggle` accepts Base UI Toggle props plus Honest UI additions:

| Prop                         | Values                                     | Default   |
| ---------------------------- | ------------------------------------------ | --------- |
| `variant`                    | `default`, `outline`                       | `default` |
| `size`                       | `1`, `2`, `3`, `4`, `sm`, `default`, `lg`  | `default` |
| `pressed` / `defaultPressed` | boolean                                    | `false`   |
| `onPressedChange`            | `(pressed: boolean, eventDetails) => void` | —         |
| `value`                      | string (identity inside a group)           | —         |
| `disabled`                   | boolean                                    | `false`   |

Disabled toggles intentionally keep `pointer-events: auto` so hovering shows a not-allowed cursor even though clicks do nothing. The rendered element is a native `<button>`; `type` cannot be changed, and the component never participates in form validation.

`ToggleGroup` accepts Base UI Toggle Group props:

| Prop                     | Values                                            | Default      |
| ------------------------ | ------------------------------------------------- | ------------ |
| `variant` / `size`       | same values as `Toggle`, shared with children     | `default`    |
| `value` / `defaultValue` | array of pressed item values                      | `[]`         |
| `onValueChange`          | `(groupValue: any[], eventDetails) => void`       | —            |
| `multiple`               | boolean — `false` allows at most one pressed item | `false`      |
| `orientation`            | `horizontal`, `vertical`                          | `horizontal` |
| `loopFocus`              | boolean — wrap arrow-key focus                    | `true`       |
| `disabled`               | boolean — disables every item                     | `false`      |

`ToggleGroupSeparator` renders a vertical separator between items. See the [Base UI Toggle API](https://base-ui.com/react/components/toggle#api-reference) and [Toggle Group API](https://base-ui.com/react/components/toggle-group#api-reference).
