Skip to content
gnosticaignostic-ui
Esc
↑↓navigate↵open⌘Jpreview
On this page

Toggle Group

Astro toggle buttons that share one selection or several selections.

API Reference

Installation

bunx shadcn@latest add @gnostic/toggle-group
npx shadcn@latest add @gnostic/toggle-group
pnpm dlx shadcn@latest add @gnostic/toggle-group

Complete the Installation guide first. Then copy the component files below.

Install component packages

bun add @data-slot/toggle-group

Copy the files

src/components/ui/toggle-group/index.ts
export { default as ToggleGroup } from './toggle-group.astro'
export { default as ToggleGroupItem } from './toggle-group-item.astro'
export { default as ToggleGroupSeparator } from './toggle-group-separator.astro'
export {
    toggleGroupItemVariants,
    toggleGroupVariants,
    type ToggleGroupItemVariantProps,
    type ToggleGroupVariantProps,
} from './toggle-group-variants.ts'

export type {
    ToggleGroupItemProps,
    ToggleGroupProps,
    ToggleGroupSeparatorProps,
} from './types.ts'
src/components/ui/toggle-group/toggle-group-item.astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import { toggleGroupItemVariants } from "./toggle-group-variants.ts"
import type { ToggleGroupItemProps } from "./types.ts"

/** Button attributes pass through. `value` is required by `ToggleGroupItemProps`. */
type Props = Omit<HTMLAttributes<"button">, "value"> & ToggleGroupItemProps

const {
  class: className,
  value,
  variant,
  size,
  disabled,
  type = "button",
  ...props
} = Astro.props
---

<!--
  This button is separate from Toggle.
  The group controller selects data-slot="toggle-group-item".
  Null leaves variant or size to the group variants.
-->
<button
  data-slot="toggle-group-item"
  data-value={value}
  data-variant={variant}
  data-size={size}
  data-disabled={disabled}
  type={type}
  disabled={disabled}
  aria-pressed="false"
  class={cn(
    toggleGroupItemVariants({
      size: size ?? null,
      variant: variant ?? null,
    }),
    className,
  )}
  {...props}
>
  <slot />
</button>
src/components/ui/toggle-group/toggle-group-separator.astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import { Separator } from "../separator/index.ts"
import type { ToggleGroupSeparatorProps } from "./types.ts"

/** Separator attributes pass through. */
type Props = HTMLAttributes<"div"> & ToggleGroupSeparatorProps

const { class: className, orientation = "vertical", ...props } = Astro.props
---

<Separator
  orientation={orientation}
  class={cn(
    "pointer-events-none relative z-2 bg-input before:absolute before:inset-0 dark:before:bg-input/32",
    className,
  )}
  {...props}
/>
src/components/ui/toggle-group/toggle-group-variants.ts
import { cva, type VariantProps } from 'class-variance-authority'

import {
    toggleBaseClass,
    toggleSizeClass,
    toggleVariantClass,
} from '../toggle/toggle-variants.ts'

/**
 * Layout for the group.
 * `outline` joins direct children and paints the Toggle outline treatment
 * on each item that does not set `data-variant`.
 * `size` paints the Toggle size on each item that does not set `data-size`.
 * The default variant keeps a small gap.
 * Separator rules follow the pressed and hover state of a neighboring item.
 * `disabled` blocks pointer events on each item.
 */
