Segmented Control

May 2023

A segmented control is a linear set of two or more segments, each of which functions as a button. Segmented controls are used to switch between different options or views, they are great alternatives to Tabs and Radio Groups.

My implementation is based on RadixUI and doesn't use external libraries like Motion.

Open in

Installation

You can install and use this component with a single command. It's powered by Shadcn CLI.

npx shadcn@latest add "https://gaievskyi.com/r/segmented-control"

Implementation

This segmented control implementation uses Radix UI Tabs primitive and a useTabObserver hook for smooth animations. Here's how to build it step by step.

npm install @radix-ui/react-tabs @radix-ui/react-slot

Add cn() helper

Used to manipulate classNames in a handy way. I'm not a fan of rewriting classes in runtime, but we don't have a better way to do it in React yet.

1import { clsx, type ClassValue } from "clsx"
2import { twMerge } from "tailwind-merge"
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs))
6}

Add mergeRefs() utility

Used to merge multiple refs into a single one. I'd love to have it in React natively, follow the React discussion. Anyways, we need it for useTabObserver hook, since it returns a listRef that should be merged with the component ref.

1import type { Ref, RefCallback, MutableRefObject } from "react"
2
3export function mergeRefs<T>(
4 ...inputRefs: (Ref<T> | undefined)[]
5): Ref<T> | RefCallback<T> {
6 const filteredInputRefs = inputRefs.filter(Boolean)
7 if (filteredInputRefs.length <= 1) {
8 const firstRef = filteredInputRefs[0]
9 return firstRef || null
10 }
11 return function mergedRefs(ref) {
12 for (const inputRef of filteredInputRefs) {
13 if (typeof inputRef === "function") {
14 inputRef(ref)
15 } else if (inputRef) {
16 ;(inputRef as MutableRefObject<T | null>).current = ref
17 }
18 }
19 }
20}

Tab Observer Hook

The magic happens with a custom hook that observes tab changes and provides smooth animation:

1import { useState, useRef, useEffect } from "react"
2
3type TabObserverOptions = {
4 onActiveTabChange?: (index: number, element: HTMLElement) => void
5}
6
7export function useTabObserver({ onActiveTabChange }: TabObserverOptions = {}) {
8 const [mounted, setMounted] = useState(false)
9 const listRef = useRef(null)
10
11 useEffect(() => {
12 setMounted(true)
13
14 const update = () => {
15 if (listRef.current) {
16 const tabs = listRef.current.querySelectorAll('[role="tab"]')
17 for (const [i, el] of tabs.entries()) {
18 if (el.getAttribute("data-state") === "active") {
19 onActiveTabChange?.(i, el)
20 }
21 }
22 }
23 }
24
25 const resizeObserver = new ResizeObserver(update)
26 const mutationObserver = new MutationObserver(update)
27
28 if (listRef.current) {
29 resizeObserver.observe(listRef.current)
30 mutationObserver.observe(listRef.current, {
31 childList: true,
32 subtree: true,
33 attributes: true,
34 })
35 }
36
37 update()
38
39 return () => {
40 resizeObserver.disconnect()
41 mutationObserver.disconnect()
42 }
43 }, [])
44
45 return { mounted, listRef }
46}

Floating Background

Animated background that slides behind active element. I use Tailwind motion-safe: utility to disable the animation for a11y reasons. It's not even a best practice, but a must-have feature for people who are sick or just prefer not to see animations on their devices.

1type FloatingBackgroundProps = {
2 isMounted: boolean
3 lineStyle: CSSProperties
4 className?: string
5}
6
7const FloatingBackground = ({ isMounted, className, lineStyle }: FloatingBackgroundProps) => (
8 <div
9 className={cn(
10 "absolute inset-y-1 left-0 -z-10 rounded-md bg-background shadow-sm ring-1 ring-border/50 motion-safe:transition-transform motion-safe:duration-300 motion-safe:[transition-timing-function:cubic-bezier(0.65,0,0.35,1)]",
11 { hidden: !isMounted },
12 className,
13 )}
14 style={{
15 transform: `translate3d(${lineStyle.left}px, 0, 0)`,
16 width: `${lineStyle.width}px`,
17 }}
18 aria-hidden="true"
19 />
20)

