Toggle Group

A set of two-state buttons that can be toggled on or off.

"use client"

import * as React from "react"
import { type VariantProps } from "class-variance-authority"
import { cn } from "cn"
import { ToggleGroup as ToggleGroupPrimitive } from "@animate-ui/react/primitives/radix-ui"

import { toggleVariants } from "@/components/ui/toggle"
import { TextBoldIcon, TextItalicIcon, TextUnderlineIcon } from "@hugeicons/core-free-icons"
import { HugeiconsIcon } from "@hugeicons/react"

const ToggleGroupContext = React.createContext<
  VariantProps<typeof toggleVariants> & {
    spacing?: number
    orientation?: "horizontal" | "vertical"
  }
>({
  size: "default",
  variant: "default",
  spacing: 2,
  orientation: "horizontal",
})

function ToggleGroup({
  className,
  variant,
  size,
  spacing = 2,
  orientation = "horizontal",
  children,
  ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Root> &
  VariantProps<typeof toggleVariants> & {
    spacing?: number
    orientation?: "horizontal" | "vertical"
  }) {
  return (
    <ToggleGroupPrimitive.Root
      data-slot="toggle-group"
      data-variant={variant}
      data-size={size}
      data-spacing={spacing}
      data-orientation={orientation}
      style={{ "--gap": spacing } as React.CSSProperties}
      className={cn(
        "rounded-lg data-[size=sm]:rounded-[min(var(--radius-md),10px)] group/toggle-group flex w-fit flex-row items-center gap-[--spacing(var(--gap))] data-vertical:flex-col data-vertical:items-stretch",
        className
      )}
      {...props}
    >
      <ToggleGroupContext.Provider
        value={{ variant, size, spacing, orientation }}
      >
        {children}
      </ToggleGroupContext.Provider>
    </ToggleGroupPrimitive.Root>
  )
}

function ToggleGroupItem({
  className,
  children,
  variant = "default",
  size = "default",
  ...props
}: React.ComponentProps<typeof ToggleGroupPrimitive.Item> &
  VariantProps<typeof toggleVariants>) {
  const context = React.useContext(ToggleGroupContext)

  return (
    <ToggleGroupPrimitive.Item
      data-slot="toggle-group-item"
      data-variant={context.variant || variant}
      data-size={context.size || size}
      data-spacing={context.spacing}
      className={cn(
        "group-data-[spacing=0]/toggle-group:rounded-none group-data-[spacing=0]/toggle-group:px-2 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-end]:pr-1.5 group-data-[spacing=0]/toggle-group:has-data-[icon=inline-start]:pl-1.5 group-data-horizontal/toggle-group:data-[spacing=0]:first:rounded-l-lg group-data-vertical/toggle-group:data-[spacing=0]:first:rounded-t-lg group-data-horizontal/toggle-group:data-[spacing=0]:last:rounded-r-lg group-data-vertical/toggle-group:data-[spacing=0]:last:rounded-b-lg shrink-0 focus:z-10 focus-visible:z-10 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:border-l-0 group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:border-t-0 group-data-horizontal/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-l group-data-vertical/toggle-group:data-[spacing=0]:data-[variant=outline]:first:border-t",
        toggleVariants({
          variant: context.variant || variant,
          size: context.size || size,
        }),
        "hover:bg-muted/50 aria-pressed:hover:bg-muted aria-checked:hover:bg-muted data-[state=on]:hover:bg-muted data-selected:hover:bg-muted",
        className
      )}
      {...props}
    >
      {children}
    </ToggleGroupPrimitive.Item>
  )
}

export { ToggleGroup, ToggleGroupItem }

export default function ToggleGroupDemo({
  multiple = true,
  variant = "outline",
  size = "default",
  spacing = 2,
  orientation = "horizontal",
}: {
  multiple?: boolean
  variant?: "default" | "outline"
  size?: "default" | "sm" | "lg"
  spacing?: number | string
  orientation?: "horizontal" | "vertical"
}) {
  const [value, setValue] = React.useState<string[]>([])

  return (
    <ToggleGroup
      key={String(multiple)}
      variant={variant}
      size={size}
      spacing={Number(spacing)}
      orientation={orientation}
      {...(multiple
        ? { type: "multiple" as const, value, onValueChange: setValue }
        : {
            type: "single" as const,
            value: value[0] ?? "",
            onValueChange: (next: string) => setValue(next ? [next] : []),
          })}
    >
      <ToggleGroupItem value="bold" aria-label="Toggle bold">
        <HugeiconsIcon icon={TextBoldIcon} strokeWidth={2} />
      </ToggleGroupItem>
      <ToggleGroupItem value="italic" aria-label="Toggle italic">
        <HugeiconsIcon icon={TextItalicIcon} strokeWidth={2} />
      </ToggleGroupItem>
      <ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
        <HugeiconsIcon icon={TextUnderlineIcon} strokeWidth={2} />
      </ToggleGroupItem>
    </ToggleGroup>
  )
}

Installation

Install the packages

npm install @animate-ui/react motion
pnpm add @animate-ui/react motion
yarn add @animate-ui/react motion
bun add @animate-ui/react motion

Add the shadcn/ui component

npx shadcn@latest add toggle-group
pnpm dlx shadcn@latest add toggle-group
yarn dlx shadcn@latest add toggle-group
bun x shadcn@latest add toggle-group

Animate it

The CLI finds your shadcn/ui components through components.json, swaps their primitive imports for Animate UI's and removes the CSS animation classes Motion replaces.

npx @animate-ui/cli@latest animate toggle-group
pnpm dlx @animate-ui/cli@latest animate toggle-group
yarn dlx @animate-ui/cli@latest animate toggle-group
bun x @animate-ui/cli@latest animate toggle-group

Usage

Import the primitive from Animate UI instead of Radix UI. Everything else stays the same.

import { ToggleGroup } from '@animate-ui/react/primitives/radix-ui';

export function Alignment() {
  return (
    <ToggleGroup.Root
      type="single"
      defaultValue="left"
      aria-label="Text alignment"
    >
      <ToggleGroup.Item value="left" aria-label="Align left">
        <AlignLeftIcon />
      </ToggleGroup.Item>
      <ToggleGroup.Item value="center" aria-label="Align center">
        <AlignCenterIcon />
      </ToggleGroup.Item>
      <ToggleGroup.Item value="right" aria-label="Align right">
        <AlignRightIcon />
      </ToggleGroup.Item>
    </ToggleGroup.Root>
  );
}

Coming from the @radix-ui/react-toggle-group package? Use the subpath: import * as ToggleGroupPrimitive from '@animate-ui/react/primitives/radix-ui/toggle-group'.

Animation

The motion of Animate UI v1's toggle group, made livelier:

  • Item (each item) scales to 0.97 while pressed, on Motion's default spring for scale (stiffness 550, damping 30); the selection changes on the press itself.
  • Root with type="multiple": a selected item's surface grows in from 70% of its size, from its center, as it fades in, in 300ms (cubic-bezier(0.4, 0, 0.2, 1), like the radio's fill), and shrinks back out as it fades when it's deselected.
  • Neighbours selected together join into one surface: on the same 300ms, each one stretches to the middle of the gap between them as its inner corners square off, so the item selected next to a selected one seems to flow out of it; deselecting one splits the surface again (the middle one shrinks out while its neighbours pull back). Items join when they touch (spacing={0}), or across a gap no wider than half their height when they have no border on the sides facing each other: bordered items apart (the outline variant with a gap) keep their own surfaces, since a band painted between two frames reads as a stray strip. Rows, columns and right to left alike.
  • Root with type="single": one surface slides from the selected item to the newly selected one, on a spring (stiffness 300, damping 30), its size and corners following. Picked again on the way (quick clicks), it goes on from where it is with the speed it had, like the tabs' indicator. The first selection grows it in like a fill; clearing the selection shrinks it out.
  • Hovering an item lights a highlight that glides from item to item on a quick spring (stiffness 500, damping 40), like a menubar's triggers, fading in from nothing and out to nothing in 200ms (ease). It's lit only where the item's own hover style paints something its selection doesn't already: a selected item hovered keeps its surface alone. It's drawn above the selected surfaces; clicking a hovered item, it stays until the surface growing (or sliding) in under it has arrived, so the item never dims on the way.
  • The surfaces copy each item's own background and corners (shadcn/ui's data-[state=on]:bg-muted, hover:bg-muted), drawn behind the items and under their borders, in the group's shadow root (so the items' first: / last: styles are untouched); a small stylesheet clears the items' own backgrounds. The demo hovers lighter than it selects (hover:bg-muted/50), so the two read apart. Borders, shadows, text colors and focus rings stay the items'. A pressed item's surface scales with it.
  • The arrow keys only move the focus between items: nothing animates. Space and Enter select like a click.
  • Nothing moves when the group mounts.
  • Under reduced motion, there is no press scale and nothing grows, slides or stretches: surfaces only fade (a single selection fades out on the item it leaves and in on the chosen one, a surface fading in next to a selected item covers the gap between them as it fades), and the highlight jumps from item to item.

API Reference

Every part takes exactly the props of Radix UI's Toggle Group. Animate UI adds the animation props below. With asChild, an item renders your element with Radix's behaviour, without the press scale.

Root

Radix UI props, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 300, damping: 30 }

Item

Radix UI props, plus:

PropTypeDefault
transition?
Transition{ type: 'spring', stiffness: 550, damping: 30, restSpeed: 10 }

AnimateUIProvider's tempo scales these transitions with everything else.

Built by Skyleen. The source code is available on GitHub.

Last updated: 10/18/2026

On this page