export const toggleGroupVariants = cva(
    'group/toggle-group dark:*:[[data-slot=separator]:has(+[data-slot=toggle-group-item]:hover)]:before:bg-input/64 dark:*:[[data-slot=separator]:has(+[data-slot=toggle-group-item][aria-pressed=true])]:before:bg-input dark:*:[[data-slot=toggle-group-item]:hover+[data-slot=separator]]:before:bg-input/64 dark:*:[[data-slot=toggle-group-item][aria-pressed=true]+[data-slot=separator]]:before:bg-input flex w-fit *:focus-visible:z-10',
    {
        compoundVariants: [
            {
                class: '*:not-first:rounded-s-none *:not-first:border-s-0 *:not-last:rounded-e-none *:not-last:border-e-0 *:not-first:before:rounded-s-none *:not-last:before:rounded-e-none *:not-first:not-data-[slot=separator]:before:inset-s-[-0.5px] *:not-last:not-data-[slot=separator]:before:inset-e-[-0.5px]',
                orientation: 'horizontal',
                variant: 'outline',
            },
            {
                class: '*:not-first:rounded-t-none *:not-first:border-t-0 *:not-last:rounded-b-none *:not-last:border-b-0 *:not-first:before:rounded-t-none *:not-last:before:rounded-b-none *:not-first:not-data-[slot=separator]:before:top-[-0.5px] *:not-last:not-data-[slot=separator]:before:bottom-[-0.5px] *:data-[slot=toggle-group-item]:not-last:before:hidden dark:*:first:before:block dark:*:last:before:hidden',
                orientation: 'vertical',
                variant: 'outline',
            },
        ],
        defaultVariants: {
            disabled: false,
            orientation: 'horizontal',
            size: 'default',
            variant: 'default',
        },
        variants: {
            disabled: {
                false: '',
                true: 'cursor-not-allowed *:data-[slot=toggle-group-item]:pointer-events-none *:data-[slot=toggle-group-item]:opacity-64',
            },
            orientation: {
                horizontal: '*:pointer-coarse:after:min-w-auto',
                vertical: 'flex-col *:pointer-coarse:after:min-h-auto',
            },
            size: {
                default:
                    '*:data-[slot=toggle-group-item]:not-data-size:h-9 *:data-[slot=toggle-group-item]:not-data-size:min-w-9 *:data-[slot=toggle-group-item]:not-data-size:px-[calc(--spacing(2)-1px)] *:data-[slot=toggle-group-item]:not-data-size:sm:h-8 *:data-[slot=toggle-group-item]:not-data-size:sm:min-w-8',
                lg: '*:data-[slot=toggle-group-item]:not-data-size:h-10 *:data-[slot=toggle-group-item]:not-data-size:min-w-10 *:data-[slot=toggle-group-item]:not-data-size:px-[calc(--spacing(2.5)-1px)] *:data-[slot=toggle-group-item]:not-data-size:sm:h-9 *:data-[slot=toggle-group-item]:not-data-size:sm:min-w-9',
                sm: '*:data-[slot=toggle-group-item]:not-data-size:h-8 *:data-[slot=toggle-group-item]:not-data-size:min-w-8 *:data-[slot=toggle-group-item]:not-data-size:px-[calc(--spacing(1.5)-1px)] *:data-[slot=toggle-group-item]:not-data-size:sm:h-7 *:data-[slot=toggle-group-item]:not-data-size:sm:min-w-7',
            },
            variant: {
                default:
                    'gap-0.5 *:data-[slot=toggle-group-item]:not-data-variant:border-transparent',
                outline:
                    '*:data-[slot=toggle-group-item]:not-data-variant:border-input *:data-[slot=toggle-group-item]:not-data-variant:bg-background *:data-[slot=toggle-group-item]:not-data-variant:dark:bg-input/32 *:data-[slot=toggle-group-item]:not-data-variant:dark:aria-pressed:bg-input *:data-[slot=toggle-group-item]:not-data-variant:dark:hover:bg-input/64 *:data-[slot=toggle-group-item]:not-data-variant:shadow-xs/5 *:data-[slot=toggle-group-item]:not-data-variant:not-dark:bg-clip-padding *:data-[slot=toggle-group-item]:not-data-variant:not-disabled:not-active:not-aria-pressed:before:shadow-[0_1px_--theme(--color-black/4%)] *:data-[slot=toggle-group-item]:not-data-variant:dark:not-disabled:not-aria-pressed:before:shadow-[0_-1px_--theme(--color-white/2%)] *:data-[slot=toggle-group-item]:not-data-variant:dark:not-disabled:not-active:not-aria-pressed:before:shadow-[0_-1px_--theme(--color-white/6%)] *:data-[slot=toggle-group-item]:not-data-variant:[:disabled,:active,[aria-pressed=true]]:shadow-none',
            },
        },
    },
)

export type ToggleGroupVariantProps = VariantProps<typeof toggleGroupVariants>

/**
 * Styles for one item.
 * `variant` and `size` use the Toggle classes.
 * Pass `null` for an omitted value. The group variant then owns that axis.
 */
export const toggleGroupItemVariants = cva(
    `${toggleBaseClass} aria-pressed:z-10`,
    {
        variants: {
            size: toggleSizeClass,
            variant: toggleVariantClass,
        },
    },
)

export type ToggleGroupItemVariantProps = VariantProps<
    typeof toggleGroupItemVariants
>
src/components/ui/toggle-group/toggle-group.astro
---
import type { HTMLAttributes } from "astro/types"

import { cn } from "cn"
import type { ToggleGroupProps } from "./types.ts"
import { toggleGroupVariants } from "./toggle-group-variants.ts"

/** Div attributes pass through. Give the group an accessible name. */
type Props = HTMLAttributes<"div"> & ToggleGroupProps

const {
  class: className,
  variant = "default",
  size = "default",
  orientation = "horizontal",
  defaultValue,
  multiple,
  loop,
  disabled,
  ...props
} = Astro.props

/**
 * The controller reads a space-separated list.
 * An array and a string with spaces become that list.
 */
const defaultValueAttribute = Array.isArray(defaultValue)
  ? defaultValue
      .flatMap((value) => value.split(/\s+/))
      .filter((value) => value.length > 0)
      .join(" ")
  : defaultValue