Segmented Control

The root component is a simple Radix TabsPrimitive.

1import * as TabsPrimitive from "@radix-ui/react-tabs"
2
3const SegmentedControl = TabsPrimitive.Root

Segmented Control List

The list component that wraps the segmented control triggers.

1type SegmentedControlListProps = ComponentPropsWithoutRef<
2 typeof TabsPrimitive.List
3> & {
4 ref?: Ref<ComponentRef<typeof TabsPrimitive.List>>
5 floatingBgClassName?: string
6}
7
8const SegmentedControlList = ({
9 children,
10 className,
11 floatingBgClassName,
12 ref,
13 ...rest
14}: SegmentedControlListProps) => {
15 const [lineStyle, setLineStyle] = useState({ width: 0, left: 0 })
16
17 const { mounted, listRef } = useTabObserver({
18 onActiveTabChange: (_, activeTab) => {
19 const { offsetWidth: width, offsetLeft: left } = activeTab
20 setLineStyle({ width, left })
21 },
22 })
23
24 return (
25 <TabsPrimitive.List
26 ref={mergeRefs(ref, listRef)}
27 className={cn(
28 "relative isolate grid w-full auto-cols-fr grid-flow-col gap-1 rounded-lg bg-muted p-1",
29 className,
30 )}
31 {...rest}
32 >
33 <Slottable>{children}</Slottable>
34 <FloatingBackground
35 isMounted={mounted}
36 lineStyle={lineStyle}
37 className={floatingBgClassName}
38 />
39 </TabsPrimitive.List>
40 )
41}

Segmented Control Trigger

The trigger component for switching the control.

1type SegmentedControlTriggerProps = ComponentPropsWithoutRef<
2 typeof TabsPrimitive.Trigger
3> & {
4 ref?: Ref<ComponentRef<typeof TabsPrimitive.Trigger>>
5}
6
7const SegmentedControlTrigger = ({
8 className,
9 ref,
10 ...rest
11}: SegmentedControlTriggerProps) => {
12return (
13 <TabsPrimitive.Trigger
14 ref={ref}
15 className={cn(
16 // base
17 "peer",
18 "relative z-10 h-8 whitespace-nowrap rounded-md px-2 text-sm font-medium text-muted-foreground outline-none",
19 "flex items-center justify-center gap-1.5",
20 "transition-all duration-300 ease-out",
21 // hover
22 "hover:text-foreground",
23 // focus
24 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
25 // active
26 "data-[state=active]:text-foreground data-[state=active]:shadow-sm",
27 className,
28 )}
29 {...rest}
30 />
31)}

Exports

Don't forget to export the components:

1export {
2 SegmentedControl,
3 SegmentedControlList,
4 SegmentedControlTrigger,
5}

Usage

Start with a simple two-option segmented control:

1import {
2 SegmentedControl,
3 SegmentedControlList,
4 SegmentedControlTrigger,
5} from "@/components/ui/segmented-control"
6
7export function BasicSegmentedControlExample() {
8 return (
9 <SegmentedControl defaultValue="profile">
10 <SegmentedControlList>
11 <SegmentedControlTrigger value="profile">
12 Profile
13 </SegmentedControlTrigger>
14 <SegmentedControlTrigger value="settings">
15 Settings
16 </SegmentedControlTrigger>
17 </SegmentedControlList>
18 </SegmentedControl>
19 )
20}

Controlled & Uncontrolled

You can use the segmented control in both controlled and uncontrolled modes:

1// Uncontrolled (recommended)
2<SegmentedControl defaultValue="profile">
3 {/* ... */}
4</SegmentedControl>
5
6// Controlled
7const [value, setValue] = useState("profile")
8
9<SegmentedControl value={value} onValueChange={setValue}>
10 {/* ... */}
11</SegmentedControl>