Toggle Group
A set of two-state buttons that can be toggled on or off.
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupDemo() {
return () => (
<ToggleGroup variant="outline" type="multiple">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}Installation
$ bunx --bun shadcn@latest add https://www.shadcn-remix.com/r/toggle-group.json$ npx shadcn@latest add https://www.shadcn-remix.com/r/toggle-group.json$ pnpm dlx shadcn@latest add https://www.shadcn-remix.com/r/toggle-group.json$ yarn dlx shadcn@latest add https://www.shadcn-remix.com/r/toggle-group.jsonInstall the following dependencies:
$ bun add remix$ npm install remix$ pnpm add remix$ yarn add remixCopy and paste the following code into your project.
import { css, on, type Handle, type Props } from 'remix/component'
import { toggleVariants, type ToggleSize, type ToggleVariant } from './toggle.tsx'
interface ToggleGroupBaseProps extends Omit<Props<'div'>, 'defaultValue' | 'dir'> {
variant?: ToggleVariant
size?: ToggleSize
/** Gap between items in spacing units (`0.25rem` each). `0` joins the items. */
spacing?: number
disabled?: boolean
/** Arrow keys move focus between items and only one item is in the tab sequence. */
rovingFocus?: boolean
/** Arrow-key navigation wraps from the last item to the first and back. */
loop?: boolean
/** Restricts arrow keys to one axis; both axes navigate when unset. */
orientation?: 'horizontal' | 'vertical'
dir?: 'ltr' | 'rtl'
}
export interface ToggleGroupSingleProps extends ToggleGroupBaseProps {
type: 'single'
/** Controlled pressed item value; `''` when none is pressed. */
value?: string
defaultValue?: string
onValueChange?: (value: string) => void
}
export interface ToggleGroupMultipleProps extends ToggleGroupBaseProps {
type: 'multiple'
/** Controlled pressed item values. */
value?: string[]
defaultValue?: string[]
onValueChange?: (value: string[]) => void
}
export type ToggleGroupProps = ToggleGroupSingleProps | ToggleGroupMultipleProps
export interface ToggleGroupItemProps extends Omit<Props<'button'>, 'value'> {
value: string
variant?: ToggleVariant
size?: ToggleSize
}
interface RegisteredItem {
readonly value: string
readonly disabled: boolean
update(): void
}
interface ToggleGroupContext {
readonly options: { variant?: ToggleVariant; size?: ToggleSize; spacing: number }
readonly type: 'single' | 'multiple'
readonly disabled: boolean
readonly rovingFocus: boolean
readonly orientation: 'horizontal' | 'vertical' | undefined
isPressed(value: string): boolean
setPressed(value: string, pressed: boolean): void
isTabStop(value: string): boolean
setTabStop(value: string): void
register(item: RegisteredItem, signal: AbortSignal): void
navigate(event: KeyboardEvent): void
}
const groupStyles = css({
display: 'flex',
width: 'fit-content',
alignItems: 'center',
gap: 'calc(0.25rem * var(--gap))',
borderRadius: 'calc(var(--radius) - 2px)',
'&[data-spacing="default"][data-variant="outline"]': {
boxShadow: '0 1px 2px 0 rgb(0 0 0 / 0.05)',
},
})
const itemStyles = css({
width: 'auto',
minWidth: 0,
flexShrink: 0,
paddingInline: '0.75rem',
'&:focus': { zIndex: '10' },
'&:focus-visible': { zIndex: '10' },
'&[data-spacing="0"]': { borderRadius: 0, '--tw-shadow': '0 0 #0000' },
'&[data-spacing="0"]:first-child': {
borderTopLeftRadius: 'calc(var(--radius) - 2px)',
borderBottomLeftRadius: 'calc(var(--radius) - 2px)',
},
'&[data-spacing="0"]:last-child': {
borderTopRightRadius: 'calc(var(--radius) - 2px)',
borderBottomRightRadius: 'calc(var(--radius) - 2px)',
},
'&[data-spacing="0"][data-variant="outline"]': { borderLeftWidth: 0 },
'&[data-spacing="0"][data-variant="outline"]:first-child': { borderLeftWidth: '1px' },
})
const ITEM_SELECTOR = '[data-slot="toggle-group-item"]'
export function ToggleGroup(handle: Handle<ToggleGroupProps, ToggleGroupContext>) {
let uncontrolledValue: string[] = toArray(handle.props.defaultValue)
let items: RegisteredItem[] = []
let tabStop: string | undefined
// Before the first commit, items render while later siblings are still unregistered.
let mounted = false
let selectedValues = () =>
handle.props.value === undefined ? uncontrolledValue : toArray(handle.props.value)
let updateItems = () => {
for (let item of items) item.update()
}
handle.context.set({
get options() {
return { variant: handle.props.variant, size: handle.props.size, spacing: handle.props.spacing ?? 0 }
},
get type() {
return handle.props.type
},
get disabled() {
return handle.props.disabled ?? false
},
get rovingFocus() {
return handle.props.rovingFocus ?? true
},
get orientation() {
return handle.props.orientation
},
isPressed: (value) => selectedValues().includes(value),
setPressed(value, pressed) {
let current = selectedValues()
let next =
handle.props.type === 'single'
? pressed
? [value]
: []
: pressed
? [...current, value]
: current.filter((v) => v !== value)
if (handle.props.value === undefined) {
uncontrolledValue = next
updateItems()
}
if (handle.props.type === 'single') handle.props.onValueChange?.(next[0] ?? '')
else handle.props.onValueChange?.(next)
},
isTabStop(value) {
let enabled = items.filter((item) => !item.disabled)
if (tabStop !== undefined && enabled.some((item) => item.value === tabStop)) {
return value === tabStop
}
let selected = selectedValues()
let firstPressed = enabled.find((item) => selected.includes(item.value))
if (firstPressed) return value === firstPressed.value
if (!mounted && selected.length > 0) return false
return value === enabled[0]?.value
},
setTabStop(value) {
if (tabStop === value) return
tabStop = value
updateItems()
},
register(item, signal) {
items.push(item)
signal.addEventListener('abort', () => {
items = items.filter((registered) => registered !== item)
updateItems()
})
},
navigate(event) {
let { orientation, loop = true, dir } = handle.props
if (event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return
let key = event.key
if (dir === 'rtl' && key === 'ArrowLeft') key = 'ArrowRight'
else if (dir === 'rtl' && key === 'ArrowRight') key = 'ArrowLeft'
if (orientation === 'vertical' && (key === 'ArrowLeft' || key === 'ArrowRight')) return
if (orientation === 'horizontal' && (key === 'ArrowUp' || key === 'ArrowDown')) return
let current = event.currentTarget as HTMLButtonElement
let group = current.closest('[data-slot="toggle-group"]')
if (!group) return
let candidates = Array.from(group.querySelectorAll<HTMLButtonElement>(ITEM_SELECTOR)).filter(
(item) => !item.disabled && item.closest('[data-slot="toggle-group"]') === group,
)
let index = candidates.indexOf(current)
let target: HTMLButtonElement | undefined
if (key === 'Home' || key === 'PageUp') target = candidates[0]
else if (key === 'End' || key === 'PageDown') target = candidates.at(-1)
else if (key === 'ArrowLeft' || key === 'ArrowUp') {
target = index > 0 ? candidates[index - 1] : loop ? candidates.at(-1) : undefined
} else if (key === 'ArrowRight' || key === 'ArrowDown') {
target =
index < candidates.length - 1 ? candidates[index + 1] : loop ? candidates[0] : undefined
} else return
event.preventDefault()
target?.focus()
},
})
handle.queueTask(() => {
mounted = true
updateItems()
})
return () => {
let {
type: _type,
value: _value,
defaultValue: _defaultValue,
onValueChange: _onValueChange,
variant,
size,
spacing = 0,
disabled: _disabled,
rovingFocus: _rovingFocus,
loop: _loop,
orientation,
style,
mix,
...props
} = handle.props
return (
<div
role="group"
data-slot="toggle-group"
data-variant={variant}
data-size={size}
data-spacing={spacing}
data-orientation={orientation}
style={{ '--gap': spacing, ...(style as object) }}
{...props}
mix={[groupStyles, mix]}
/>
)
}
}
export function ToggleGroupItem(handle: Handle<ToggleGroupItemProps>) {
let group = handle.context.get(ToggleGroup)
group.register(
{
get value() {
return handle.props.value
},
get disabled() {
return group.disabled || (handle.props.disabled ?? false)
},
update: () => handle.update(),
},
handle.signal,
)
return () => {
let { value, variant: itemVariant, size: itemSize, disabled, mix, ...props } = handle.props
let { options } = group
let variant = options.variant || itemVariant
let size = options.size || itemSize
let pressed = group.isPressed(value)
let isDisabled = group.disabled || (disabled ?? false)
let single = group.type === 'single'
return (
<button
type="button"
data-slot="toggle-group-item"
data-variant={variant}
data-size={size}
data-spacing={options.spacing}
data-state={pressed ? 'on' : 'off'}
data-disabled={isDisabled ? '' : undefined}
data-orientation={group.orientation}
role={single ? 'radio' : undefined}
aria-checked={single ? pressed : undefined}
aria-pressed={single ? undefined : pressed}
tabIndex={group.rovingFocus ? (group.isTabStop(value) ? 0 : -1) : undefined}
disabled={isDisabled}
value={value}
{...props}
mix={[
toggleVariants({ variant, size }),
itemStyles,
mix,
on('click', (event) => {
if (event.defaultPrevented || isDisabled) return
group.setPressed(handle.props.value, !group.isPressed(handle.props.value))
}),
on('focus', () => {
if (group.rovingFocus) group.setTabStop(handle.props.value)
}),
on('keydown', (event) => {
if (group.rovingFocus) group.navigate(event)
}),
]}
/>
)
}
}
function toArray(value: string | string[] | undefined): string[] {
if (value === undefined || value === '') return []
return typeof value === 'string' ? [value] : value
}Update the import paths to match your project setup.
Usage
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'<ToggleGroup type="single">
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
<ToggleGroupItem value="c">C</ToggleGroupItem>
</ToggleGroup>Examples
Outline
Use variant="outline" for an outline style.
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupOutline() {
return () => (
<ToggleGroup type="multiple" variant="outline">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}Spacing
Use spacing to add spacing between toggle group items.
import { css } from 'remix/component'
import { BookmarkIcon, HeartIcon, StarIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
function filledWhenOn(color: string) {import { css } from 'remix/component'
import { BookmarkIcon, HeartIcon, StarIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
function filledWhenOn(color: string) {
return css({
'&[data-state="on"]': { backgroundColor: 'transparent' },
'&[data-state="on"] > svg': { fill: color, stroke: color },
})
}
const star = filledWhenOn('oklch(79.5% 0.184 86.047)')
const heart = filledWhenOn('oklch(63.7% 0.237 25.331)')
const bookmark = filledWhenOn('oklch(62.3% 0.214 259.815)')
export function ToggleGroupSpacing() {
return () => (
<ToggleGroup type="multiple" variant="outline" spacing={2} size="sm">
<ToggleGroupItem value="star" aria-label="Toggle star" mix={star}>
<StarIcon />
Star
</ToggleGroupItem>
<ToggleGroupItem value="heart" aria-label="Toggle heart" mix={heart}>
<HeartIcon />
Heart
</ToggleGroupItem>
<ToggleGroupItem value="bookmark" aria-label="Toggle bookmark" mix={bookmark}>
<BookmarkIcon />
Bookmark
</ToggleGroupItem>
</ToggleGroup>
)
}Disabled
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupDisabled() {
return () => (
<ToggleGroup type="multiple" disabled>
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}Lg
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupLg() {
return () => (
<ToggleGroup type="multiple" size="lg">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}Single
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupSingle() {
return () => (
<ToggleGroup type="single">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}Sm
import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })import { css } from 'remix/component'
import { BoldIcon, ItalicIcon, UnderlineIcon } from '#app/ui/icons.tsx'
import { ToggleGroup, ToggleGroupItem } from '#app/ui/toggle-group.tsx'
const icon = css({ height: '1rem', width: '1rem' })
export function ToggleGroupSm() {
return () => (
<ToggleGroup type="single" size="sm">
<ToggleGroupItem value="bold" aria-label="Toggle bold">
<BoldIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="italic" aria-label="Toggle italic">
<ItalicIcon mix={icon} />
</ToggleGroupItem>
<ToggleGroupItem value="strikethrough" aria-label="Toggle strikethrough">
<UnderlineIcon mix={icon} />
</ToggleGroupItem>
</ToggleGroup>
)
}API Reference
ToggleGroup
| Prop | Type | Default |
|---|---|---|
type | "multiple" | "single" | - |
value | string | string[] | - |
defaultValue | string | string[] | - |
onValueChange | ((value: string) => void) | ((value: string[]) => void) | - |
variant | ToggleVariant | - |
size | ToggleSize | - |
spacing | number | 0 |
disabled | boolean | - |
rovingFocus | boolean | - |
loop | boolean | true |
orientation | "horizontal" | "vertical" | - |
dir | "rtl" | "ltr" | - |
mix | MixValue | - |
ToggleGroupItem
| Prop | Type | Default |
|---|---|---|
value | string | - |
variant | ToggleVariant | - |
size | ToggleSize | - |
mix | MixValue | - |