1"use client";2
3import React, { useCallback, useId, useRef, useState } from "react";4import { LayoutGroup, motion, useReducedMotion } from "motion/react";5import { cn } from "@/lib/utils";6
7export type SegmentedOption = {8 value: string;9 label: string;10 11 icon?: React.ReactNode;12 disabled?: boolean;13};14
15export type SegmentedControlProps = {16 options: SegmentedOption[];17 value?: string;18 defaultValue?: string;19 onValueChange?: (value: string) => void;20 size?: "sm" | "md";21 22232425 equalWidth?: boolean;26 className?: string;27 "aria-label"?: string;28};29
30const PILL_SPRING = { type: "spring" as const, bounce: 0.18, duration: 0.45 };31
3233343536export function SegmentedControl({37 options,38 value: valueProp,39 defaultValue,40 onValueChange,41 size = "md",42 equalWidth = true,43 className,44 "aria-label": ariaLabel = "Options",45}: SegmentedControlProps) {46 const reduce = !!useReducedMotion();47 const uid = useId();48 const listRef = useRef<HTMLDivElement>(null);49 const isControlled = valueProp !== undefined;50 const [uncontrolled, setUncontrolled] = useState(defaultValue ?? options[0]?.value ?? "");51 const value = isControlled ? valueProp : uncontrolled;52 const [hovered, setHovered] = useState<string | null>(null);53 const [pressed, setPressed] = useState<string | null>(null);54
55 const setValue = useCallback(56 (next: string) => {57 if (!isControlled) setUncontrolled(next);58 if (next !== value) onValueChange?.(next);59 },60 [isControlled, onValueChange, value],61 );62
63 const enabled = options.filter((o) => !o.disabled);64 65 66 67 const selectedValue = enabled.some((o) => o.value === value) ? value : null;68 const tabStop = selectedValue ?? enabled[0]?.value ?? null;69 const activeIndex = enabled.findIndex((o) => o.value === selectedValue);70
71 const focusByIndex = (index: number) => {72 const opt = enabled[Math.min(Math.max(index, 0), enabled.length - 1)];73 if (!opt) return;74 setValue(opt.value);75 listRef.current?.querySelector<HTMLButtonElement>(`[data-segment="${CSS.escape(opt.value)}"]`)?.focus();76 };77
78 const onKeyDown = (e: React.KeyboardEvent) => {79 const map: Record<string, number> = {80 ArrowRight: activeIndex + 1,81 ArrowDown: activeIndex + 1,82 ArrowLeft: activeIndex - 1,83 ArrowUp: activeIndex - 1,84 Home: 0,85 End: enabled.length - 1,86 };87 if (!(e.key in map)) return;88 e.preventDefault();89 90 let next = activeIndex < 0 ? 0 : map[e.key];91 if (next >= enabled.length) next = 0;92 if (next < 0) next = enabled.length - 1;93 focusByIndex(next);94 };95
96 const sm = size === "sm";97 const radius = sm ? 8 : 10;98
99 return (100 <LayoutGroup id={uid}>101 <div102 ref={listRef}103 role="radiogroup"104 aria-label={ariaLabel}105 onKeyDown={onKeyDown}106 onPointerLeave={() => setHovered(null)}107 className={cn(108 "relative isolate flex items-stretch",109 equalWidth ? "w-full" : "w-fit max-w-full",110 sm ? "gap-0.5 rounded-[10px] p-0.5" : "gap-0.5 rounded-[13px] p-[3px]",111 "bg-(--color-paper-2) shadow-[inset_0_0_0_1px_var(--color-rule),inset_0_1px_2px_rgba(0,0,0,0.04)]",112 className,113 )}114 >115 {options.map((opt, i) => {116 const active = opt.value === selectedValue;117 const isHovered = hovered === opt.value && !active && !opt.disabled;118 119 const prev = options[i - 1];120 const showDivider =121 i > 0 && !active && prev?.value !== selectedValue && hovered !== opt.value && hovered !== prev?.value;122 return (123 <button124 key={opt.value}125 type="button"126 role="radio"127 data-segment={opt.value}128 aria-checked={active}129 disabled={opt.disabled}130 tabIndex={opt.value === tabStop ? 0 : -1}131 onClick={() => !opt.disabled && setValue(opt.value)}132 onPointerEnter={() => setHovered(opt.value)}133 onPointerDown={() => setPressed(opt.value)}134 onPointerUp={() => setPressed(null)}135 onPointerCancel={() => setPressed(null)}136 onPointerLeave={() => setPressed(null)}137 className={cn(138 "relative inline-flex items-center justify-center font-medium whitespace-nowrap outline-none select-none",139 "transition-colors duration-200",140 "focus-visible:ring-2 focus-visible:ring-(--color-focus) focus-visible:ring-offset-1 focus-visible:ring-offset-(--color-paper-2)",141 "disabled:cursor-not-allowed disabled:opacity-40",142 equalWidth && "min-w-0 flex-1",143 sm ? "h-7 gap-1 px-2.5 text-[12px]" : "h-8 gap-1.5 px-3 text-[13px]",144 active ? "text-(--color-ink)" : "text-(--color-neutral) hover:text-(--color-ink-2)",145 )}146 style={{ borderRadius: radius }}147 >148 <span149 aria-hidden150 className={cn(151 "absolute top-1/2 -left-px h-3.5 w-px -translate-y-1/2 bg-(--color-rule) transition-opacity duration-200",152 showDivider ? "opacity-100" : "opacity-0",153 )}154 />155 {isHovered ? (156 <motion.span157 layoutId="hover"158 aria-hidden159 transition={reduce ? { duration: 0 } : PILL_SPRING}160 className="absolute inset-0 -z-10 bg-(--color-ink)/[0.045]"161 style={{ borderRadius: radius }}162 />163 ) : null}164 {active ? (165 <motion.span166 layoutId="pill"167 aria-hidden168 transition={reduce ? { duration: 0 } : PILL_SPRING}169 animate={{ scale: pressed === opt.value && !reduce ? 0.96 : 1 }}170 className={cn(171 "absolute inset-0 -z-10 bg-(--color-paper)",172 "shadow-[0_0_0_0.5px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.08),0_2px_6px_-2px_rgba(0,0,0,0.08)]",173 "dark:shadow-[0_0_0_0.5px_var(--color-rule),0_1px_2px_rgba(0,0,0,0.5)]",174 )}175 style={{ borderRadius: radius }}176 />177 ) : null}178 {opt.icon ? (179 <motion.span180 aria-hidden181 initial={false}182 animate={active && !reduce ? { scale: [0.8, 1.12, 1] } : { scale: 1 }}183 transition={{ duration: 0.35, ease: [0.23, 1, 0.32, 1] }}184 className={cn("flex shrink-0 items-center justify-center [&_svg]:size-full [&_svg]:stroke-[1.75]", sm ? "size-3.5" : "size-4")}185 >186 {opt.icon}187 </motion.span>188 ) : null}189 <span className="truncate">{opt.label}</span>190 </button>191 );192 })}193 </div>194 </LayoutGroup>195 );196}