diff --git a/apps/design-system/content/docs/components/toggle-group.mdx b/apps/design-system/content/docs/components/toggle-group.mdx
index 5cedba42af4..f06d35cba17 100644
--- a/apps/design-system/content/docs/components/toggle-group.mdx
+++ b/apps/design-system/content/docs/components/toggle-group.mdx
@@ -89,3 +89,37 @@ import { ToggleGroup, ToggleGroupItem } from '@/components/ui/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".
+
+
+
+
+
+## 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.
diff --git a/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx b/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx
new file mode 100644
index 00000000000..0a40a293b38
--- /dev/null
+++ b/apps/design-system/registry/default/example/toggle-group-segmented-filter.tsx
@@ -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 (
+
+ {SIZES.map((size) => (
+
+ {size}
+
+ All
+ Active
+ Revoked
+
+
+ ))}
+
+ )
+}
diff --git a/apps/design-system/registry/default/example/toggle-group-segmented.tsx b/apps/design-system/registry/default/example/toggle-group-segmented.tsx
new file mode 100644
index 00000000000..e3cf3996df6
--- /dev/null
+++ b/apps/design-system/registry/default/example/toggle-group-segmented.tsx
@@ -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 (
+
+ {TONES.map((tone) => (
+
+ {tone}
+
+ Data
+ Definition
+
+
+ ))}
+
+ )
+}
diff --git a/apps/design-system/registry/examples.ts b/apps/design-system/registry/examples.ts
index 13c8741b839..540b6ac2e17 100644
--- a/apps/design-system/registry/examples.ts
+++ b/apps/design-system/registry/examples.ts
@@ -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',
diff --git a/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx b/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx
new file mode 100644
index 00000000000..9da88783479
--- /dev/null
+++ b/packages/ui/src/components/shadcn/ui/toggle-group.test.tsx
@@ -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'
+}) => (
+
+ Data
+ Definition
+
+)
+
+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 (
+ {
+ onValueChange(next)
+ setValue(next)
+ }}
+ >
+ Data
+ Definition
+
+ )
+ }
+ render()
+
+ 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(
+
+ A
+
+ )
+
+ 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(
+
+ A
+ B
+
+ )
+
+ expect(container.querySelector('[data-segment-indicator]')).toBeNull()
+ expect(screen.getByRole('button', { name: 'A' })).toHaveClass(
+ 'data-[state=on]:bg-overlay-hover'
+ )
+ })
+ })
+})
diff --git a/packages/ui/src/components/shadcn/ui/toggle-group.tsx b/packages/ui/src/components/shadcn/ui/toggle-group.tsx
index 4163c0ce249..6664bf8f04c 100644
--- a/packages/ui/src/components/shadcn/ui/toggle-group.tsx
+++ b/packages/ui/src/components/shadcn/ui/toggle-group.tsx
@@ -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>({
+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 & { hasIndicator?: boolean }
+>({
size: 'default',
variant: 'default',
})
+type ToggleGroupProps = React.ComponentPropsWithoutRef &
+ VariantProps & {
+ allowDeselect?: boolean
+ tone?: VariantProps['tone']
+ }
+
const ToggleGroup = React.forwardRef<
React.ElementRef,
- React.ComponentPropsWithoutRef &
- VariantProps
->(({ className, variant, size, children, ...props }, ref) => (
-
- {children}
-
-))
+ ToggleGroupProps
+>(
+ (
+ { className, variant, size, tone, children, allowDeselect = true, onValueChange, ...props },
+ ref
+ ) => {
+ const rootRef = React.useRef(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
+
+ return (
+ {
+ 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 && }
+
+ {children}
+
+
+ )
+ }
+)
ToggleGroup.displayName = ToggleGroupPrimitive.Root.displayName
+const ToggleGroupIndicator = ({
+ className,
+ tone,
+ ...props
+}: React.ComponentPropsWithRef<'span'> & VariantProps) => (
+
+)
+
const ToggleGroupItem = React.forwardRef<
React.ElementRef,
React.ComponentPropsWithoutRef &
VariantProps
>(({ className, children, variant, size, ...props }, ref) => {
const context = React.useContext(ToggleGroupContext)
+ const resolvedVariant = context.variant || variant
return (
) => {
+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
+
+export const useTabIndicator = (
+ listRef: RefObject,
+ 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(':scope > [role="tab"][data-state="active"]')
+ const trigger = list.querySelector(`: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) => {
cancelled = true
mutations?.disconnect()
resizes?.disconnect()
- delete list.dataset.tabIndicatorReady
+ delete list.dataset[readyFlag]
}
- }, [listRef])
+ }, [
+ listRef,
+ activeItemSelector,
+ indicatorSelector,
+ readyFlag,
+ leftProperty,
+ widthProperty,
+ insetByPadding,
+ ])
}