Switcher
pnpm dlx shadcn@latest add …/r/switcher.jsonAn accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.
- Default
- Outline
import { Switcher, SwitcherItem } from "@/components/ui/switcher"
export function RangeSwitcher() {
return (
<Switcher defaultValue="week" aria-label="Range">
<SwitcherItem value="day">Day</SwitcherItem>
<SwitcherItem value="week">Week</SwitcherItem>
<SwitcherItem value="month">Month</SwitcherItem>
</Switcher>
)
}"use client";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switcher, SwitcherItem } from "@/components/ui/switcher";
import { useId, useState } from "react";
type Size = "sm" | "md" | "lg";
const variants = [
{ variant: "default", label: "Default" },
{ variant: "outline", label: "Outline" },
] as const;
/*
* Both variants side by side, named underneath, with the size in the top-right corner. An empty row at the
* bottom, as tall as the setting's row, keeps them centred.
*/
export default function SwitcherVariants() {
const id = useId();
const [size, setSize] = useState<Size>("md");
return (
<div className="flex w-full flex-col gap-6 self-stretch">
<div className="-mt-6 -mr-6 flex items-center justify-end gap-2">
<label htmlFor={`${id}-size`} className="text-sm text-muted-foreground">
Size
</label>
<Select value={size} onValueChange={(value) => setSize(value as Size)}>
<SelectTrigger id={`${id}-size`} size="sm" className="w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="sm">Small</SelectItem>
<SelectItem value="md">Medium</SelectItem>
<SelectItem value="lg">Large</SelectItem>
</SelectContent>
</Select>
</div>
<ul className="flex flex-1 flex-wrap items-center justify-center gap-x-10 gap-y-6">
{variants.map(({ variant, label }) => (
<li key={variant} className="flex flex-col items-center gap-2">
<Switcher
defaultValue="week"
variant={variant}
size={size}
aria-label={`Range, ${label.toLowerCase()} style`}
>
<SwitcherItem value="day">Day</SwitcherItem>
<SwitcherItem value="week">Week</SwitcherItem>
<SwitcherItem value="month">Month</SwitcherItem>
<SwitcherItem value="year">Year</SwitcherItem>
</Switcher>
<span className="text-xs text-muted-foreground">{label}</span>
</li>
))}
</ul>
<div aria-hidden className="-mb-6 h-(--size-control-sm)" />
</div>
);
}Installation
Install the dependencies.
$ pnpm add class-variance-authority motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --size-roll-fade: 0.25em; --size-roll-fade-x: 0.5em; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-overlay: 1200; --z-modal: 1300; --z-popover: 1400; --z-toast: 1500; --z-tooltip: 1600; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 40%, transparent); } .dark { --destructive-foreground: oklch(1 0 0); --overlay: color-mix(in oklch, oklch(0 0 0) 60%, transparent); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --animate-collapsible-open: collapsible-open var(--duration-slow) cubic-bezier(0.22, 1, 0.36, 1); --animate-collapsible-close: collapsible-close var(--duration-base) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); --color-overlay: var(--overlay); @keyframes popover-in { from { opacity: 0; filter: blur(6px); transform: scale(0.96); } to { opacity: 1; filter: blur(0); transform: scale(1); } } @keyframes popover-out { from { opacity: 1; filter: blur(0); transform: scale(1); } to { opacity: 0; filter: blur(6px); transform: scale(0.96); } } @keyframes collapsible-open { from { height: 0; opacity: 0; filter: blur(6px); } to { height: var(--collapsible-height); opacity: 1; filter: blur(0); } } @keyframes collapsible-close { from { height: var(--collapsible-height); opacity: 1; filter: blur(0); } to { height: 0; opacity: 0; filter: blur(6px); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring; /** * The blur (owner, 2026-10-15): content that appears, leaves or swaps sharpens in from a 6px blur as it * fades, instead of only fading. Pair it with the movement the pattern already has. Not for fills, * highlights, hover or anything full-screen (those still fade), and with reduced motion it's opacity only. * The CSS keyframes in tokens.css use the same 6px (checked by `pnpm check:tokens`). */ export const blur = { hidden: "blur(6px)", shown: "blur(0px)" } as const; /** Swapping content in place (Loading Button, Copy Button): the new rises 6px from blurred; the old lifts away blurring */ export const swap = { initial: { opacity: 0, filter: blur.hidden, y: 6 }, animate: { opacity: 1, filter: blur.shown, y: 0 }, exit: { opacity: 0, filter: blur.hidden, y: -6 }, } as const; /** * The flow of a changing number (Number Ticker): a spring for the characters' moves, the width and the digits' * spin, sampled into CSS linear(), and a fade for characters coming and going. Web Animations timings, in * milliseconds. The curve is from number-flow, © 2024 Maxwell Barvian, MIT licence (its full notice is in * components/number-ticker/number-ticker.tsx). */ export const flow = { transform: { duration: 900, easing: "linear(0,.005,.019,.039,.066,.096,.129,.165,.202,.24,.278,.316,.354,.39,.426,.461,.494,.526,.557,.586,.614,.64,.665,.689,.711,.731,.751,.769,.786,.802,.817,.831,.844,.856,.867,.877,.887,.896,.904,.912,.919,.925,.931,.937,.942,.947,.951,.955,.959,.962,.965,.968,.971,.973,.976,.978,.98,.981,.983,.984,.986,.987,.988,.989,.99,.991,.992,.992,.993,.994,.994,.995,.995,.996,.996,.9963,.9967,.9969,.9972,.9975,.9977,.9979,.9981,.9982,.9984,.9985,.9987,.9988,.9989,1)", }, opacity: { duration: 450, easing: "ease-out" }, } as const satisfies Record<string, EffectTiming>;Copy the source into your project.
"use client"; import { spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { cva, type VariantProps } from "class-variance-authority"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type Ref, useContext, useId, useLayoutEffect, useRef, useState, } from "react"; /* * Switcher: one choice from a few, like Day / Week / Month (a segmented control). Owner, 2026-10-14: split * from Toggle Group, which switches several options on and off; Lab only until it's approved, so no meta * yet. * - A pill slides to the active option (layoutId on spring.snappy, .agents/rules/motion.md), scoped to each * Switcher, so two never trade a pill. * - There's always a choice: clicking the active option keeps it. * - A radiogroup of radio items (Radix Toggle Group, single). Arrow keys move between options; Home and End * jump to the ends. * - Flat. The pill takes the page's own colour, dark in dark mode and light in light mode (owner, * 2026-10-14). Default: a muted track. Outline: a bordered track, and the pill gets a hairline too, so it * shows on any surface. No shadows and no * scale. Inactive options are muted and brighten on hover. * - The pill's corners follow the option's own radius (read from it), so they stay true while it changes * width between options of different sizes. */ const switcherVariants = cva("relative isolate inline-flex w-fit items-center gap-0.5 rounded-lg p-0.5", { variants: { variant: { default: "bg-muted", outline: "border border-input", }, }, defaultVariants: { variant: "default" }, }); // The options sit 2px inside the track, so the track is a control height (32, 36 or 40px) const switcherItemVariants = cva( [ "relative inline-flex shrink-0 cursor-pointer items-center justify-center gap-1.5 rounded-md text-sm font-medium whitespace-nowrap", "text-muted-foreground transition-colors duration-(--duration-base) ease-out data-[state=off]:hover:text-foreground data-[state=on]:text-foreground", "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "disabled:pointer-events-none disabled:opacity-(--opacity-disabled)", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)", ], { variants: { size: { sm: "h-7 px-2.5", md: "h-8 px-3", lg: "h-9 px-4", }, }, defaultVariants: { size: "md" }, }, ); const SwitcherContext = createContext<{ value: string; variant: "default" | "outline"; size: "sm" | "md" | "lg"; reduce: boolean; } | null>(null); type RootProps = Omit< Extract<ComponentProps<typeof ToggleGroupPrimitive.Root>, { type: "single" }>, "type" | "value" | "defaultValue" | "onValueChange" >; export type SwitcherProps = RootProps & VariantProps<typeof switcherVariants> & VariantProps<typeof switcherItemVariants> & { /** The chosen option, when you control it */ value?: string; /** The option chosen at first */ defaultValue?: string; /** Called with the new option when the choice changes */ onValueChange?: (value: string) => void; }; function Switcher({ value, defaultValue, onValueChange, variant, size, className, children, ...props }: SwitcherProps) { const groupId = useId(); const reduce = useReducedMotion() ?? false; // Mirror the value, controlled or not, so options know whether they're on const [internal, setInternal] = useState(defaultValue ?? ""); const current = value ?? internal; return ( <SwitcherContext.Provider value={{ value: current, variant: variant ?? "default", size: size ?? "md", reduce }} > <ToggleGroupPrimitive.Root data-slot="switcher" data-variant={variant ?? "default"} type="single" value={current} onValueChange={(next) => { // There's always a choice: Radix sends "" when the active option is clicked again if (!next) return; setInternal(next); onValueChange?.(next); }} className={cn(switcherVariants({ variant }), className)} {...props} > <LayoutGroup id={groupId}>{children}</LayoutGroup> </ToggleGroupPrimitive.Root> </SwitcherContext.Provider> ); } /** Hands the option's node to our ref and to the caller's */ function useMergedRef<T>(own: { current: T | null }, theirs?: Ref<T>) { return (node: T | null) => { own.current = node; if (typeof theirs === "function") theirs(node); else if (theirs) (theirs as { current: T | null }).current = node; }; } function SwitcherItem({ className, children, value, ref, ...props }: ComponentProps<typeof ToggleGroupPrimitive.Item>) { const switcher = useContext(SwitcherContext); const node = useRef<HTMLButtonElement>(null); const setRef = useMergedRef(node, ref); const [radius, setRadius] = useState(0); // A numeric radius lets motion keep the pill's corners true while it changes width useLayoutEffect(() => { if (node.current) setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || 0); }, []); return ( <ToggleGroupPrimitive.Item ref={setRef} data-slot="switcher-item" value={value} className={cn(switcherItemVariants({ size: switcher?.size }), className)} {...props} > {switcher?.value === value ? ( // Options aren't stacking contexts: the pill joins the Switcher's layer (it's `isolate`), so it passes // behind every option's label as it slides <motion.span layoutId="switcher-pill" aria-hidden className={cn( "absolute inset-0 -z-10", switcher.variant === "outline" ? "border border-input bg-background" : "bg-background", )} style={{ borderRadius: radius }} transition={switcher.reduce ? { duration: 0 } : spring.snappy} /> ) : null} {children} </ToggleGroupPrimitive.Item> ); } export { Switcher, SwitcherItem, switcherItemVariants, switcherVariants };Update the import paths to match your project.
Examples
Icons
Name each icon-only option with aria-label, so screen readers can tell them apart.
import { Switcher, SwitcherItem } from "@/components/ui/switcher";
import { AlignCenterIcon, AlignLeftIcon, AlignRightIcon } from "lucide-react";
export default function SwitcherIcons() {
return (
<Switcher defaultValue="left" aria-label="Text alignment">
<SwitcherItem value="left" aria-label="Align left">
<AlignLeftIcon />
</SwitcherItem>
<SwitcherItem value="center" aria-label="Align centre">
<AlignCenterIcon />
</SwitcherItem>
<SwitcherItem value="right" aria-label="Align right">
<AlignRightIcon />
</SwitcherItem>
</Switcher>
);
}Icons and text
import { Switcher, SwitcherItem } from "@/components/ui/switcher";
import { LayoutGridIcon, ListIcon } from "lucide-react";
export default function SwitcherIconsAndText() {
return (
<Switcher defaultValue="list" variant="outline" aria-label="View">
<SwitcherItem value="list">
<ListIcon />
List
</SwitcherItem>
<SwitcherItem value="grid">
<LayoutGridIcon />
Grid
</SwitcherItem>
</Switcher>
);
}API reference
Switcher
One choice from a few: a radiogroup, built on Radix Toggle Group. Arrow keys move between options; Home and End jump to the ends. Give it a name with aria-label.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string | – | The chosen option, when you control it. |
defaultValue? | string | – | The option chosen at first. |
onValueChange? | (value: string) => void | – | Called with the new option. Clicking the chosen option again keeps it. |
variant? | "default" | "outline" | "default" | Default: a muted track. Outline: a bordered track, with a bordered pill. The pill is dark in dark mode and light in light mode. |
size? | "sm" | "md" | "lg" | "md" | 32, 36 or 40px tall, matching Button. |
disabled? | boolean | false | Disables every option. |
SwitcherItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this option picks. |
disabled? | boolean | false | Skips the option. |
Related components
An accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.
An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
An accessible React button for Next.js and Tailwind CSS with six variants, three sizes, icons and a soft tint on hover and press, built on a native button.