Toggle Group
Astro toggle buttons that share one selection or several selections.
---
import AlignCenterIcon from "@lucide/astro/icons/text-align-center"
import AlignLeftIcon from "@lucide/astro/icons/text-align-start"
import AlignRightIcon from "@lucide/astro/icons/text-align-end"
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 />
</ToggleGroupItem>
<ToggleGroupItem value="center" aria-label="Align center">
<AlignCenterIcon />
</ToggleGroupItem>
<ToggleGroupItem value="right" aria-label="Align right">
<AlignRightIcon />
</ToggleGroupItem>
</ToggleGroup>
Installation
bunx shadcn@latest add @gnostic/toggle-groupnpx shadcn@latest add @gnostic/toggle-grouppnpm dlx shadcn@latest add @gnostic/toggle-groupComplete the Installation guide first. Then copy the component files below.
Install component packages
bun add @data-slot/toggle-groupCopy 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
---
import BoldIcon from "@lucide/astro/icons/bold"
import ItalicIcon from "@lucide/astro/icons/italic"
import UnderlineIcon from "@lucide/astro/icons/underline"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
---
<ToggleGroup
multiple
defaultValue={["bold", "italic"]}
aria-label="Text formatting"
>
<ToggleGroupItem value="bold" aria-label="Bold">
<BoldIcon />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Italic">
<ItalicIcon />
</ToggleGroupItem>
<ToggleGroupItem value="underline" aria-label="Underline">
<UnderlineIcon />
</ToggleGroupItem>
</ToggleGroup>
Disabled
disabled on the group blocks every item. disabled on one item blocks that item.
---
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
---
<div class="flex flex-wrap items-center gap-6">
<ToggleGroup
disabled
variant="outline"
defaultValue="center"
aria-label="Locked alignment"
>
<ToggleGroupItem value="left">Left</ToggleGroupItem>
<ToggleGroupItem value="center">Center</ToggleGroupItem>
<ToggleGroupItem value="right">Right</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup variant="outline" defaultValue="left" aria-label="Alignment">
<ToggleGroupItem value="left">Left</ToggleGroupItem>
<ToggleGroupItem value="center" disabled>
Center
</ToggleGroupItem>
<ToggleGroupItem value="right">Right</ToggleGroupItem>
</ToggleGroup>
</div>
Sizes
---
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
---
<div class="flex flex-col items-start gap-4">
{(["sm", "default", "lg"] as const).map((size) => (
<ToggleGroup
variant="outline"
size={size}
defaultValue="center"
aria-label={size + " alignment"}
>
<ToggleGroupItem value="left">Left</ToggleGroupItem>
<ToggleGroupItem value="center">Center</ToggleGroupItem>
<ToggleGroupItem value="right">Right</ToggleGroupItem>
</ToggleGroup>
))}
</div>
Vertical
---
import {
ToggleGroup,
ToggleGroupItem,
ToggleGroupSeparator,
} from "@/components/ui/toggle-group"
---
<ToggleGroup
orientation="vertical"
variant="outline"
defaultValue="start"
aria-label="Block alignment"
>
<ToggleGroupItem value="start">Start</ToggleGroupItem>
<ToggleGroupSeparator orientation="horizontal" />
<ToggleGroupItem value="center">Center</ToggleGroupItem>
<ToggleGroupSeparator orientation="horizontal" />
<ToggleGroupItem value="end">End</ToggleGroupItem>
</ToggleGroup>
API Reference
ToggleGroup
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.
ToggleVariantProps['variant']"default"size?ToggleVariantProps['size']
Height and padding for every item that does not set its own `size`.
ToggleVariantProps['size']"default"orientation?'horizontal' | 'vertical'
Direction of the items and of the arrow keys.
'horizontal' | 'vertical'"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.
string | string[]multiple?boolean
Allow more than one pressed item.
booleanfalseloop?boolean
Move focus from the last item to the first item, and the reverse.
booleantruedisabled?boolean
Block changes to every item.
booleanfalseDiv attributes such as class, id, and aria-label pass through.
ToggleGroupItem
valuestring
Value stored for this item. The group matches this value against `defaultValue`. Use a value with no spaces.
stringvariant?ToggleVariantProps['variant']
Visual style for this item. When omitted, the item uses the group's `variant`.
ToggleVariantProps['variant']size?ToggleVariantProps['size']
Control size for this item. When omitted, the item uses the group's `size`.
ToggleVariantProps['size']disabled?boolean
Block changes to this item.
booleanfalseButton attributes such as class and aria-label pass through. Give an icon-only item an accessible name.
ToggleGroupSeparator
orientation?'horizontal' | 'vertical'
Direction of the divider line. Use `vertical` between items in a horizontal group. Use `horizontal` between items in a vertical group.
'horizontal' | 'vertical'"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.