FewTone UI
ComponentsDocs
  • Home

Getting started

  • Introduction
  • Installation
  • MCP Server
  • Smooth UI Skill
  • Roadmap

Components46

  • Accordion
  • Alert Dialog
  • Avatar
  • Badge
  • Breadcrumb
  • Button
  • Calendar
  • Card
  • Checkbox
  • Combobox
  • Command
  • Context Menu
  • Copy Button
  • Date Picker
  • Dialog
  • Dropdown Menu
  • Hover Card
  • Input
  • Input OTP
  • Loading Button
  • Marquee
  • Navigation Menu
  • Number Input
  • Number Ticker
  • Pagination
  • Popover
  • Progress
  • Radio Group
  • Scroll Area
  • Select
  • Sheet
  • Sidebar
  • Skeleton
  • Slider
  • Spinner
  • Stepper
  • Switch
  • Switcher
  • Table
  • Tabs
  • Text Effects
  • Textarea
  • Theme Toggle
  • Toast
  • Toggle Group
  • Tooltip
© 2026 FewTone · MIT licenceGitHub
  1. Components
  2. Switcher

Switcher

pnpm dlx shadcn@latest add …/r/switcher.json

An 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>
  )
}
variants.tsx
"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

  1. 1

    Install the dependencies.

    Package manager
    $ pnpm add class-variance-authority motion radix-ui
  2. 2

    Add the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.

    app/globals.css
    :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;
      }
    }
  3. 3

    Add the motion presets.

    lib/motion.ts
    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>;
  4. 4

    Copy the source into your project.

    components/ui/switcher.tsx
    "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 };
  5. 5

    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.

icons.tsx
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

icons-and-text.tsx
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.

PropTypeDefaultDescription
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?booleanfalseDisables every option.

SwitcherItem

PropTypeDefaultDescription
valuestring–The value this option picks.
disabled?booleanfalseSkips the option.

Related components

  • Toggle Group

    An accessible React toggle group for Next.js: buttons you switch on and off independently, like Bold, Italic and Underline in a toolbar.

  • Select

    An accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.

  • Button

    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.