---

<div
  data-slot="toggle-group"
  data-variant={variant}
  data-size={size}
  data-orientation={orientation}
  data-default-value={defaultValueAttribute}
  data-multiple={multiple ? "" : undefined}
  data-loop={loop === false ? "false" : undefined}
  data-disabled={disabled ? "" : undefined}
  role="group"
  aria-disabled={disabled ? "true" : undefined}
  aria-orientation={orientation === "vertical" ? "vertical" : undefined}
  class={cn(
    toggleGroupVariants({ disabled, orientation, size, variant }),
    className,
  )}
  {...props}
>
  <slot />
</div>

<script is:inline>
  // Paint the pressed items before the module controller binds.
  // Skip a group that already has a live selection.
  {
    const script = document.currentScript
    const root = script && script.previousElementSibling
    if (
      root instanceof HTMLElement &&
      root.getAttribute("data-slot") === "toggle-group" &&
      !root.hasAttribute("data-value")
    ) {
      const multiple = root.hasAttribute("data-multiple")
      const items = []
      for (const item of root.querySelectorAll(
        '[data-slot="toggle-group-item"]',
      )) {
        const owner =
          item.parentElement &&
          item.parentElement.closest('[data-slot="toggle-group"]')
        if (owner === root) items.push(item)
      }
      const known = new Set()
      for (const item of items) {
        const value = (item.getAttribute("data-value") || "").trim()
        if (value) known.add(value)
      }
      const selected = new Set()
      for (const value of (root.getAttribute("data-default-value") || "").split(
        /\s+/,
      )) {
        if (!value || !known.has(value)) continue
        selected.add(value)
        if (!multiple) break
      }
      for (const item of items) {
        const value = (item.getAttribute("data-value") || "").trim()
        const pressed = value !== "" && selected.has(value)
        item.setAttribute("aria-pressed", pressed ? "true" : "false")
        item.setAttribute("data-state", pressed ? "on" : "off")
      }
    }
  }
</script>

<script>
  import { create, type ToggleGroupController } from "@data-slot/toggle-group"

  // Destroy bindings before a view transition so the next page-load binds once.
  const controllers: ToggleGroupController[] = []
  const initialize = () => controllers.push(...create())
  initialize()
  document.addEventListener("astro:page-load", initialize)
  document.addEventListener("astro:before-swap", () => {
    for (const controller of controllers) controller.destroy()
    controllers.length = 0
  })
</script>
src/components/ui/toggle-group/types.ts
import type { ToggleVariantProps } from '../toggle/toggle-variants.ts'

/**
 * Props for a set of toggle buttons that share a selection.
 * Div attributes such as `class`, `id`, and `aria-label` pass through.
 * Give the group an accessible name with `aria-label` or `aria-labelledby`.
 * Place each item directly inside the group. A separator may sit between items.
 */
export interface ToggleGroupProps {
    /**
     * Visual style for every item that does not set its own `variant`.
     * `outline` joins the items into one control. The default style leaves a small gap.
     *
     * @default "default"
     */
    variant?: ToggleVariantProps['variant']
    /**
     * Height and padding for every item that does not set its own `size`.
     *
     * @default "default"
     */
    size?: ToggleVariantProps['size']
    /**
     * Direction of the items and of the arrow keys.
     *
     * @default "horizontal"
     */
    orientation?: 'horizontal' | 'vertical'
    /**
     * Value selected at first render.
     * Pass one value, a space-separated list, or an array.
     * A value matches an item `value`. Use a value with no spaces.
     * In single selection, the first known value is selected.
     */
    defaultValue?: string | string[]
    /**
     * Allow more than one pressed item.
     *
     * @default false
     */
    multiple?: boolean
    /**
     * Move focus from the last item to the first item, and the reverse.
     *
     * @default true
     */
    loop?: boolean
    /**
     * Block changes to every item.
     *
     * @default false
     */
    disabled?: boolean
}

/**
 * Props for one toggle in the group.
 * Button attributes such as `class` and `aria-label` pass through.
 * The control is its own button. It uses the Toggle styles.
 */
export interface ToggleGroupItemProps {
    /**
     * Value stored for this item.
     * The group matches this value against `defaultValue`.
     * Use a value with no spaces.
     */
    value: string
    /**
     * Visual style for this item.
     * When omitted, the item uses the group's `variant`.
     */
    variant?: ToggleVariantProps['variant']
    /**
     * Control size for this item.
     * When omitted, the item uses the group's `size`.
     */
    size?: ToggleVariantProps['size']
    /**
     * Block changes to this item.
     *
     * @default false
     */
    disabled?: boolean
}

/**
 * Props for a divider between toggle group items.
 * Separator attributes such as `class` pass through.
 */
