Skip to content
  1. Navigation
  2. Segmented control

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

PropDefaultDescription
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.
sizemd`sm` for a compact tray, in a toolbar or a card header.
variantsegments`underline` for a row of tabs under a hairline.
PropDefaultDescription
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.
sizemd`sm` for a compact tray, in a toolbar or a card header.
variantsegments`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>

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" />