Segmented control
A row of mutually exclusive options, for switching a view without leaving the page.
'use client'
import { SegmentedControl } from '@/components/segmented-control'
import { useState } from 'react'
const periods = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
] as const
export default function Example() {
let [period, setPeriod] = useState<(typeof periods)[number]['value']>('week')
return <SegmentedControl aria-label="Period" value={period} onChange={setPeriod} options={periods} />
}<script setup lang="ts">
import SegmentedControl from '@/components/segmented-control.vue'
import { ref } from 'vue'
const periods = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
] as const
const period = ref<(typeof periods)[number]['value']>('week')
</script>
<template>
<SegmentedControl v-model="period" aria-label="Period" :options="periods" />
</template>Installation
Copy these files into the folder where you keep your components. They import each other with relative paths, so the folder can live anywhere. The packages they rely on are listed on the Installation page.
'use client'
import clsx from 'clsx'
import type React from 'react'
import { useRef } from 'react'
import { Link } from './link'
export type Segment<T extends string> = {
value: T
label: string
icon?: React.ComponentType<{ className?: string }>
href?: string
count?: number
}
const trays = {
md: 'gap-0.5 rounded-xl p-1',
sm: 'rounded-lg p-0.5',
}
const segments = {
md: 'rounded-lg px-3 py-1.5 text-base/6 sm:py-1 sm:text-sm/6',
sm: 'rounded-md px-2.5 py-1 text-sm/5',
}
export function SegmentedControl<T extends string>({
value,
onChange,
options,
size = 'md',
variant = 'segments',
className,
...props
}: {
value: T
onChange?: (value: T) => void
options: readonly Segment<T>[]
size?: keyof typeof segments
variant?: 'segments' | 'underline'
} & Omit<React.ComponentPropsWithoutRef<'div'>, 'onChange'>) {
let refs = useRef<(HTMLButtonElement | null)[]>([])
let links = options.some((option) => option.href)
let underline = variant === 'underline'
let focusable = Math.max(
options.findIndex((option) => option.value === value),
0
)
function handleKeyDown(index: number, event: React.KeyboardEvent) {
let step =
event.key === 'ArrowRight' || event.key === 'ArrowDown'
? 1
: event.key === 'ArrowLeft' || event.key === 'ArrowUp'
? -1
: 0
if (step === 0) return
event.preventDefault()
let next = (index + step + options.length) % options.length
onChange?.(options[next]!.value)
refs.current[next]?.focus()
}
let rootClass = clsx(
className,
underline
? 'flex gap-4 border-b border-zinc-950/10 dark:border-white/10'
: [
'inline-flex w-fit max-w-full items-center overflow-x-auto bg-zinc-950/5 ring-1 ring-zinc-200 [scrollbar-width:none] dark:bg-zinc-950 dark:ring-white/10 [&::-webkit-scrollbar]:hidden',
trays[size],
]
)
let items = options.map((option, index) => {
let current = value === option.value
let segmentClass = clsx(
'flex cursor-pointer items-center gap-1.5 font-medium whitespace-nowrap focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500',
underline
? [
'-mb-px border-b-2 pb-2 text-base/6 focus-visible:-outline-offset-2 sm:text-sm/6',
current
? 'border-zinc-950 text-zinc-950 dark:border-white dark:text-white'
: 'border-transparent text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white',
]
: [
segments[size],
'focus-visible:-outline-offset-1',
current
? 'bg-white text-zinc-950 shadow-sm dark:bg-zinc-800 dark:text-white'
: 'text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white',
]
)
let content = (
<>
{option.icon && <option.icon className="size-4 shrink-0" />}
{option.label}
{option.count ? <span className="text-zinc-500 tabular-nums">{option.count}</span> : null}
</>
)
if (option.href) {
return (
<Link key={option.value} href={option.href} aria-current={current ? 'page' : undefined} className={segmentClass}>
{content}
</Link>
)
}
return (
<button
key={option.value}
ref={(element) => {
refs.current[index] = element
}}
type="button"
role="radio"
aria-checked={current}
tabIndex={index === focusable ? 0 : -1}
onClick={() => onChange?.(option.value)}
onKeyDown={(event) => handleKeyDown(index, event)}
className={segmentClass}
>
{content}
</button>
)
})
return links ? (
<nav {...props} className={rootClass}>
{items}
</nav>
) : (
<div role="radiogroup" {...props} className={rootClass}>
{items}
</div>
)
}<script setup lang="ts" generic="T extends string">
import type { Component } from 'vue'
import { computed } from 'vue'
import Link from './link.vue'
const props = withDefaults(
defineProps<{
options: readonly {
value: T
label: string
icon?: Component
href?: string
count?: number
}[]
size?: 'md' | 'sm'
variant?: 'segments' | 'underline'
}>(),
{ size: 'md', variant: 'segments' }
)
const model = defineModel<T>({ required: true })
const segments: (HTMLButtonElement | null)[] = []
const links = computed(() => props.options.some((option) => option.href))
const underline = computed(() => props.variant === 'underline')
const focusable = computed(() =>
Math.max(
props.options.findIndex((option) => option.value === model.value),
0,
),
)
const trays = {
md: 'gap-0.5 rounded-xl p-1',
sm: 'rounded-lg p-0.5',
}
const sizes = {
md: 'rounded-lg px-3 py-1.5 text-base/6 sm:py-1 sm:text-sm/6',
sm: 'rounded-md px-2.5 py-1 text-sm/5',
}
const rootClass = computed(() =>
underline.value
? 'flex gap-4 border-b border-zinc-950/10 dark:border-white/10'
: [
'inline-flex w-fit max-w-full items-center overflow-x-auto bg-zinc-950/5 ring-1 ring-zinc-200 [scrollbar-width:none] dark:bg-zinc-950 dark:ring-white/10 [&::-webkit-scrollbar]:hidden',
trays[props.size],
]
)
function segmentClass(current: boolean) {
return [
'flex cursor-pointer items-center gap-1.5 font-medium whitespace-nowrap focus:outline-hidden focus-visible:outline-2 focus-visible:outline-accent-500',
underline.value
? [
'-mb-px border-b-2 pb-2 text-base/6 focus-visible:-outline-offset-2 sm:text-sm/6',
current
? 'border-zinc-950 text-zinc-950 dark:border-white dark:text-white'
: 'border-transparent text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white',
]
: [
sizes[props.size],
'focus-visible:-outline-offset-1',
current
? 'bg-white text-zinc-950 shadow-sm dark:bg-zinc-800 dark:text-white'
: 'text-zinc-500 hover:text-zinc-950 dark:text-zinc-400 dark:hover:text-white',
],
]
}
function onKeydown(index: number, event: KeyboardEvent): void {
const step =
event.key === 'ArrowRight' || event.key === 'ArrowDown'
? 1
: event.key === 'ArrowLeft' || event.key === 'ArrowUp'
? -1
: 0
if (step === 0) return
event.preventDefault()
const next = (index + step + props.options.length) % props.options.length
model.value = props.options[next]!.value
segments[next]?.focus()
}
</script>
<template>
<component :is="links ? 'nav' : 'div'" :role="links ? undefined : 'radiogroup'" :class="rootClass">
<template v-for="(option, index) in options" :key="option.value">
<Link
v-if="option.href"
:href="option.href"
:aria-current="model === option.value ? 'page' : undefined"
:class="segmentClass(model === option.value)"
>
<component :is="option.icon" v-if="option.icon" class="size-4 shrink-0" />
{{ option.label }}
<span v-if="option.count" class="text-zinc-500 tabular-nums">{{ option.count }}</span>
</Link>
<button
v-else
:ref="(element) => (segments[index] = element as HTMLButtonElement | null)"
type="button"
role="radio"
:aria-checked="model === option.value"
:tabindex="index === focusable ? 0 : -1"
:class="segmentClass(model === option.value)"
@click="model = option.value"
@keydown="onKeydown(index, $event)"
>
<component :is="option.icon" v-if="option.icon" class="size-4 shrink-0" />
{{ option.label }}
<span v-if="option.count" class="text-zinc-500 tabular-nums">{{ option.count }}</span>
</button>
</template>
</component>
</template>Component API
| Prop | Default | Description |
|---|---|---|
SegmentedControlrenders a <div> element | ||
value | - | The selected value. The control is always controlled. |
onChange | - | Called with the value of the segment that was pressed. Not needed when every option is a link. |
options | - | Array of `{ value, label, icon?, href?, count? }`. `icon` takes any component, a Heroicon for example. With `href` the segment is a link, current when its value matches. `count` shows after the label. |
size | md | `sm` for a compact tray, in a toolbar or a card header. |
variant | segments | `underline` for a row of tabs under a hairline. |
| Prop | Default | Description |
|---|---|---|
SegmentedControlrenders a <div> element | ||
v-model | - | The selected value. Required, the control is always controlled. With links, pass the current path. |
options | - | Array of `{ value, label, icon?, href?, count? }`. `icon` takes any component, a Heroicon for example. With `href` the segment is a link, current when its value matches. `count` shows after the label. |
size | md | `sm` for a compact tray, in a toolbar or a card header. |
variant | segments | `underline` for a row of tabs under a hairline. |
Examples
Basic example
Hold the selected value in state and pass the options. The value type is inferred from the options, so a typo is caught at compile time:
Selected: week
'use client'
import { SegmentedControl } from '@/components/segmented-control'
import { useState } from 'react'
const periods = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
] as const
export default function Example() {
let [period, setPeriod] = useState<(typeof periods)[number]['value']>('week')
return <SegmentedControl aria-label="Period" value={period} onChange={setPeriod} options={periods} />
}<script setup lang="ts">
import SegmentedControl from '@/components/segmented-control.vue'
import { ref } from 'vue'
const periods = [
{ value: 'day', label: 'Day' },
{ value: 'week', label: 'Week' },
{ value: 'month', label: 'Month' },
] as const
const period = ref<(typeof periods)[number]['value']>('week')
</script>
<template>
<SegmentedControl v-model="period" aria-label="Period" :options="periods" />
</template>With icons
Add an icon to an option to make the choice readable at a glance:
'use client'
import { SegmentedControl } from '@/components/segmented-control'
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from '@heroicons/react/16/solid'
import { useState } from 'react'
const views = [
{ value: 'list', label: 'List', icon: ListBulletIcon },
{ value: 'grid', label: 'Grid', icon: Squares2X2Icon },
{ value: 'chart', label: 'Chart', icon: ChartBarIcon },
] as const
export default function Example() {
let [view, setView] = useState<(typeof views)[number]['value']>('list')
return <SegmentedControl aria-label="View" value={view} onChange={setView} options={views} />
}<script setup lang="ts">
import SegmentedControl from '@/components/segmented-control.vue'
import { ChartBarIcon, ListBulletIcon, Squares2X2Icon } from '@heroicons/vue/16/solid'
import { ref } from 'vue'
const views = [
{ value: 'list', label: 'List', icon: ListBulletIcon },
{ value: 'grid', label: 'Grid', icon: Squares2X2Icon },
{ value: 'chart', label: 'Chart', icon: ChartBarIcon },
] as const
const view = ref<(typeof views)[number]['value']>('list')
</script>
<template>
<SegmentedControl v-model="view" aria-label="View" :options="views" />
</template>Many options
The control never wraps: past a certain width the segments scroll sideways, which keeps the row on a single line on mobile:
<SegmentedControl aria-label="Status" value={status} onChange={setStatus} options={statuses} className="max-w-full" /><template>
<SegmentedControl v-model="status" aria-label="Status" :options="statuses" class="max-w-full" />
</template>As links
Give each option an href and the control becomes a nav of links: the one whose value matches the current path is marked aria-current="page". Pass the path as the value, there is nothing to change:
const sections = [
{ value: '/settings/billing', label: 'Subscription', href: '/settings/billing' },
{ value: '/settings/billing/invoices', label: 'Invoices', href: '/settings/billing/invoices' },
{ value: '/settings/billing/settings', label: 'Settings', href: '/settings/billing/settings' },
]
<SegmentedControl aria-label="Billing sections" value={pathname} options={sections} /><script setup lang="ts">
const route = useRoute()
const sections = [
{ value: '/settings/billing', label: 'Subscription', href: '/settings/billing' },
{ value: '/settings/billing/invoices', label: 'Invoices', href: '/settings/billing/invoices' },
{ value: '/settings/billing/settings', label: 'Settings', href: '/settings/billing/settings' },
]
</script>
<template>
<SegmentedControl aria-label="Billing sections" :model-value="route.path" :options="sections" />
</template>Small
size="sm" tightens the tray for a toolbar or the corner of a card:
<SegmentedControl aria-label="Time range" size="sm" value={range} onChange={setRange} options={ranges} /><SegmentedControl v-model="range" aria-label="Time range" size="sm" :options="ranges" />With counts
A count shows after the label, in grey. Zero shows nothing:
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'payments', label: 'Payments', count: 42 },
{ value: 'refunds', label: 'Refunds', count: 3 },
{ value: 'disputes', label: 'Disputes', count: 1 },
]
<SegmentedControl aria-label="Section" value={tab} onChange={setTab} options={tabs} /><script setup lang="ts">
const tabs = [
{ value: 'overview', label: 'Overview' },
{ value: 'payments', label: 'Payments', count: 42 },
{ value: 'refunds', label: 'Refunds', count: 3 },
{ value: 'disputes', label: 'Disputes', count: 1 },
]
</script>
<template>
<SegmentedControl v-model="tab" aria-label="Section" :options="tabs" />
</template>Underline
variant="underline" trades the tray for tabs under a hairline, for switching the panes of a detail:
<SegmentedControl aria-label="Request details" variant="underline" value={pane} onChange={setPane} options={panes} /><SegmentedControl v-model="pane" aria-label="Request details" variant="underline" :options="panes" />