feat(design-system): segmented controls (#50738)

## Problem

We don't have any one defined way of displaying segmented controls. This
PR looks at unifying and using underlying primtives to put something
together. Open to comment.

## Solution

Improves upon the shadcn toggle group, which has a specific use case.

## Review instructions

Run the design system and find "Segmented Controls" in the Fragment
Components. Test
[here](https://design-system-git-feat-button-group-component-draft-supabase.vercel.app/design-system/docs/components/toggle-group#segmented).


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

## Summary

* **New Features**
* Added segmented toggle groups with default, text, outline, and primary
tones.
* Single-select groups display a sliding selection indicator;
multi-select groups highlight selected items individually.
* Groups can keep the selected option active; deselection is enabled by
default.
  * Added examples for tone variations and a status filter.
* **Documentation**
* Updated segmented-control guidance and props tables, including usage
recommendations, accessibility labeling, and filter options.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Gildas Garcia <1122076+djhi@users.noreply.github.com>
This commit is contained in:
kemal.earthandGildas Garcia authored and GitHub committed 2026-09-25 11:26:46 +01:00
1 parent 9b12165400
commit 0e6fff6760
8 files changed
+518 -30

No files matched your search

@@ -89,3 +89,37 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'
### Disabled
<ComponentPreview name="toggle-group-disabled" />
## Segmented
A Supabase addition, not part of shadcn or Radix. A segmented control is a compact row of two to
four mutually exclusive options sharing one continuous track, for switching view state (Data or
Definition) or filtering a list (All, Active, Revoked). The choice takes effect immediately and is
never saved, so reach for a [Switch](../components/switch) or
[Radio Group](../components/radio-group) when the setting is persisted, and
[Tabs](../components/tabs) when the options swap whole panels of content. Items paint nothing
themselves; a single indicator slides between them, so the group always has exactly one thing
selected. Give a filter an explicit **All** segment rather than letting deselection stand for
"no filter".
<ComponentPreview name="toggle-group-segmented" />
<ComponentPreview name="toggle-group-segmented-filter" peekCode />
## Props
`ToggleGroup` forwards every prop to the underlying
[Radix Toggle Group](https://www.radix-ui.com/docs/primitives/components/toggle-group#api-reference).
These are the ones worth knowing about.
| Prop | Type | Default | Description |
| --------------- | --------------------------------------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `type` | `'single' \| 'multiple'` | | Required by Radix. Only a `'single'` group gets the segmented indicator; a `'multiple'` group falls back to items painting their own background. |
| `variant` | `'default' \| 'outline' \| 'segmented'` | `'default'` | Styles the root and every item, which inherit it through context. `segmented` drops the gaps and per-item borders in favor of an indicator that slides between segments, respecting `prefers-reduced-motion`. |
| `size` | `'tiny' \| 'sm' \| 'default' \| 'lg'` | `'default'` | Item height and padding, inherited through context. `tiny` suits a dense toolbar or table footer. Segments hug their labels, so add `className="flex-1"` to each item, plus a width on the root, if you need them equal. |
| `tone` | `'text' \| 'outline' \| 'primary'` | `'text'` | Segmented only. `text` is a flat `bg-accent` fill, `outline` adds a container border and a raised bordered indicator, `primary` fills the indicator with brand. |
| `allowDeselect` | `boolean` | `true` | Whether clicking the active item in a `type="single"` group clears it, emitting `''`. Set `false` for a segmented control, which has no empty state. |
| `aria-label` | `string` | | Names what is being switched or filtered. The segments name the options, not the axis. |
`variant` and `size` are the shadcn variant axes; `segmented`, `tone` and `allowDeselect` are the
Supabase additions. `default` and `outline` are unchanged from shadcn.
@@ -0,0 +1,33 @@
'use client'
import * as React from 'react'
import { ToggleGroup, ToggleGroupItem } from 'ui'
const SIZES = ['tiny', 'sm', 'default', 'lg'] as const
export default function ToggleGroupSegmentedFilter() {
const [status, setStatus] = React.useState('all')
return (
<div className="flex flex-col gap-4">
{SIZES.map((size) => (
<div key={size} className="flex items-center gap-4">
<span className="w-16 shrink-0 text-xs text-foreground-lighter">{size}</span>
<ToggleGroup
type="single"
variant="segmented"
size={size}
value={status}
onValueChange={setStatus}
allowDeselect={false}
aria-label={`Filter keys by status (${size})`}
>
<ToggleGroupItem value="all">All</ToggleGroupItem>
<ToggleGroupItem value="active">Active</ToggleGroupItem>
<ToggleGroupItem value="revoked">Revoked</ToggleGroupItem>
</ToggleGroup>
</div>
))}
</div>
)
}
@@ -0,0 +1,32 @@
'use client'
import * as React from 'react'
import { ToggleGroup, ToggleGroupItem } from 'ui'
const TONES = ['text', 'outline', 'primary'] as const
export default function ToggleGroupSegmented() {
const [view, setView] = React.useState('data')
return (
<div className="flex flex-col gap-4">
{TONES.map((tone) => (
<div key={tone} className="flex items-center gap-4">
<span className="w-16 shrink-0 text-xs text-foreground-lighter">{tone}</span>
<ToggleGroup
type="single"
variant="segmented"
tone={tone}
value={view}
onValueChange={setView}
allowDeselect={false}
aria-label={`Table view (${tone})`}
>
<ToggleGroupItem value="data">Data</ToggleGroupItem>
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
</ToggleGroup>
</div>
))}
</div>
)
}
+12
View File
@@ -1069,6 +1069,18 @@ export const examples: Registry = [
registryDependencies: ['toggle-group'],
files: ['example/toggle-group-outline.tsx'],
},
{
name: 'toggle-group-segmented',
type: 'components:example',
registryDependencies: ['toggle-group'],
files: ['example/toggle-group-segmented.tsx'],
},
{
name: 'toggle-group-segmented-filter',
type: 'components:example',
registryDependencies: ['toggle-group'],
files: ['example/toggle-group-segmented-filter.tsx'],
},
{
name: 'toggle-group-sm',
type: 'components:example',
@@ -0,0 +1,212 @@
import { render, screen, waitFor } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import * as React from 'react'
import { describe, expect, it, vi } from 'vitest'
import { ToggleGroup, ToggleGroupItem } from './toggle-group'
const renderSegmented = (props: {
defaultValue?: string
onValueChange?: (value: string) => void
allowDeselect?: boolean
tone?: 'outline' | 'text' | 'primary'
}) => (
<ToggleGroup variant="segmented" size="tiny" type="single" {...props}>
<ToggleGroupItem value="data">Data</ToggleGroupItem>
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
</ToggleGroup>
)
describe('ToggleGroup', () => {
it('leaves the segmented container naked — no border on the root or the items', () => {
render(renderSegmented({ defaultValue: 'data' }))
const root = screen.getByRole('group')
expect(root).toHaveClass('p-px', 'gap-0', 'w-fit')
expect(root).not.toHaveClass('border', 'border-strong')
expect(screen.getByRole('radio', { name: 'Data' })).not.toHaveClass('border')
})
it('leaves the selected item unpainted so only the indicator marks it', () => {
render(renderSegmented({ defaultValue: 'data' }))
const selected = screen.getByRole('radio', { name: 'Data' })
expect(selected).toHaveAttribute('aria-checked', 'true')
expect(selected).toHaveClass('data-[state=on]:bg-transparent', 'aria-checked:bg-transparent')
expect(selected).not.toHaveClass(
'data-[state=on]:bg-accent',
'aria-checked:bg-accent',
'data-[state=on]:bg-overlay-hover',
'data-[state=on]:border-strong',
'data-[state=on]:shadow-sm'
)
})
it('adds a container border only for the outline tone', () => {
const { rerender } = render(renderSegmented({ defaultValue: 'data' }))
expect(screen.getByRole('group')).not.toHaveClass('border-strong')
rerender(renderSegmented({ defaultValue: 'data', tone: 'outline' }))
expect(screen.getByRole('group')).toHaveClass('border', 'border-strong')
})
it('tones the indicator rather than the items', () => {
const { container, rerender } = render(renderSegmented({ defaultValue: 'data' }))
expect(container.querySelector('[data-segment-indicator]')).toHaveClass('bg-accent')
rerender(renderSegmented({ defaultValue: 'data', tone: 'outline' }))
expect(container.querySelector('[data-segment-indicator]')).toHaveClass(
'bg-overlay-hover',
'border-strong'
)
rerender(renderSegmented({ defaultValue: 'data', tone: 'primary' }))
expect(container.querySelector('[data-segment-indicator]')).toHaveClass('bg-brand-400')
expect(screen.getByRole('radio', { name: 'Data' })).toHaveClass(
'data-[state=on]:bg-transparent'
)
})
it('gives items a pointer cursor', () => {
render(renderSegmented({ defaultValue: 'data' }))
expect(screen.getByRole('radio', { name: 'Data' })).toHaveClass('cursor-pointer')
})
it('clears the selection on re-click by default', async () => {
const onValueChange = vi.fn()
render(renderSegmented({ defaultValue: 'data', onValueChange }))
await userEvent.click(screen.getByRole('radio', { name: 'Data' }))
expect(onValueChange).toHaveBeenCalledWith('')
})
it('keeps the active item selected across repeated clicks when allowDeselect is false', async () => {
const onValueChange = vi.fn()
render(renderSegmented({ defaultValue: 'data', onValueChange, allowDeselect: false }))
const active = screen.getByRole('radio', { name: 'Data' })
await userEvent.click(active)
expect(active).toHaveAttribute('aria-checked', 'true')
await userEvent.click(active)
expect(active).toHaveAttribute('aria-checked', 'true')
expect(onValueChange).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('radio', { name: 'Definition' }))
expect(onValueChange).toHaveBeenCalledExactlyOnceWith('definition')
expect(screen.getByRole('radio', { name: 'Definition' })).toHaveAttribute(
'aria-checked',
'true'
)
expect(active).toHaveAttribute('aria-checked', 'false')
})
it('leaves a controlled group to its own value', async () => {
const onValueChange = vi.fn()
const Controlled = () => {
const [value, setValue] = React.useState('data')
return (
<ToggleGroup
variant="segmented"
type="single"
allowDeselect={false}
value={value}
onValueChange={(next) => {
onValueChange(next)
setValue(next)
}}
>
<ToggleGroupItem value="data">Data</ToggleGroupItem>
<ToggleGroupItem value="definition">Definition</ToggleGroupItem>
</ToggleGroup>
)
}
render(<Controlled />)
const active = screen.getByRole('radio', { name: 'Data' })
await userEvent.click(active)
expect(active).toHaveAttribute('aria-checked', 'true')
expect(onValueChange).not.toHaveBeenCalled()
await userEvent.click(screen.getByRole('radio', { name: 'Definition' }))
expect(onValueChange).toHaveBeenCalledExactlyOnceWith('definition')
})
it('still deselects an uncontrolled group when allowDeselect is left alone', async () => {
render(renderSegmented({ defaultValue: 'data' }))
const active = screen.getByRole('radio', { name: 'Data' })
await userEvent.click(active)
expect(active).toHaveAttribute('aria-checked', 'false')
})
it('still reports every change for multi-select groups', async () => {
const onValueChange = vi.fn()
render(
<ToggleGroup
variant="segmented"
size="tiny"
type="multiple"
allowDeselect={false}
onValueChange={onValueChange}
>
<ToggleGroupItem value="a">A</ToggleGroupItem>
</ToggleGroup>
)
await userEvent.click(screen.getByRole('button', { name: 'A' }))
expect(onValueChange).toHaveBeenCalledWith(['a'])
await userEvent.click(screen.getByRole('button', { name: 'A' }))
expect(onValueChange).toHaveBeenCalledWith([])
})
it('moves focus between items with the arrow keys', async () => {
render(renderSegmented({ defaultValue: 'data' }))
await userEvent.tab()
expect(screen.getByRole('radio', { name: 'Data' })).toHaveFocus()
await userEvent.keyboard('{ArrowRight}')
expect(screen.getByRole('radio', { name: 'Definition' })).toHaveFocus()
})
describe('with a sliding indicator', () => {
it('renders one automatically for a single-select segmented group', () => {
const { container } = render(renderSegmented({ defaultValue: 'data' }))
expect(container.querySelectorAll('[data-segment-indicator]')).toHaveLength(1)
})
it('measures the active item onto the root as custom properties', async () => {
render(renderSegmented({ defaultValue: 'data' }))
const root = screen.getByRole('group')
await waitFor(() => expect(root).toHaveAttribute('data-segment-indicator-ready'))
expect(root.style.getPropertyValue('--active-segment-left')).not.toBe('')
expect(root.style.getPropertyValue('--active-segment-width')).not.toBe('')
})
it('falls back to per-item paint for multi-select, which an indicator cannot track', () => {
const { container } = render(
<ToggleGroup variant="segmented" size="tiny" type="multiple" defaultValue={['a']}>
<ToggleGroupItem value="a">A</ToggleGroupItem>
<ToggleGroupItem value="b">B</ToggleGroupItem>
</ToggleGroup>
)
expect(container.querySelector('[data-segment-indicator]')).toBeNull()
expect(screen.getByRole('button', { name: 'A' })).toHaveClass(
'data-[state=on]:bg-overlay-hover'
)
})
})
})
@@ -1,48 +1,168 @@
'use client'
import { cva, VariantProps } from 'class-variance-authority'
import { ToggleGroup as ToggleGroupPrimitive } from 'radix-ui'
import { VariantProps } from 'class-variance-authority'
import * as React from 'react'
import { cn } from '../../../lib/utils'
import { toggleVariants } from './toggle'
import { useTabIndicator } from './useTabIndicator'
const ToggleGroupContext = React.createContext<VariantProps<typeof toggleVariants>>({
const segmentIndicatorOptions = {
activeItemSelector: '[data-state="on"]',
indicatorSelector: '[data-segment-indicator]',
readyFlag: 'segmentIndicatorReady',
leftProperty: '--active-segment-left',
widthProperty: '--active-segment-width',
insetByPadding: false,
}
const segmentedSelfPaint = 'data-[state=on]:bg-overlay-hover aria-checked:bg-overlay-hover'
const segmentedToneVariants = cva('', {
variants: {
tone: {
outline: 'border border-strong',
text: '',
primary: '',
},
},
defaultVariants: { tone: 'text' },
})
const segmentedIndicatorToneVariants = cva('', {
variants: {
tone: {
outline: 'bg-overlay-hover border border-strong shadow-sm',
text: 'bg-accent',
primary:
'bg-brand-400 dark:bg-brand-500 border border-brand-500/75 dark:border-brand-default/30',
},
},
defaultVariants: { tone: 'text' },
})
const toggleGroupVariants = cva('flex items-center justify-center', {
variants: {
variant: {
default: 'gap-1',
outline: 'gap-1',
segmented: 'relative isolate w-fit gap-0 p-px rounded-md group/segmented',
},
},
defaultVariants: {
variant: 'default',
},
})
const ToggleGroupContext = React.createContext<
VariantProps<typeof toggleVariants> & { hasIndicator?: boolean }
>({
size: 'default',
variant: 'default',
})
type ToggleGroupProps = React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants> & {
allowDeselect?: boolean
tone?: VariantProps<typeof segmentedToneVariants>['tone']
}
const ToggleGroup = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root> &
VariantProps<typeof toggleVariants>
>(({ className, variant, size, children, ...props }, ref) => (
<ToggleGroupPrimitive.Root
ref={ref}
className={cn('flex items-center justify-center gap-1', className)}
{...props}
>
<ToggleGroupContext.Provider value={{ variant, size }}>{children}</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
))
ToggleGroupProps
>(
(
{ className, variant, size, tone, children, allowDeselect = true, onValueChange, ...props },
ref
) => {
const rootRef = React.useRef<HTMLDivElement>(null)
useTabIndicator(rootRef, segmentIndicatorOptions)
const hasIndicator = variant === 'segmented' && props.type === 'single'
const ownsValue = !allowDeselect && props.type === 'single' && props.value === undefined
const [internalValue, setInternalValue] = React.useState(() =>
typeof props.defaultValue === 'string' ? props.defaultValue : ''
)
const handleValueChange = (value: string & string[]) => {
if (!allowDeselect && value === '') return
if (ownsValue && typeof value === 'string') setInternalValue(value)
if (onValueChange) onValueChange(value)
}
const rootProps = (
ownsValue ? { ...props, value: internalValue, defaultValue: undefined } : props
) as React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Root>
return (
<ToggleGroupPrimitive.Root
ref={(node) => {
rootRef.current = node
if (typeof ref === 'function') ref(node)
else if (ref) ref.current = node
}}
className={cn(
toggleGroupVariants({ variant }),
variant === 'segmented' && segmentedToneVariants({ tone }),
className
)}
{...rootProps}
onValueChange={handleValueChange}
>
{hasIndicator && <ToggleGroupIndicator tone={tone} />}
<ToggleGroupContext.Provider value={{ variant, size, hasIndicator }}>
{children}
</ToggleGroupContext.Provider>
</ToggleGroupPrimitive.Root>
)
}
)
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
const ToggleGroupIndicator = ({
className,
tone,
...props
}: React.ComponentPropsWithRef<'span'> & VariantProps<typeof segmentedIndicatorToneVariants>) => (
<span
aria-hidden
data-segment-indicator
className={cn(
'pointer-events-none absolute z-0 left-0 inset-y-px rounded-sm',
segmentedIndicatorToneVariants({ tone }),
'w-[var(--active-segment-width,0)] translate-x-[var(--active-segment-left,0)]',
'transition-none opacity-0',
'group-data-[segment-indicator-ready]/segmented:opacity-100',
'group-data-[segment-indicator-ready]/segmented:transition-[translate,width]',
'group-data-[segment-indicator-ready]/segmented:duration-[250ms]',
'group-data-[segment-indicator-ready]/segmented:ease-move',
'motion-reduce:transition-none',
className
)}
{...props}
/>
)
const ToggleGroupItem = React.forwardRef<
React.ElementRef<typeof ToggleGroupPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof ToggleGroupPrimitive.Item> &
VariantProps<typeof toggleVariants>
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
const resolvedVariant = context.variant || variant
return (
<ToggleGroupPrimitive.Item
ref={ref}
className={cn(
toggleVariants({
variant: context.variant || variant,
variant: resolvedVariant,
size: context.size || size,
}),
resolvedVariant === 'segmented' && !context.hasIndicator && segmentedSelfPaint,
className
)}
{...props}
@@ -54,4 +174,12 @@ const ToggleGroupItem = React.forwardRef<
ToggleGroupItem.displayName = ToggleGroupPrimitive.Item.displayName
export { ToggleGroup, ToggleGroupItem }
export {
ToggleGroup,
ToggleGroupIndicator,
ToggleGroupItem,
segmentedIndicatorToneVariants,
segmentedToneVariants,
toggleGroupVariants,
type ToggleGroupProps,
}
@@ -14,6 +14,8 @@ const toggleVariants = cva(
default: 'bg-transparent',
outline:
'bg-transparent border border-control hover:bg-accent hover:text-accent-foreground',
segmented:
'relative z-10 cursor-pointer rounded-sm bg-transparent hover:bg-transparent text-foreground-light hover:text-foreground data-[state=on]:bg-transparent data-[state=on]:text-foreground aria-checked:bg-transparent aria-checked:text-foreground',
},
size: {
tiny: 'h-[26px] px-2.5 text-xs',
@@ -2,34 +2,61 @@
import { useLayoutEffect, type RefObject } from 'react'
export const useTabIndicator = (listRef: RefObject<HTMLElement | null>) => {
type IndicatorOptions = {
activeItemSelector?: string
indicatorSelector?: string
readyFlag?: string
leftProperty?: string
widthProperty?: string
insetByPadding?: boolean
}
const tabDefaults = {
activeItemSelector: '[role="tab"][data-state="active"]',
indicatorSelector: '[data-tab-indicator]',
readyFlag: 'tabIndicatorReady',
leftProperty: '--active-tab-left',
widthProperty: '--active-tab-width',
insetByPadding: true,
} satisfies Required<IndicatorOptions>
export const useTabIndicator = (
listRef: RefObject<HTMLElement | null>,
options: IndicatorOptions = {}
) => {
const {
activeItemSelector,
indicatorSelector,
readyFlag,
leftProperty,
widthProperty,
insetByPadding,
} = { ...tabDefaults, ...options }
useLayoutEffect(() => {
const list = listRef.current
if (!list || !list.querySelector(':scope > [data-tab-indicator]')) return
if (!list || !list.querySelector(`:scope > ${indicatorSelector}`)) return
let cancelled = false
const measure = () => {
const trigger = list.querySelector<HTMLElement>(':scope > [role="tab"][data-state="active"]')
const trigger = list.querySelector<HTMLElement>(`:scope > ${activeItemSelector}`)
if (cancelled) return
if (!trigger) {
delete list.dataset.tabIndicatorReady
delete list.dataset[readyFlag]
return
}
const styles = getComputedStyle(trigger)
const paddingLeft = parseFloat(styles.paddingLeft) || 0
const paddingRight = parseFloat(styles.paddingRight) || 0
const paddingLeft = insetByPadding ? parseFloat(styles.paddingLeft) || 0 : 0
const paddingRight = insetByPadding ? parseFloat(styles.paddingRight) || 0 : 0
list.style.setProperty('--active-tab-left', `${trigger.offsetLeft + paddingLeft}px`)
list.style.setProperty(
'--active-tab-width',
`${trigger.offsetWidth - paddingLeft - paddingRight}px`
)
if (list.dataset.tabIndicatorReady === undefined) {
list.style.setProperty(leftProperty, `${trigger.offsetLeft + paddingLeft}px`)
list.style.setProperty(widthProperty, `${trigger.offsetWidth - paddingLeft - paddingRight}px`)
if (list.dataset[readyFlag] === undefined) {
// makes the bar already sitting on the active tab rather than sliding in
requestAnimationFrame(() => {
if (!cancelled) list.dataset.tabIndicatorReady = ''
if (!cancelled) list.dataset[readyFlag] = ''
})
}
}
@@ -60,7 +87,15 @@ export const useTabIndicator = (listRef: RefObject<HTMLElement | null>) => {
cancelled = true
mutations?.disconnect()
resizes?.disconnect()
delete list.dataset.tabIndicatorReady
delete list.dataset[readyFlag]
}
}, [listRef])
}, [
listRef,
activeItemSelector,
indicatorSelector,
readyFlag,
leftProperty,
widthProperty,
insetByPadding,
])
}