# Toggle Group

> Single and multiple selection button group with roving tabindex, sizes and outline/ghost variants.

- Author: Atelier UI
- License: MIT
- Page: https://chromotion.dev/en/components/toggle-group
- Site: Atelier UI (https://chromotion.dev)

## Install

```bash
npx shadcn@latest add https://chromotion.dev/r/toggle-group.json
```

Stack: React + TypeScript + Tailwind CSS v4, shadcn/ui conventions. `cn` comes from `@/lib/utils`.

## Dependencies

No extra dependencies.

## Usage

```tsx title="components/toggle-group-demo.tsx"
"use client"

import * as React from "react"
import { AlignCenter, AlignLeft, AlignRight, Bold, Columns3, Italic, LayoutGrid, List, Underline } from "lucide-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/toggle-group"
import { cn } from "@/lib/utils"

export default function ToggleGroupDemo() {
  const [format, setFormat] = React.useState<string[]>(["bold"])
  const [align, setAlign] = React.useState("left")
  const [view, setView] = React.useState("grid")

  return (
    <div className="grid w-full max-w-md grid-cols-[minmax(0,1fr)] gap-6">
      <section className="grid min-w-0 gap-3 rounded-2xl border bg-card p-4 shadow-sm">
        <div className="flex flex-wrap items-center gap-2">
          <ToggleGroup type="multiple" variant="outline" aria-label="Text formatting" value={format} onValueChange={setFormat}>
            <ToggleGroupItem value="bold" aria-label="Bold">
              <Bold />
            </ToggleGroupItem>
            <ToggleGroupItem value="italic" aria-label="Italic">
              <Italic />
            </ToggleGroupItem>
            <ToggleGroupItem value="underline" aria-label="Underline">
              <Underline />
            </ToggleGroupItem>
          </ToggleGroup>
          <ToggleGroup type="single" aria-label="Text alignment" value={align} onValueChange={setAlign}>
            <ToggleGroupItem value="left" aria-label="Align left">
              <AlignLeft />
            </ToggleGroupItem>
            <ToggleGroupItem value="center" aria-label="Align center">
              <AlignCenter />
            </ToggleGroupItem>
            <ToggleGroupItem value="right" aria-label="Align right">
              <AlignRight />
            </ToggleGroupItem>
          </ToggleGroup>
        </div>
        <p
          className={cn(
            "rounded-lg border bg-background p-3 text-sm",
            format.includes("bold") && "font-bold",
            format.includes("italic") && "italic",
            format.includes("underline") && "underline",
            align === "center" && "text-center",
            align === "right" && "text-right"
          )}
        >
          The quick brown fox jumps over the lazy dog.
        </p>
      </section>

      <section className="grid min-w-0 gap-3">
        <p className="text-sm font-medium text-muted-foreground">View switcher</p>
        <div className="flex items-center justify-between gap-3">
          <ToggleGroup type="single" variant="ghost" aria-label="View" value={view} onValueChange={setView}>
            <ToggleGroupItem value="list">
              <List /> List
            </ToggleGroupItem>
            <ToggleGroupItem value="grid">
              <LayoutGrid /> Grid
            </ToggleGroupItem>
            <ToggleGroupItem value="board">
              <Columns3 /> Board
            </ToggleGroupItem>
          </ToggleGroup>
          <span className="text-xs text-muted-foreground" aria-live="polite">
            {view} view
          </span>
        </div>
      </section>

      <section className="grid min-w-0 gap-3">
        <p className="text-sm font-medium text-muted-foreground">Sizes</p>
        <div className="flex flex-wrap items-center gap-3">
          {(["sm", "md", "lg"] as const).map((size) => (
            <ToggleGroup key={size} type="single" variant="outline" size={size} aria-label={`Density ${size}`} defaultValue="b">
              <ToggleGroupItem value="a">A</ToggleGroupItem>
              <ToggleGroupItem value="b">B</ToggleGroupItem>
              <ToggleGroupItem value="c" disabled>
                C
              </ToggleGroupItem>
            </ToggleGroup>
          ))}
        </div>
      </section>
    </div>
  )
}
```

## Source

```tsx title="components/toggle-group.tsx"
"use client"

import * as React from "react"

import { cn } from "@/lib/utils"

export type ToggleGroupVariant = "default" | "outline" | "ghost"
export type ToggleGroupSize = "sm" | "md" | "lg"

interface ToggleGroupContextValue {
  multiple: boolean
  selected: string[]
  toggle: (value: string) => void
  variant: ToggleGroupVariant
  size: ToggleGroupSize
  orientation: "horizontal" | "vertical"
  disabled: boolean
  tabStop: string | undefined
  setFocusValue: (value: string) => void
}

const ToggleGroupContext = React.createContext<ToggleGroupContextValue | null>(null)

interface ToggleGroupBaseProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange"> {
  variant?: ToggleGroupVariant
  size?: ToggleGroupSize
  orientation?: "horizontal" | "vertical"
  disabled?: boolean
  /** Accessible name for the group */
  "aria-label"?: string
}

export type ToggleGroupProps = ToggleGroupBaseProps &
  (
    | {
        /** One pressed item at a time (radiogroup semantics) */
        type?: "single"
        value?: string
        defaultValue?: string
        onValueChange?: (value: string) => void
      }
    | {
        /** Any number of pressed items (aria-pressed buttons) */
        type: "multiple"
        value?: string[]
        defaultValue?: string[]
        onValueChange?: (value: string[]) => void
      }
  )

function toArray(value: string | string[] | undefined) {
  if (value === undefined) return []
  return (Array.isArray(value) ? value : [value]).filter(Boolean)
}

/** Direct children must be <ToggleGroupItem value="…"> elements so the roving tab stop can be computed. */
export function ToggleGroup(props: ToggleGroupProps) {
  const {
    type = "single",
    value,
    defaultValue,
    onValueChange,
    variant = "default",
    size = "md",
    orientation = "horizontal",
    disabled = false,
    className,
    children,
    ...rest
  } = props as ToggleGroupBaseProps & {
    type?: "single" | "multiple"
    value?: string | string[]
    defaultValue?: string | string[]
    onValueChange?: (value: string | string[]) => void
  }
  const multiple = type === "multiple"
  const [inner, setInner] = React.useState(() => toArray(defaultValue))
  const [focusValue, setFocusValue] = React.useState<string>()
  const selected = value !== undefined ? toArray(value) : inner

  const enabledValues = React.Children.toArray(children)
    .filter((child): child is React.ReactElement<{ value?: string; disabled?: boolean }> => React.isValidElement(child))
    .filter((child) => child.props.value && !child.props.disabled && !disabled)
    .map((child) => child.props.value as string)
  const tabStop =
    focusValue && enabledValues.includes(focusValue)
      ? focusValue
      : (selected.find((v) => enabledValues.includes(v)) ?? enabledValues[0])

  function toggle(itemValue: string) {
    let next: string[]
    if (multiple) {
      next = selected.includes(itemValue) ? selected.filter((v) => v !== itemValue) : [...selected, itemValue]
    } else {
      if (selected[0] === itemValue) return
      next = [itemValue]
    }
    if (value === undefined) setInner(next)
    onValueChange?.(multiple ? next : next[0])
  }

  const ctx: ToggleGroupContextValue = {
    multiple,
    selected,
    toggle,
    variant,
    size,
    orientation,
    disabled,
    tabStop,
    setFocusValue,
  }

  return (
    <ToggleGroupContext.Provider value={ctx}>
      <div
        role={multiple ? "group" : "radiogroup"}
        aria-orientation={multiple ? undefined : orientation}
        aria-disabled={disabled || undefined}
        {...rest}
        className={cn(
          "inline-flex max-w-full",
          orientation === "vertical" ? "flex-col" : "flex-row flex-wrap",
          variant === "default" && "gap-0.5 rounded-lg bg-muted p-0.5",
          variant === "outline" && "flex-nowrap rounded-lg shadow-xs",
          variant === "ghost" && "gap-1",
          className
        )}
      >
        {children}
      </div>
    </ToggleGroupContext.Provider>
  )
}

const sizes: Record<ToggleGroupSize, string> = {
  sm: "h-8 min-w-8 gap-1.5 px-2.5 text-xs",
  md: "h-9 min-w-9 gap-2 px-3 text-sm",
  lg: "h-11 min-w-11 gap-2 px-4 text-base",
}

export interface ToggleGroupItemProps extends Omit<React.ComponentProps<"button">, "value"> {
  value: string
}

export function ToggleGroupItem({ value, disabled, className, children, onClick, onFocus, onKeyDown, ...props }: ToggleGroupItemProps) {
  const ctx = React.useContext(ToggleGroupContext)
  if (!ctx) throw new Error("ToggleGroupItem must be used inside <ToggleGroup>")

  const pressed = ctx.selected.includes(value)
  const isDisabled = disabled || ctx.disabled
  const vertical = ctx.orientation === "vertical"

  function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
    onKeyDown?.(event)
    if (event.defaultPrevented) return
    const items = Array.from(
      event.currentTarget.parentElement?.querySelectorAll<HTMLButtonElement>("[data-toggle-item]:not(:disabled)") ?? []
    )
    const index = items.indexOf(event.currentTarget)
    if (index < 0) return
    const prevKey = vertical ? "ArrowUp" : "ArrowLeft"
    const nextKey = vertical ? "ArrowDown" : "ArrowRight"
    let target: number | null = null
    if (event.key === nextKey) target = (index + 1) % items.length
    else if (event.key === prevKey) target = (index - 1 + items.length) % items.length
    else if (event.key === "Home") target = 0
    else if (event.key === "End") target = items.length - 1
    if (target === null) return
    event.preventDefault()
    items[target].focus()
    // Radio semantics: moving focus selects.
    if (!ctx!.multiple) items[target].click()
  }

  return (
    <button
      type="button"
      {...props}
      data-toggle-item=""
      data-state={pressed ? "on" : "off"}
      role={ctx.multiple ? undefined : "radio"}
      aria-pressed={ctx.multiple ? pressed : undefined}
      aria-checked={ctx.multiple ? undefined : pressed}
      tabIndex={ctx.tabStop === value ? 0 : -1}
      disabled={isDisabled}
      onClick={(event) => {
        onClick?.(event)
        if (!event.defaultPrevented) ctx.toggle(value)
      }}
      onFocus={(event) => {
        onFocus?.(event)
        ctx.setFocusValue(value)
      }}
      onKeyDown={handleKeyDown}
      className={cn(
        "relative inline-flex shrink-0 items-center justify-center font-medium whitespace-nowrap transition-colors focus-visible:z-10 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-ring/40 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
        sizes[ctx.size],
        ctx.variant === "default" &&
          cn("rounded-md", pressed ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"),
        ctx.variant === "outline" &&
          cn(
            "rounded-none border bg-background",
            vertical
              ? "first:rounded-t-lg last:rounded-b-lg [&:not(:first-child)]:-mt-px"
              : "first:rounded-l-lg last:rounded-r-lg [&:not(:first-child)]:-ml-px",
            pressed ? "bg-accent text-accent-foreground" : "text-muted-foreground hover:bg-muted hover:text-foreground"
          ),
        ctx.variant === "ghost" &&
          cn("rounded-lg", pressed ? "bg-muted text-foreground" : "text-muted-foreground hover:bg-muted/60 hover:text-foreground"),
        className
      )}
    >
      {children}
    </button>
  )
}
```
