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.
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>
)
}pnpm dlx shadcn@latest add https://hextaui.com/r/toggle-group.jsonAdds the component, the HextaUI theme tokens and any HextaUI components it depends on.
- 1
Add the theme tokens to your global CSS, if you haven’t yet.
- 2
Install the dependencies.
pnpm add @base-ui/react class-variance-authority cn - 3
Copy and paste the following code into your project.
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, }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 }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 } - 4
Update the import paths to match your project setup.
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"<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.
ToggleGroup
└── ToggleGroupItemOutline#
variant="outline" joins the items into one bordered control.
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.
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.
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.
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.
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 ↓.
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.
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.
Showing the Board view.
"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.
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>
)
}| 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. |
| HomeEnd | Moves to the first or last item. |
| EnterSpace | Turns the focused item on or off. |
- The group has the
grouprole; give it anaria-label. Each item is a button witharia-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.
| Prop | Type | Default |
|---|---|---|
value | string[] | – |
defaultValue | string[] | – |
onValueChange | (value: string[], details) => void | – |
multiple | boolean | false |
variantSets every item's variant. | "default" | "outline" | "default" |
sizeSets every item's size. | "default" | "sm" | "lg" | "default" |
spacingGap between items on the spacing scale. 0 joins outline items. | 0 | 0.5 | 1 | 1.5 | 2 | 2.5 | 3 | 4 | 5 | 6 | 8 | 0 |
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. |
| Prop | Type | Default |
|---|---|---|
valueIdentifies the item in the group's value. | string | – |
disabled | boolean | false |
variantUsed when the group sets none. | "default" | "outline" | – |
sizeUsed when the group sets none. | "default" | "sm" | "lg" | – |
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. |