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. Toggle Group

Toggle Group

pnpm dlx shadcn@latest add …/r/toggle-group.json

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

  • Default
  • Outline
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

export function Formatting() {
  return (
    <ToggleGroup defaultValue={["bold"]} aria-label="Formatting">
      <ToggleGroupItem value="bold">Bold</ToggleGroupItem>
      <ToggleGroupItem value="italic">Italic</ToggleGroupItem>
      <ToggleGroupItem value="underline">Underline</ToggleGroupItem>
    </ToggleGroup>
  )
}
variants.tsx
"use client";

import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
import { BoldIcon, ItalicIcon, UnderlineIcon } from "lucide-react";
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 ToggleGroupVariants() {
  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">
            <ToggleGroup
              defaultValue={["bold"]}
              variant={variant}
              size={size}
              aria-label={`Formatting, ${label.toLowerCase()} style`}
            >
              <ToggleGroupItem value="bold" aria-label="Bold">
                <BoldIcon />
              </ToggleGroupItem>
              <ToggleGroupItem value="italic" aria-label="Italic">
                <ItalicIcon />
              </ToggleGroupItem>
              <ToggleGroupItem value="underline" aria-label="Underline">
                <UnderlineIcon />
              </ToggleGroupItem>
            </ToggleGroup>
            <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/toggle-group.tsx
    "use client";
    
    import { duration, ease, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { AnimatePresence, motion, useReducedMotion } from "motion/react";
    import { ToggleGroup as ToggleGroupPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      type Ref,
      useContext,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    
    /*
     * Toggle Group: buttons you switch on and off independently, like Bold / Italic / Underline (owner,
     * 2026-10-14: split from Switcher, which is for one choice; Lab only until it's approved, so no meta yet).
     * - Each pressed option fades its own pill in (opacity, plus a small scale on spring.snappy) and out quickly.
     * - A toolbar of aria-pressed buttons (Radix Toggle Group, multiple). 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 on the buttons. Inactive options are muted and brighten on hover.
     * - The pill's corners follow the option's own radius (read from it).
     */
    
    const toggleGroupVariants = 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 toggleGroupItemVariants = 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 GroupContext = createContext<{
      value: string[];
      variant: "default" | "outline";
      size: "sm" | "md" | "lg";
      reduce: boolean;
    } | null>(null);
    
    type RootProps = Omit<
      Extract<ComponentProps<typeof ToggleGroupPrimitive.Root>, { type: "multiple" }>,
      "type" | "value" | "defaultValue" | "onValueChange"
    >;
    
    export type ToggleGroupProps = RootProps &
      VariantProps<typeof toggleGroupVariants> &
      VariantProps<typeof toggleGroupItemVariants> & {
        /** The options switched on, when you control them */
        value?: string[];
        /** The options switched on at first */
        defaultValue?: string[];
        /** Called with every option that's on, after each change */
        onValueChange?: (value: string[]) => void;
      };
    
    function ToggleGroup({
      value,
      defaultValue,
      onValueChange,
      variant,
      size,
      className,
      ...props
    }: ToggleGroupProps) {
      const reduce = useReducedMotion() ?? false;
      // Mirror the value, controlled or not, so options know whether they're on
      const [internal, setInternal] = useState<string[]>(defaultValue ?? []);
      const current = value ?? internal;
    
      return (
        <GroupContext.Provider
          value={{ value: current, variant: variant ?? "default", size: size ?? "md", reduce }}
        >
          <ToggleGroupPrimitive.Root
            data-slot="toggle-group"
            data-variant={variant ?? "default"}
            type="multiple"
            value={current}
            onValueChange={(next) => {
              setInternal(next);
              onValueChange?.(next);
            }}
            className={cn(toggleGroupVariants({ variant }), className)}
            {...props}
          />
        </GroupContext.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 ToggleGroupItem({
      className,
      children,
      value,
      ref,
      ...props
    }: ComponentProps<typeof ToggleGroupPrimitive.Item>) {
      const group = useContext(GroupContext);
      const node = useRef<HTMLButtonElement>(null);
      const setRef = useMergedRef(node, ref);
      const [radius, setRadius] = useState(0);
    
      // The pill takes the option's corner radius, whatever the user's --radius is
      useLayoutEffect(() => {
        if (node.current) setRadius(Number.parseFloat(getComputedStyle(node.current).borderTopLeftRadius) || 0);
      }, []);
    
      const on = group?.value.includes(value) ?? false;
      const reduce = group?.reduce ?? false;
    
      return (
        <ToggleGroupPrimitive.Item
          ref={setRef}
          data-slot="toggle-group-item"
          value={value}
          className={cn(toggleGroupItemVariants({ size: group?.size }), className)}
          {...props}
        >
          <AnimatePresence initial={false}>
            {on ? (
              <motion.span
                key="pill"
                aria-hidden
                className={cn(
                  "absolute inset-0 -z-10",
                  group?.variant === "outline" ? "border border-input bg-background" : "bg-background",
                )}
                style={{ borderRadius: radius }}
                initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.92 }}
                animate={{ opacity: 1, scale: 1 }}
                exit={
                  reduce
                    ? { opacity: 0 }
                    : { opacity: 0, scale: 0.92, transition: { duration: duration.fast, ease: ease.in } }
                }
                transition={reduce ? { duration: duration.fast } : spring.snappy}
              />
            ) : null}
          </AnimatePresence>
          {children}
        </ToggleGroupPrimitive.Item>
      );
    }
    
    export { ToggleGroup, ToggleGroupItem, toggleGroupItemVariants, toggleGroupVariants };
  5. 5

    Update the import paths to match your project.

Examples

Days

Text options work the same way, here picking the days to repeat on.

days.tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

const days = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];

export default function ToggleGroupDays() {
  return (
    <ToggleGroup defaultValue={["mon", "wed", "fri"]} aria-label="Repeat on">
      {days.map((day) => (
        <ToggleGroupItem key={day} value={day.toLowerCase()}>
          {day}
        </ToggleGroupItem>
      ))}
    </ToggleGroup>
  );
}

A disabled option

Disabled options don't respond and the arrow keys skip them.

disabled.tsx
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";

export default function ToggleGroupDisabled() {
  return (
    <ToggleGroup defaultValue={["email"]} variant="outline" aria-label="Notify by">
      <ToggleGroupItem value="email">Email</ToggleGroupItem>
      <ToggleGroupItem value="sms" disabled>
        SMS
      </ToggleGroupItem>
      <ToggleGroupItem value="push">Push</ToggleGroupItem>
    </ToggleGroup>
  );
}

API reference

ToggleGroup

Options switched on and off independently: a toolbar of pressed buttons, built on Radix Toggle Group. Arrow keys move between options; Home and End jump to the ends. For one choice, use Switcher. Give it a name with aria-label.

PropTypeDefaultDescription
value?string[]–The options switched on, when you control them.
defaultValue?string[]–The options switched on at first.
onValueChange?(value: string[]) => void–Called with every option that's on, after each change.
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.

ToggleGroupItem

PropTypeDefaultDescription
valuestring–The value this option adds when it's on.
disabled?booleanfalseSkips the option.

Related components

  • Switcher

    An accessible React segmented control for Next.js: one choice from a few options, with a pill that slides to the one you pick.

  • 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.

    • Ghost
    • Outline
    • With text
    Copy Button

    An accessible React copy to clipboard button for Next.js: the copy icon turns into a check, and screen readers hear "Copied".