export interface ToggleGroupSeparatorProps {
    /**
     * Direction of the divider line.
     * Use `vertical` between items in a horizontal group.
     * Use `horizontal` between items in a vertical group.
     *
     * @default "vertical"
     */
    orientation?: 'horizontal' | 'vertical'
}

Add a preset (optional)

Default Coss works with the component classes. To use another design, inspect and save its CSS to src/styles/presets/. Import it after the global stylesheet, then set data-preset on your layout or a container. These files also work with JavaScript disabled.

Usage

---
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
---
<ToggleGroup
    variant="outline"
    defaultValue="center"
    aria-label="Text alignment"
  >
  <ToggleGroupItem
    value="left"
    aria-label="Align left"
  >
  <AlignLeftIcon />Left</ToggleGroupItem>
  <ToggleGroupItem
    value="center"
    aria-label="Align center"
  >
  <AlignCenterIcon />Center</ToggleGroupItem>
  <ToggleGroupItem
    value="right"
    aria-label="Align right"
    >
  <AlignRightIcon />Right</ToggleGroupItem>
</ToggleGroup>

Set multiple when more than one item can stay pressed. Pass several values as an array or as a space-separated string.

<ToggleGroup
  multiple
  defaultValue={["bold", "italic"]}
  aria-label="Text formatting"
  >
  <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
  <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
  <ToggleGroupItem value="underline">Underline</ToggleGroupItem>
</ToggleGroup>

variant and size on the group apply to each item that does not set its own value. outline joins the items. The default variant leaves a small gap. Each item uses the Toggle styles.

Composition

Place the parts directly inside ToggleGroup:

ToggleGroup
├── ToggleGroupItem
├── ToggleGroupSeparator
└── ToggleGroupItem

ToggleGroupSeparator is optional. Use orientation="horizontal" on the separator when the group is vertical.

Examples

Several selections

Disabled

disabled on the group blocks every item. disabled on one item blocks that item.

Sizes

Vertical

API Reference

ToggleGroup

PropType
variant?ToggleVariantProps['variant']

Visual style for every item that does not set its own `variant`. `outline` joins the items into one control. The default style leaves a small gap.

TypeToggleVariantProps['variant']
Default"default"
size?ToggleVariantProps['size']

Height and padding for every item that does not set its own `size`.

TypeToggleVariantProps['size']
Default"default"
orientation?'horizontal' | 'vertical'

Direction of the items and of the arrow keys.

Type'horizontal' | 'vertical'
Default"horizontal"
defaultValue?string | string[]

Value selected at first render. Pass one value, a space-separated list, or an array. A value matches an item `value`. Use a value with no spaces. In single selection, the first known value is selected.

Typestring | string[]
multiple?boolean

Allow more than one pressed item.

Typeboolean
Defaultfalse
loop?boolean

Move focus from the last item to the first item, and the reverse.

Typeboolean
Defaulttrue
disabled?boolean

Block changes to every item.

Typeboolean
Defaultfalse

Div attributes such as class, id, and aria-label pass through.

ToggleGroupItem

PropType
valuestring

Value stored for this item. The group matches this value against `defaultValue`. Use a value with no spaces.

Typestring
variant?ToggleVariantProps['variant']

Visual style for this item. When omitted, the item uses the group's `variant`.

TypeToggleVariantProps['variant']
size?ToggleVariantProps['size']

Control size for this item. When omitted, the item uses the group's `size`.

TypeToggleVariantProps['size']
disabled?boolean

Block changes to this item.

Typeboolean
Defaultfalse

Button attributes such as class and aria-label pass through. Give an icon-only item an accessible name.

ToggleGroupSeparator

PropType
orientation?'horizontal' | 'vertical'

Direction of the divider line. Use `vertical` between items in a horizontal group. Use `horizontal` between items in a vertical group.

Type'horizontal' | 'vertical'
Default"vertical"

Separator attributes such as class pass through.

Read the installed source for the component source. See the data-slot docs for the controller, events, and keyboard model.

When to use Toggle Group

Use Toggle Group when the visitor keeps one option pressed, or several options pressed, and every option stays visible. Put the group label in aria-label when nearby text does not name the group. Use one item value with no spaces.

A change event on the group carries the selected values. Listen for toggle-group:change on the group element. event.detail.value is an array of strings.

Keyboard and behavior

Tab moves to the active item. Arrow keys move focus along orientation. Home moves to the first item. End moves to the last item. Enter and Space press the focused item. Set loop={false} to stop focus at the ends.

One item stays pressed at a time unless multiple is set. Press the active item again to clear it. A disabled group and a disabled item ignore pointer and keyboard changes.

Theme and layout

Pressed, hover, focus, and disabled styles use the Toggle variants and the current theme. Check an icon-only item with an accessible name. In an outline group, keep item sizes equal so the shared border stays even. Color should reinforce the pressed state, not be its only indication.

Was this page helpful?