# Toggle group

> A row of toggles for one choice or many, with a fill that slides to the picked item, joined or spaced items and arrow-key focus.

Docs: https://hextaui.com/docs/toggle-group
Markdown: https://hextaui.com/docs/toggle-group.md

```tsx title="components/examples/toggle-group/demo.tsx"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupDemo() {
  return (
    <ToggleGroup aria-label="Date range" defaultValue={["week"]}>
      <ToggleGroupItem value="day">Day</ToggleGroupItem>
      <ToggleGroupItem value="week">Week</ToggleGroupItem>
      <ToggleGroupItem value="month">Month</ToggleGroupItem>
      <ToggleGroupItem value="year">Year</ToggleGroupItem>
    </ToggleGroup>
  )
}
```

## Installation

### CLI

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

This adds the component, the HextaUI theme tokens and any HextaUI components it depends on.

### Manual

Add the HextaUI theme tokens (https://hextaui.com/docs/installation#theme) to your global CSS if you haven't yet, then install the dependencies.

```bash
pnpm add @base-ui/react class-variance-authority cn
```

Copy and paste the following code into your project.

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

import * as React from "react"
import {
  DirectionProvider,
  useDirection,
} from "@base-ui/react/direction-provider"
import { Toggle as TogglePrimitive } from "@base-ui/react/toggle"
import { ToggleGroup as ToggleGroupPrimitive } from "@base-ui/react/toggle-group"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"

import { toggleVariants } from "@/components/ui/toggle"
import { prefersReducedMotion } from "@/lib/motion"

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

type ToggleGroupVariant = NonNullable<
  VariantProps<typeof toggleVariants>["variant"]
>
type ToggleGroupSize = NonNullable<VariantProps<typeof toggleVariants>["size"]>
type ToggleGroupSpacing = 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 4 | 5 | 6 | 8
type ToggleGroupOrientation = "horizontal" | "vertical"

const spacingClassNames: Record<ToggleGroupSpacing, string> = {
  0: "gap-0",
  0.5: "gap-0.5",
  1: "gap-1",
  1.5: "gap-1.5",
  2: "gap-2",
  2.5: "gap-2.5",
  3: "gap-3",
  4: "gap-4",
  5: "gap-5",
  6: "gap-6",
  8: "gap-8",
}

type ToggleGroupContextValue = {
  variant?: ToggleGroupVariant
  size?: ToggleGroupSize
  spacing: ToggleGroupSpacing
  orientation: ToggleGroupOrientation
  joined: boolean
  sliding: boolean
}

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

const pressedSelector = ":scope > [data-slot=toggle-group-item][data-pressed]"

function useToggleGroupIndicator(
  rootRef: React.RefObject<HTMLElement | null>,
  indicatorRef: React.RefObject<HTMLElement | null>,
  enabled: boolean
) {
  React.useLayoutEffect(() => {
    const root = rootRef.current
    const indicator = indicatorRef.current
    if (!enabled || !root || !indicator) {
      return
    }

    let current: HTMLElement | null = null

    const place = (item: HTMLElement, instant: boolean) => {
      indicator.toggleAttribute(
        "data-instant",
        instant || prefersReducedMotion()
      )
      indicator.toggleAttribute(
        "data-disabled",
        item.hasAttribute("data-disabled")
      )
      const frame = root.getBoundingClientRect()
      const box = item.getBoundingClientRect()
      const layoutWidth = parseFloat(getComputedStyle(root).width)
      const scale =
        layoutWidth > 0 && frame.width > 0 ? frame.width / layoutWidth : 1
      const style = getComputedStyle(item)
      const width = parseFloat(style.width)
      const height = parseFloat(style.height)
      indicator.style.width = `${Number.isFinite(width) ? width : box.width / scale}px`
      indicator.style.height = `${Number.isFinite(height) ? height : box.height / scale}px`
      indicator.style.translate = `${(box.left - frame.left) / scale - root.clientLeft}px ${(box.top - frame.top) / scale - root.clientTop}px`
      indicator.style.borderRadius = style.borderRadius
    }

    const sync = () => {
      const item = root.querySelector<HTMLElement>(pressedSelector)
      if (item === current) {
        if (item) {
          place(item, true)
        }
        return
      }
      const appearing = current === null
      current = item
      if (!item) {
        indicator.removeAttribute("data-visible")
        return
      }
      place(item, appearing)
      if (appearing) {
        void indicator.offsetWidth
      }
      indicator.setAttribute("data-visible", "")
    }

    sync()

    let second = 0
    const first = requestAnimationFrame(() => {
      second = requestAnimationFrame(() =>
        indicator.setAttribute("data-ready", "")
      )
    })

    const mutations = new MutationObserver(sync)
    mutations.observe(root, {
      subtree: true,
      childList: true,
      characterData: true,
      attributes: true,
      attributeFilter: ["data-pressed", "data-disabled", "data-size"],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(root)

    return () => {
      cancelAnimationFrame(first)
      cancelAnimationFrame(second)
      mutations.disconnect()
      resize?.disconnect()
      indicator.removeAttribute("data-ready")
      indicator.removeAttribute("data-visible")
    }
  }, [rootRef, indicatorRef, enabled])
}

function isFocusVisible(element: HTMLElement) {
  try {
    return element.matches(":focus-visible")
  } catch {
    return true
  }
}

function moveFocusToPressed(root: HTMLElement, event: React.FocusEvent) {
  const target = event.target as HTMLElement
  if (
    target.parentElement !== root ||
    target.getAttribute("data-slot") !== "toggle-group-item" ||
    target.hasAttribute("data-pressed") ||
    root.contains(event.relatedTarget as Node | null) ||
    !isFocusVisible(target)
  ) {
    return
  }
  root
    .querySelector<HTMLElement>(
      ":scope > [data-slot=toggle-group-item][data-pressed]:not([data-disabled])"
    )
    ?.focus()
}

type ToggleGroupProps<Value extends string = string> =
  ToggleGroupPrimitive.Props<Value> &
    VariantProps<typeof toggleVariants> & {
      spacing?: ToggleGroupSpacing
      orientation?: ToggleGroupOrientation
    }

function ToggleGroup<Value extends string = string>({
  className,
  variant,
  size,
  spacing = 0,
  orientation = "horizontal",
  multiple = false,
  children,
  ref,
  onFocus,
  ...props
}: ToggleGroupProps<Value>) {
  const rootRef = React.useRef<HTMLDivElement | null>(null)
  const indicatorRef = React.useRef<HTMLSpanElement | null>(null)
  const inherited = useDirection()
  const [domDirection, setDomDirection] = React.useState<"ltr" | "rtl">()
  const direction = domDirection === "rtl" ? "rtl" : inherited

  const resolvedVariant = variant ?? "default"
  const joined = spacing === 0
  const outlineJoined = joined && resolvedVariant === "outline"
  const sliding = !multiple && (joined || resolvedVariant !== "outline")

  useToggleGroupIndicator(rootRef, indicatorRef, sliding)

  const setRef = React.useCallback(
    (node: HTMLDivElement | null) => {
      rootRef.current = node
      if (typeof ref === "function") {
        return ref(node)
      }
      if (ref) {
        ref.current = node
      }
      return undefined
    },
    [ref]
  )

  const context = React.useMemo<ToggleGroupContextValue>(
    () => ({
      variant: variant ?? undefined,
      size: size ?? undefined,
      spacing,
      orientation,
      joined,
      sliding,
    }),
    [variant, size, spacing, orientation, joined, sliding]
  )

  const vertical = orientation === "vertical"

  return (
    <DirectionProvider direction={direction}>
      <ToggleGroupContext.Provider value={context}>
        <ToggleGroupPrimitive
          ref={setRef}
          multiple={multiple}
          orientation={orientation}
          onFocus={(event) => {
            onFocus?.(event)
            const next =
              getComputedStyle(event.currentTarget).direction === "rtl"
                ? "rtl"
                : "ltr"
            if (next !== domDirection) {
              setDomDirection(next)
            }
            if (!multiple) {
              moveFocusToPressed(event.currentTarget, event)
            }
          }}
          className={mergeClassName(
            cn(
              "group/toggle-group relative isolate flex w-fit max-w-full min-w-0 items-center [--toggle-group-radius:var(--radius-md)] data-[orientation=vertical]:flex-col data-[orientation=vertical]:items-stretch",
              size === "sm" &&
                "[--toggle-group-radius:min(var(--radius-md),10px)]",
              spacingClassNames[spacing] ?? "gap-0",
              !joined && !vertical && "flex-wrap",
              outlineJoined &&
                "rounded-(--toggle-group-radius) bg-background dark:bg-input/30 [&>[data-slot=toggle-group-item]]:rounded-none",
              outlineJoined &&
                !vertical &&
                "[&>[data-slot=toggle-group-item]:not(:has(~[data-slot=toggle-group-item]))]:rounded-e-(--toggle-group-radius) [&>[data-slot=toggle-group-item]:not([data-slot=toggle-group-item]~*)]:rounded-s-(--toggle-group-radius) [&>[data-slot=toggle-group-item]~[data-slot=toggle-group-item]]:-ms-(--hairline)",
              outlineJoined &&
                vertical &&
                "[&>[data-slot=toggle-group-item]:not(:has(~[data-slot=toggle-group-item]))]:rounded-b-(--toggle-group-radius) [&>[data-slot=toggle-group-item]:not([data-slot=toggle-group-item]~*)]:rounded-t-(--toggle-group-radius) [&>[data-slot=toggle-group-item]~[data-slot=toggle-group-item]]:-mt-(--hairline)"
            ),
            className
          )}
          {...props}
          data-slot="toggle-group"
          data-variant={resolvedVariant}
          data-size={size ?? "default"}
          data-spacing={spacing}
        >
          {children}
          {sliding ? (
            <span
              ref={indicatorRef}
              aria-hidden="true"
              data-slot="toggle-group-indicator"
              className="pointer-events-none absolute top-0 left-0 -z-1 box-border bg-foreground/8 bg-clip-content p-(--hairline) opacity-0 data-ready:transition-[translate,width,height,border-radius,opacity,scale] data-ready:duration-300 data-ready:ease-out-quint data-ready:data-instant:transition-[opacity,scale] data-ready:data-instant:duration-200 data-visible:opacity-100 motion-safe:scale-[0.88] motion-safe:group-has-[>[data-slot=toggle-group-item][data-pressed]:active]/toggle-group:scale-[0.97] motion-safe:data-visible:scale-100 motion-reduce:data-ready:transition-opacity dark:bg-foreground/12 data-visible:data-disabled:opacity-50"
            />
          ) : null}
        </ToggleGroupPrimitive>
      </ToggleGroupContext.Provider>
    </DirectionProvider>
  )
}

type ToggleGroupItemProps<Value extends string = string> =
  TogglePrimitive.Props<Value> & VariantProps<typeof toggleVariants>

function ToggleGroupItem<Value extends string = string>({
  className,
  variant,
  size,
  ...props
}: ToggleGroupItemProps<Value>) {
  const context = React.useContext(ToggleGroupContext)
  if (!context) {
    throw new Error("ToggleGroupItem must be used within ToggleGroup.")
  }
  const resolvedVariant = context.variant ?? variant ?? "default"
  const resolvedSize = context.size ?? size ?? "default"
  const vertical = context.orientation === "vertical"
  const outlineJoined = context.joined && resolvedVariant === "outline"

  return (
    <TogglePrimitive
      className={mergeClassName(
        cn(
          toggleVariants({ variant: resolvedVariant, size: resolvedSize }),
          "pointer-coarse:in-data-[slot=toggle-group]:after:block",
          vertical
            ? "pointer-coarse:after:inset-y-0"
            : "pointer-coarse:after:inset-x-0",
          outlineJoined &&
            "bg-transparent motion-safe:active:not-data-disabled:translate-y-0 motion-safe:active:not-data-disabled:scale-100 dark:bg-transparent",
          context.sliding &&
            "group-has-[>[data-slot=toggle-group-indicator][data-visible]]/toggle-group:before:opacity-0 group-has-[>[data-slot=toggle-group-indicator][data-visible]]/toggle-group:before:transition-none data-pressed:hover:bg-transparent dark:data-pressed:hover:bg-transparent forced-colors:border-0"
        ),
        className
      )}
      {...props}
      data-slot="toggle-group-item"
      data-variant={resolvedVariant}
      data-size={resolvedSize}
    />
  )
}

export { ToggleGroup, ToggleGroupItem }
export type {
  ToggleGroupItemProps,
  ToggleGroupOrientation,
  ToggleGroupProps,
  ToggleGroupSize,
  ToggleGroupSpacing,
  ToggleGroupVariant,
}
```

```tsx title="components/ui/toggle.tsx"
"use client"

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

type ClassName<State> =
  string | ((state: State) => string | undefined) | undefined

function mergeClassName<State>(base: string, className: ClassName<State>) {
  return typeof className === "function"
    ? (state: State) => cn(base, className(state))
    : cn(base, className)
}

const toggleVariants = cva(
  "group/toggle relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md bg-clip-padding text-sm font-medium whitespace-nowrap text-muted-foreground inset-ring-(length:--hairline) inset-ring-transparent transition-[color,background-color,box-shadow,opacity,translate,scale] duration-200 ease-out-quint outline-none select-none before:pointer-events-none before:absolute before:inset-(--hairline) before:-z-1 before:rounded-[inherit] before:bg-foreground/8 before:opacity-0 before:transition-[opacity,scale] before:duration-200 before:ease-out-quint hover:text-foreground focus-visible:z-10 focus-visible:ring-3 focus-visible:ring-focus-ring focus-visible:inset-ring-ring focus-visible:outline-hidden active:duration-100 aria-invalid:ring-3 aria-invalid:ring-destructive/20 aria-invalid:inset-ring-destructive data-pressed:text-foreground data-pressed:before:opacity-100 motion-safe:before:scale-[0.88] motion-safe:active:not-data-disabled:translate-y-px motion-safe:active:not-data-disabled:scale-[0.97] motion-safe:data-pressed:before:scale-100 dark:before:bg-foreground/12 dark:aria-invalid:ring-destructive/40 dark:aria-invalid:inset-ring-destructive/50 forced-colors:border forced-colors:before:hidden forced-colors:data-pressed:outline-2 forced-colors:data-pressed:-outline-offset-2 forced-colors:data-pressed:outline-solid pointer-coarse:after:absolute pointer-coarse:in-data-[slot=toggle-group]:after:hidden data-disabled:pointer-events-none data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg]:transition-[color,fill] [&_svg]:duration-200 [&_svg]:ease-out-quint [&_svg:not([class*='size-'])]:size-4",
  {
    variants: {
      variant: {
        default: "bg-transparent hover:bg-muted dark:hover:bg-muted/50",
        outline:
          "bg-background inset-ring-border hover:bg-muted dark:bg-input/30 dark:inset-ring-input dark:hover:bg-input/50",
      },
      size: {
        default: "h-9 min-w-9 px-2 pointer-coarse:after:-inset-1",
        sm: "h-8 min-w-8 rounded-[min(var(--radius-md),10px)] px-1.5 pointer-coarse:after:-inset-1.5",
        lg: "h-10 min-w-10 px-2.5 pointer-coarse:after:-inset-0.5",
      },
    },
    defaultVariants: {
      variant: "default",
      size: "default",
    },
  }
)

type ToggleProps<Value extends string = string> = TogglePrimitive.Props<Value> &
  VariantProps<typeof toggleVariants>

function Toggle<Value extends string = string>({
  className,
  variant = "default",
  size = "default",
  ...props
}: ToggleProps<Value>) {
  return (
    <TogglePrimitive
      data-variant={variant}
      data-size={size}
      className={mergeClassName(toggleVariants({ variant, size }), className)}
      {...props}
      data-slot="toggle"
    />
  )
}

export { Toggle, toggleVariants }
export type { ToggleProps }
```

```ts title="lib/motion.ts"
import * as React from "react"

const easeOut = "cubic-bezier(0.23, 1, 0.32, 1)"
const easeInOut = "cubic-bezier(0.77, 0, 0.175, 1)"
const easeSpring =
  "linear(0, 0.015 2%, 0.0532 4%, 0.1065 6%, 0.1686 8%, 0.2351 10%, 0.3363 13%, 0.4332 16%, 0.5495 20%, 0.648 24%, 0.7287 28%, 0.807 33%, 0.8646 38%, 0.9128 44%, 0.9447 50%, 0.968 57%, 0.9834 65%, 0.9924 74%, 0.9975 85%, 1)"

const duration = {
  press: 100,
  release: 200,
  hover: 150,
  enter: 200,
  exit: 150,
  morph: 300,
} as const

function prefersReducedMotion() {
  return (
    typeof window === "undefined" ||
    typeof window.matchMedia !== "function" ||
    window.matchMedia("(prefers-reduced-motion: reduce)").matches
  )
}

type SizeAxis = "width" | "height"

type SizeMorphOptions = {
  axis: SizeAxis
  enabled?: boolean
  duration?: number
  easing?: string
}

function readSize(element: HTMLElement, axis: SizeAxis) {
  const value = parseFloat(getComputedStyle(element)[axis])
  if (Number.isFinite(value)) {
    return value
  }
  const rect = element.getBoundingClientRect()
  return axis === "width" ? rect.width : rect.height
}

function attachSizeMorph(
  element: HTMLElement,
  axis: SizeAxis,
  time: number,
  easing: string
) {
  if (
    typeof MutationObserver === "undefined" ||
    typeof element.animate !== "function"
  ) {
    return undefined
  }

  let settled = readSize(element, axis)
  let animation: Animation | null = null

  const morph = () => {
    const from = animation ? readSize(element, axis) : settled
    animation?.cancel()
    animation = null
    const to = readSize(element, axis)
    settled = to

    if (
      Math.abs(from - to) < 0.5 ||
      prefersReducedMotion() ||
      !element.isConnected ||
      element.getClientRects().length === 0
    ) {
      element.removeAttribute("data-morphing")
      return
    }

    element.setAttribute("data-morphing", "")
    const running = element.animate(
      [{ [axis]: `${from}px` }, { [axis]: `${to}px` }],
      { duration: time, easing }
    )
    animation = running
    running.onfinish = () => {
      if (animation === running) {
        animation = null
        element.removeAttribute("data-morphing")
        settled = readSize(element, axis)
      }
    }
  }

  const mutations = new MutationObserver(morph)
  mutations.observe(element, {
    childList: true,
    subtree: true,
    characterData: true,
  })

  const resize =
    typeof ResizeObserver === "undefined"
      ? null
      : new ResizeObserver(() => {
          if (!animation) {
            settled = readSize(element, axis)
          }
        })
  resize?.observe(element)

  return () => {
    mutations.disconnect()
    resize?.disconnect()
    animation?.cancel()
    element.removeAttribute("data-morphing")
  }
}

function useSizeMorph<T extends HTMLElement>({
  axis,
  enabled = true,
  duration: time = duration.morph,
  easing = easeOut,
}: SizeMorphOptions): React.RefCallback<T> {
  return React.useCallback(
    (element: T | null) => {
      if (!element || !enabled) {
        return undefined
      }
      return attachSizeMorph(element, axis, time, easing)
    },
    [axis, enabled, time, easing]
  )
}

function useSlidingHighlight(
  barRef: React.RefObject<HTMLElement | null>,
  highlightRef: React.RefObject<HTMLElement | null>,
  selector: string,
  attribute = "data-popup-open"
) {
  React.useLayoutEffect(() => {
    const bar = barRef.current
    const highlight = highlightRef.current
    if (!bar || !highlight) {
      return
    }

    let current: HTMLElement | null = null

    const place = (trigger: HTMLElement, instant: boolean) => {
      if (instant || prefersReducedMotion()) {
        highlight.setAttribute("data-instant", "")
      } else {
        highlight.removeAttribute("data-instant")
      }
      const frame = bar.getBoundingClientRect()
      const box = trigger.getBoundingClientRect()
      const scale = bar.offsetWidth > 0 ? frame.width / bar.offsetWidth : 1
      highlight.style.left = "0px"
      highlight.style.width = `${box.width / scale}px`
      highlight.style.height = `${box.height / scale}px`
      highlight.style.transform = `translate(${(box.left - frame.left) / scale - bar.clientLeft}px, ${(box.top - frame.top) / scale - bar.clientTop}px)`
    }

    const sync = () => {
      const trigger = bar.querySelector<HTMLElement>(selector)
      if (trigger === current) {
        if (trigger) {
          place(trigger, true)
        }
        return
      }
      const appearing = current === null
      current = trigger
      if (!trigger) {
        highlight.removeAttribute("data-visible")
        return
      }
      place(trigger, appearing)
      if (appearing) {
        void highlight.offsetWidth
      }
      highlight.setAttribute("data-visible", "")
    }

    sync()
    const mutations = new MutationObserver(sync)
    mutations.observe(bar, {
      subtree: true,
      childList: true,
      attributes: true,
      attributeFilter: [attribute],
    })
    const resize =
      typeof ResizeObserver === "undefined"
        ? null
        : new ResizeObserver(() => {
            if (current) {
              place(current, true)
            }
          })
    resize?.observe(bar)
    const onScroll = () => {
      if (current) {
        place(current, true)
      }
    }
    bar.addEventListener("scroll", onScroll, { capture: true, passive: true })

    return () => {
      mutations.disconnect()
      resize?.disconnect()
      bar.removeEventListener("scroll", onScroll, { capture: true })
    }
  }, [barRef, highlightRef, selector, attribute])
}

export {
  duration,
  easeInOut,
  easeOut,
  easeSpring,
  prefersReducedMotion,
  useSizeMorph,
  useSlidingHighlight,
}
export type { SizeMorphOptions }
```

Update the import paths to match your project setup.

## Usage

```tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
```

```tsx
<ToggleGroup aria-label="Date range" defaultValue={["week"]}>
  <ToggleGroupItem value="day">Day</ToggleGroupItem>
  <ToggleGroupItem value="week">Week</ToggleGroupItem>
  <ToggleGroupItem value="month">Month</ToggleGroupItem>
</ToggleGroup>
```

When one item can be on at a time, a single fill slides to the picked item and takes its shape, so the group reads as one control. With `multiple`, each item fills on its own. The fill is placed before the first paint, so it never slides in on load.

## Composition

```text
ToggleGroup
└── ToggleGroupItem
```

## Examples

### Outline

`variant="outline"` joins the items into one bordered control.

```tsx title="components/examples/toggle-group/outline.tsx"
import {
  IconAlignCenter,
  IconAlignJustified,
  IconAlignLeft,
  IconAlignRight,
} from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupOutline() {
  return (
    <ToggleGroup
      variant="outline"
      aria-label="Text alignment"
      defaultValue={["left"]}
    >
      <ToggleGroupItem value="left" aria-label="Align left">
        <IconAlignLeft />
      </ToggleGroupItem>
      <ToggleGroupItem value="center" aria-label="Align center">
        <IconAlignCenter />
      </ToggleGroupItem>
      <ToggleGroupItem value="right" aria-label="Align right">
        <IconAlignRight />
      </ToggleGroupItem>
      <ToggleGroupItem value="justify" aria-label="Justify">
        <IconAlignJustified />
      </ToggleGroupItem>
    </ToggleGroup>
  )
}
```

### Multiple

`multiple` lets any number of items be on, like text formatting.

```tsx title="components/examples/toggle-group/multiple.tsx"
import {
  IconBold,
  IconItalic,
  IconStrikethrough,
  IconUnderline,
} from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupMultiple() {
  return (
    <ToggleGroup
      multiple
      variant="outline"
      aria-label="Text formatting"
      defaultValue={["bold", "italic"]}
    >
      <ToggleGroupItem value="bold" aria-label="Bold">
        <IconBold />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Italic">
        <IconItalic />
      </ToggleGroupItem>
      <ToggleGroupItem value="underline" aria-label="Underline">
        <IconUnderline />
      </ToggleGroupItem>
      <ToggleGroupItem value="strikethrough" aria-label="Strikethrough">
        <IconStrikethrough />
      </ToggleGroupItem>
    </ToggleGroup>
  )
}
```

### Sizes

`size` on the group sets every item: `sm`, `default` or `lg`.

```tsx title="components/examples/toggle-group/sizes.tsx"
import { IconBold, IconItalic, IconUnderline } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupSizes() {
  return (
    <div className="flex flex-col items-center gap-4">
      {(["sm", "default", "lg"] as const).map((size) => (
        <ToggleGroup
          key={size}
          multiple
          size={size}
          variant="outline"
          aria-label={`Formatting, ${size}`}
          defaultValue={["bold"]}
        >
          <ToggleGroupItem value="bold" aria-label="Bold">
            <IconBold />
          </ToggleGroupItem>
          <ToggleGroupItem value="italic" aria-label="Italic">
            <IconItalic />
          </ToggleGroupItem>
          <ToggleGroupItem value="underline" aria-label="Underline">
            <IconUnderline />
          </ToggleGroupItem>
        </ToggleGroup>
      ))}
    </div>
  )
}
```

### Spacing

`spacing` puts a gap between items on the spacing scale. Outline items with a gap keep their own borders and fills.

```tsx title="components/examples/toggle-group/spacing.tsx"
import { IconStar } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupSpacing() {
  return (
    <div className="flex flex-col items-center gap-4">
      <ToggleGroup spacing={1} aria-label="Rating filter" defaultValue={["4"]}>
        {["1", "2", "3", "4", "5"].map((stars) => (
          <ToggleGroupItem key={stars} value={stars}>
            {stars}
            <IconStar />
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <ToggleGroup
        multiple
        spacing={2}
        variant="outline"
        size="sm"
        aria-label="Topics"
        defaultValue={["design"]}
      >
        <ToggleGroupItem value="design">Design</ToggleGroupItem>
        <ToggleGroupItem value="engineering">Engineering</ToggleGroupItem>
        <ToggleGroupItem value="research">Research</ToggleGroupItem>
      </ToggleGroup>
    </div>
  )
}
```

### Icons and labels

Put an icon before the label. Icon-only items need an aria-label.

```tsx title="components/examples/toggle-group/icons.tsx"
import { IconDeviceDesktop, IconMoon, IconSun } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupIcons() {
  return (
    <ToggleGroup aria-label="Theme" defaultValue={["system"]}>
      <ToggleGroupItem value="light">
        <IconSun />
        Light
      </ToggleGroupItem>
      <ToggleGroupItem value="dark">
        <IconMoon />
        Dark
      </ToggleGroupItem>
      <ToggleGroupItem value="system">
        <IconDeviceDesktop />
        System
      </ToggleGroupItem>
    </ToggleGroup>
  )
}
```

### Vertical

`orientation="vertical"` stacks the items and moves focus with ↑ and ↓.

```tsx title="components/examples/toggle-group/vertical.tsx"
import { IconLayoutGrid, IconLayoutList, IconList } from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupVertical() {
  return (
    <ToggleGroup
      orientation="vertical"
      variant="outline"
      aria-label="Layout"
      defaultValue={["grid"]}
    >
      <ToggleGroupItem value="grid" aria-label="Grid">
        <IconLayoutGrid />
      </ToggleGroupItem>
      <ToggleGroupItem value="cards" aria-label="Cards">
        <IconLayoutList />
      </ToggleGroupItem>
      <ToggleGroupItem value="list" aria-label="List">
        <IconList />
      </ToggleGroupItem>
    </ToggleGroup>
  )
}
```

### Disabled

Disable one item, or the whole group with `disabled`. Disabled items are skipped by the keyboard.

```tsx title="components/examples/toggle-group/disabled.tsx"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupDisabled() {
  return (
    <div className="flex flex-col items-center gap-4">
      <ToggleGroup aria-label="Plan" defaultValue={["pro"]}>
        <ToggleGroupItem value="free">Free</ToggleGroupItem>
        <ToggleGroupItem value="pro">Pro</ToggleGroupItem>
        <ToggleGroupItem value="team" disabled>
          Team
        </ToggleGroupItem>
      </ToggleGroup>
      <ToggleGroup
        disabled
        variant="outline"
        aria-label="Billing"
        defaultValue={["monthly"]}
      >
        <ToggleGroupItem value="monthly">Monthly</ToggleGroupItem>
        <ToggleGroupItem value="yearly">Yearly</ToggleGroupItem>
      </ToggleGroup>
    </div>
  )
}
```

### Controlled

Pass `value` and `onValueChange`. Ignoring the empty value keeps one item on, so pressing the picked item again does nothing.

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

import * as React from "react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

const views = ["Board", "List", "Calendar"]

export function ToggleGroupControlled() {
  const [view, setView] = React.useState("Board")

  return (
    <div className="flex flex-col items-center gap-3">
      <ToggleGroup
        variant="outline"
        aria-label="View"
        value={[view]}
        onValueChange={(value) => {
          if (value.length > 0) {
            setView(value[0])
          }
        }}
      >
        {views.map((item) => (
          <ToggleGroupItem key={item} value={item}>
            {item}
          </ToggleGroupItem>
        ))}
      </ToggleGroup>
      <p className="text-sm text-muted-foreground">Showing the {view} view.</p>
    </div>
  )
}
```

### Right to left

Item order, joined corners, the sliding fill and arrow keys follow the reading direction.

```tsx title="components/examples/toggle-group/rtl.tsx"
import {
  IconAlignCenter,
  IconAlignLeft,
  IconAlignRight,
} from "@tabler/icons-react"

import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function ToggleGroupRtl() {
  return (
    <div dir="rtl" className="flex flex-col items-center gap-4">
      <ToggleGroup aria-label="الفترة" defaultValue={["week"]}>
        <ToggleGroupItem value="day">يوم</ToggleGroupItem>
        <ToggleGroupItem value="week">أسبوع</ToggleGroupItem>
        <ToggleGroupItem value="month">شهر</ToggleGroupItem>
      </ToggleGroup>
      <ToggleGroup
        variant="outline"
        aria-label="محاذاة النص"
        defaultValue={["right"]}
      >
        <ToggleGroupItem value="right" aria-label="محاذاة لليمين">
          <IconAlignRight />
        </ToggleGroupItem>
        <ToggleGroupItem value="center" aria-label="توسيط">
          <IconAlignCenter />
        </ToggleGroupItem>
        <ToggleGroupItem value="left" aria-label="محاذاة لليسار">
          <IconAlignLeft />
        </ToggleGroupItem>
      </ToggleGroup>
    </div>
  )
}
```

## Keyboard

| Key | Action |
| --- | --- |
| `Tab` | Moves into the group on the last focused item, then out of it. |
| `←` `→` | Moves between items, wrapping at the ends. ↑ and ↓ when vertical. |
| `Home` `End` | Moves to the first or last item. |
| `Enter` `Space` | Turns the focused item on or off. |

## Accessibility

- The group has the `group` role; give it an `aria-label`. Each item is a button with `aria-pressed`.
- Only one item is in the tab order, so the group is a single Tab stop and arrow keys move inside it.
- Without `multiple`, pressing the picked item turns it off. Control the value to keep one item on.
- With reduced motion, the fill fades instead of sliding.

## API reference

### ToggleGroup

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string[]` | – |  |
| `defaultValue` | `string[]` | – |  |
| `onValueChange` | `(value: string[], details) => void` | – |  |
| `multiple` | `boolean` | `false` |  |
| `variant` | `"default" \| "outline"` | `"default"` | Sets every item's variant. |
| `size` | `"default" \| "sm" \| "lg"` | `"default"` | Sets every item's size. |
| `spacing` | `0 \| 0.5 \| 1 \| 1.5 \| 2 \| 2.5 \| 3 \| 4 \| 5 \| 6 \| 8` | `0` | Gap between items on the spacing scale. 0 joins outline items. |
| `orientation` | `"horizontal" \| "vertical"` | `"horizontal"` |  |
| `loopFocus` | `boolean` | `true` |  |
| `disabled` | `boolean` | `false` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<div>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="toggle-group"` | The root. |
| `data-slot="toggle-group-indicator"` | The sliding fill, rendered without multiple. data-visible when an item is on. |
| `data-variant / data-size / data-spacing` | The group's variant, size and spacing. |
| `data-orientation` | "horizontal" or "vertical". |
| `data-multiple` | Present when many items can be on. |
| `data-disabled` | Present when the group is disabled. |

### ToggleGroupItem

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `value` | `string` | – | Identifies the item in the group's value. |
| `disabled` | `boolean` | `false` |  |
| `variant` | `"default" \| "outline"` | – | Used when the group sets none. |
| `size` | `"default" \| "sm" \| "lg"` | – | Used when the group sets none. |
| `nativeButton` | `boolean` | `true` |  |
| `render` | `ReactElement \| (props, state) => ReactElement` | `<button>` |  |

| Attribute | Description |
| --- | --- |
| `data-slot="toggle-group-item"` | Each item. |
| `data-pressed` | The item is on. |
| `data-disabled` | The item is disabled. |
| `data-variant / data-size` | The item's resolved variant and size. |

## Notes for AI assistants

- Install a component with the shadcn CLI: `npx shadcn@latest add https://hextaui.com/r/<name>.json`. It adds the source, the HextaUI theme tokens and any HextaUI components it depends on. `https://hextaui.com/r/all.json` installs every component.
- The code is then owned by the project, like shadcn/ui. There is no HextaUI npm package. HextaUI is MIT licensed and free for personal and commercial use.
- Behavior and accessibility come from Base UI (`@base-ui/react`). Compose with the `render` prop, not `asChild`.
- Styling uses Tailwind CSS v4 with theme tokens. Merge classes with `cn` from the `cn` package.
- Icons come from `@tabler/icons-react`.
- Import components from `@/components/ui/<name>`, hooks from `@/hooks/<name>` and utilities from `@/lib/<name>`.

Every HextaUI doc: https://hextaui.com/llms.txt
