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

Radio Group

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

An accessible React radio group for Next.js forms, where the selected dot travels to the new choice, built on Radix Radio Group.

  • Slide
  • Stretch
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"

export function Density() {
  return (
    <RadioGroup defaultValue="comfortable" aria-label="Density">
      <div>
        <RadioGroupItem id="comfortable" value="comfortable" />
        <label htmlFor="comfortable">Comfortable</label>
      </div>
      <div>
        <RadioGroupItem id="compact" value="compact" />
        <label htmlFor="compact">Compact</label>
      </div>
    </RadioGroup>
  )
}
travel.tsx
"use client";

import {
  RadioGroup,
  RadioGroupItem,
  type RadioGroupTravel,
} from "@/components/ui/radio-group";
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select";
import { useId, useState } from "react";

type Size = "sm" | "md";

const travels: { travel: RadioGroupTravel; label: string }[] = [
  { travel: "slide", label: "Slide" },
  { travel: "stretch", label: "Stretch" },
];
const options = ["Default", "Comfortable", "Compact"];

/*
 * Both ways of travelling 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 RadioGroupTravelDemo() {
  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>
          </SelectContent>
        </Select>
      </div>

      <ul className="flex flex-1 flex-wrap items-center justify-center gap-x-16 gap-y-8">
        {travels.map(({ travel, label }) => (
          <li key={travel} className="flex flex-col items-center gap-4">
            <RadioGroup
              defaultValue="comfortable"
              travel={travel}
              size={size}
              aria-label={`Density, ${label.toLowerCase()}`}
            >
              {options.map((option) => (
                <div key={option} className="flex items-center gap-3">
                  <RadioGroupItem id={`${id}-${travel}-${option}`} value={option.toLowerCase()} />
                  <label htmlFor={`${id}-${travel}-${option}`} className="text-sm">
                    {option}
                  </label>
                </div>
              ))}
            </RadioGroup>
            <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/radio-group.tsx
    "use client";
    
    import { duration, spring } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva } from "class-variance-authority";
    import {
      type AnimationPlaybackControls,
      animate,
      motion,
      useMotionValue,
      useReducedMotion,
    } from "motion/react";
    import { RadioGroup as RadioGroupPrimitive } from "radix-ui";
    import {
      type ComponentProps,
      createContext,
      useContext,
      useEffect,
      useLayoutEffect,
      useRef,
      useState,
    } from "react";
    
    /*
     * Radio Group (owner, 2026-10-15; chosen in the Lab).
     * - The selection travels: when the choice changes, the dot leaves the old radio and lands in the new one.
     *   Two ways (`travel`), both chosen by the owner:
     *   - slide (the default): straight across on spring.snappy
     *   - stretch: the front leaves first and the back follows a moment later, so it stretches into a capsule
     *     for the trip and lands round, like Switch's thumb
     * - At rest each dot sits inside its own radio, so nothing is measured until the choice changes. For a
     *   trip, one shared dot moves at the group's level, placed with offsets (layout numbers, so a zoomed or
     *   scaled preview doesn't double the movement), then hands back to the new radio's own dot in the same
     *   frame.
     * - Nothing animates on first paint. With reduced motion the dot moves at once.
     */
    
    export type RadioGroupTravel = "slide" | "stretch";
    type Size = "sm" | "md";
    
    type GroupContextValue = {
      value: string;
      /** The radio the shared dot is on its way to; its own dot waits until it lands */
      arriving: string | null;
      size: Size;
      register: (value: string, node: HTMLElement | null) => void;
    };
    
    const GroupContext = createContext<GroupContextValue | null>(null);
    
    const radioGroupItemVariants = cva(
      [
        "peer relative inline-flex shrink-0 cursor-pointer items-center justify-center rounded-full border border-input bg-transparent",
        "transition-colors duration-(--duration-fast) ease-out data-[state=checked]:border-primary data-[state=unchecked]:enabled:hover:border-ring",
        "outline-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)",
        "aria-invalid:border-destructive aria-invalid:outline-destructive",
        "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)",
      ],
      {
        // The same sizes as Checkbox and Switch (16 and 20px)
        variants: { size: { sm: "size-4", md: "size-5" } },
        defaultVariants: { size: "md" },
      },
    );
    
    const dotSize = { sm: "size-1.5", md: "size-2" } as const;
    
    /** A radio's centre in the group, in layout pixels (offsets, so transforms and zoom don't count) */
    function centreOf(node: HTMLElement, root: HTMLElement) {
      let x = node.offsetWidth / 2;
      let y = node.offsetHeight / 2;
      let element: HTMLElement | null = node;
      while (element && element !== root) {
        x += element.offsetLeft;
        y += element.offsetTop;
        element = element.offsetParent as HTMLElement | null;
      }
      return element === root ? { x, y } : null;
    }
    
    export type RadioGroupProps = ComponentProps<typeof RadioGroupPrimitive.Root> & {
      /** 16 or 20px radios */
      size?: Size;
      /** How the dot travels to the new choice: straight across, or stretching on the way */
      travel?: RadioGroupTravel;
    };
    
    /**
     * One choice from a few, built on Radix Radio Group (role="radiogroup", arrow keys move and select).
     * Give each RadioGroupItem a visible label (`<label htmlFor>`).
     */
    function RadioGroup({
      className,
      size = "md",
      travel = "slide",
      value,
      defaultValue,
      onValueChange,
      children,
      ref,
      ...props
    }: RadioGroupProps) {
      const [uncontrolled, setUncontrolled] = useState(defaultValue ?? "");
      const current = value ?? uncontrolled;
      const reduce = useReducedMotion() ?? false;
      const root = useRef<HTMLDivElement>(null);
      const items = useRef(new Map<string, HTMLElement>());
      const [arriving, setArriving] = useState<string | null>(null);
      // The shared dot's diameter while it travels
      const [trip, setTrip] = useState<number | null>(null);
    
      // The shared dot: its back end (x, y), its length along the trip (width) and the trip's angle
      const x = useMotionValue(0);
      const y = useMotionValue(0);
      const width = useMotionValue(0);
      const angle = useMotionValue(0);
      const running = useRef<AnimationPlaybackControls[]>([]);
      const tripId = useRef(0);
      const previous = useRef(current);
    
      // Unmounting mid-trip stops the animations
      useEffect(
        () => () => {
          for (const animation of running.current) animation.stop();
        },
        [],
      );
    
      // A new choice: send the shared dot from where it is (the old radio, or mid-trip) to the new radio
      // biome-ignore lint/correctness/useExhaustiveDependencies: only a change of choice starts a trip
      useLayoutEffect(() => {
        const from = previous.current;
        previous.current = current;
        if (from === current) return;
    
        const box = root.current;
        const start = items.current.get(from);
        const end = items.current.get(current);
        const a = box && start ? centreOf(start, box) : null;
        const b = box && end ? centreOf(end, box) : null;
        const shape = end?.querySelector<HTMLElement>("[data-slot=radio-group-indicator]");
        for (const animation of running.current) animation.stop();
        running.current = [];
        if (reduce || !a || !b || !shape) {
          setTrip(null);
          setArriving(null);
          return;
        }
    
        // Mid-trip, it leaves from where its front is now
        const size = shape.offsetWidth;
        const midTrip = trip !== null;
        const radians = (angle.get() * Math.PI) / 180;
        const origin = midTrip
          ? {
              x: x.get() + Math.cos(radians) * (width.get() - size),
              y: y.get() + Math.sin(radians) * (width.get() - size),
            }
          : a;
        const dx = b.x - origin.x;
        const dy = b.y - origin.y;
        const distance = Math.hypot(dx, dy);
    
        // Placed with jump(), not set(): set() counts the move from 0 (or the last trip) as speed, and the
        // slide's spring started with it, so the first trip overshot (owner, 2026-10-15). A slide turned
        // mid-trip keeps its speed, so it curves to the new radio smoothly.
        if (travel === "stretch" || !midTrip) {
          x.jump(origin.x);
          y.jump(origin.y);
        }
        width.jump(size);
        angle.jump((Math.atan2(dy, dx) * 180) / Math.PI);
        setTrip(size);
        setArriving(current);
    
        const id = ++tripId.current;
        let trips: AnimationPlaybackControls[];
        if (travel === "stretch") {
          // The front leaves first and the back follows a moment later
          const ends = { front: 0, back: 0 };
          const update = () => {
            x.set(origin.x + dx * ends.back);
            y.set(origin.y + dy * ends.back);
            width.set(size + Math.max(0, ends.front - ends.back) * distance);
          };
          trips = [
            animate(0, 1, {
              ...spring.snappy,
              onUpdate: (value) => {
                ends.front = value;
                update();
              },
            }),
            animate(0, 1, {
              ...spring.snappy,
              delay: duration.instant,
              onUpdate: (value) => {
                ends.back = value;
                update();
              },
            }),
          ];
        } else {
          trips = [animate(x, b.x, spring.snappy), animate(y, b.y, spring.snappy)];
        }
        running.current = trips;
        Promise.all(trips).then(() => {
          if (id !== tripId.current) return;
          running.current = [];
          setTrip(null);
          setArriving(null);
        });
      }, [current]);
    
      return (
        <GroupContext.Provider
          value={{
            value: current,
            arriving,
            size,
            register: (name, node) => {
              if (node) items.current.set(name, node);
              else items.current.delete(name);
            },
          }}
        >
          <RadioGroupPrimitive.Root
            ref={(node) => {
              root.current = node;
              if (typeof ref === "function") ref(node);
              else if (ref) ref.current = node;
            }}
            data-slot="radio-group"
            value={current}
            onValueChange={(next) => {
              if (value === undefined) setUncontrolled(next);
              onValueChange?.(next);
            }}
            className={cn("relative isolate grid gap-3", className)}
            {...props}
          >
            {children}
            {trip !== null ? (
              // The shared dot: a zero-size point at its back end, turned towards the new radio, with the dot
              // (or, while stretching, the capsule) drawn out from it
              <motion.span
                aria-hidden
                data-slot="radio-group-travelling-dot"
                className="pointer-events-none absolute top-0 left-0 z-10 size-0"
                style={{ x, y, rotate: angle, originX: 0, originY: 0 }}
              >
                <motion.span
                  className="absolute rounded-full bg-primary"
                  style={{ left: -trip / 2, top: -trip / 2, height: trip, width }}
                />
              </motion.span>
            ) : null}
          </RadioGroupPrimitive.Root>
        </GroupContext.Provider>
      );
    }
    
    export type RadioGroupItemProps = ComponentProps<typeof RadioGroupPrimitive.Item>;
    
    /** One radio. Its dot shows while it's chosen, except while the shared dot is still on its way. */
    function RadioGroupItem({ className, value, ref, ...props }: RadioGroupItemProps) {
      const group = useContext(GroupContext);
      const size = group?.size ?? "md";
      const shown = group?.value === value && group.arriving !== value;
    
      return (
        <RadioGroupPrimitive.Item
          ref={(node) => {
            group?.register(value, node);
            if (typeof ref === "function") ref(node);
            else if (ref) ref.current = node;
          }}
          data-slot="radio-group-item"
          value={value}
          className={cn(radioGroupItemVariants({ size }), className)}
          {...props}
        >
          {/* Appears and goes at once, handing over to and from the shared dot */}
          <span
            aria-hidden
            data-slot="radio-group-indicator"
            className={cn(
              "pointer-events-none rounded-full bg-primary",
              dotSize[size],
              shown ? "scale-100" : "scale-0",
            )}
          />
        </RadioGroupPrimitive.Item>
      );
    }
    
    export { RadioGroup, RadioGroupItem, radioGroupItemVariants };
  5. 5

    Update the import paths to match your project.

Examples

In a row

Left and right arrow keys move along the row, and the dot follows.

horizontal.tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";

export default function RadioGroupHorizontal() {
  const id = useId();
  return (
    <RadioGroup defaultValue="m" orientation="horizontal" aria-label="Size" className="flex flex-wrap gap-6">
      {["S", "M", "L", "XL"].map((option) => (
        <div key={option} className="flex items-center gap-2">
          <RadioGroupItem id={`${id}-${option}`} value={option.toLowerCase()} />
          <label htmlFor={`${id}-${option}`} className="text-sm">
            {option}
          </label>
        </div>
      ))}
    </RadioGroup>
  );
}

Cards

Each card is the radio's label, so the whole card picks it. Its border follows the choice.

cards.tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";

const plans = [
  { value: "starter", title: "Starter", price: "Free", note: "For trying things out." },
  { value: "pro", title: "Pro", price: "$12 a month", note: "For one person shipping." },
  { value: "team", title: "Team", price: "$40 a month", note: "For a small team." },
];

/** Each card is the radio's label, so a click anywhere on it picks the plan; the border follows the choice */
export default function RadioGroupCards() {
  const id = useId();
  return (
    <RadioGroup defaultValue="pro" aria-label="Plan" className="w-full max-w-2xl gap-3 sm:grid-cols-3">
      {plans.map((plan) => (
        <label
          key={plan.value}
          htmlFor={`${id}-${plan.value}`}
          className="grid cursor-pointer gap-1 rounded-lg border border-input bg-background p-4 transition-colors duration-(--duration-fast) ease-out has-data-[state=checked]:border-primary"
        >
          <span className="flex items-center justify-between gap-2">
            <span className="text-sm font-medium">{plan.title}</span>
            <RadioGroupItem id={`${id}-${plan.value}`} value={plan.value} />
          </span>
          <span className="text-sm">{plan.price}</span>
          <span className="text-xs text-muted-foreground">{plan.note}</span>
        </label>
      ))}
    </RadioGroup>
  );
}

Disabled

One option can't be picked; arrow keys skip it.

disabled.tsx
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { useId } from "react";

const options = [
  { value: "email", label: "Email" },
  { value: "sms", label: "Text message", disabled: true, hint: "(not available in your region)" },
  { value: "app", label: "Authenticator app" },
];

export default function RadioGroupDisabled() {
  const id = useId();
  return (
    <RadioGroup defaultValue="email" aria-label="Two-step sign-in">
      {options.map((option) => (
        <div key={option.value} className="flex items-center gap-3">
          <RadioGroupItem id={`${id}-${option.value}`} value={option.value} disabled={option.disabled} />
          <label
            htmlFor={`${id}-${option.value}`}
            className={option.disabled ? "text-sm text-muted-foreground" : "text-sm"}
          >
            {option.label} {option.hint}
          </label>
        </div>
      ))}
    </RadioGroup>
  );
}

API reference

RadioGroup

Also accepts every Radix Radio Group prop. Arrow keys move the choice; Tab leaves the group.

PropTypeDefaultDescription
value?string–The chosen value, when you control it.
defaultValue?string–The value chosen at first.
onValueChange?(value: string) => void–Called when the user picks another option.
travel?"slide" | "stretch""slide"How the dot travels to the new choice: straight across, or stretching on the way.
size?"sm" | "md""md"16 or 20px radios, the sizes of Checkbox and Switch.
orientation?"horizontal" | "vertical"–Which arrow keys move the choice. Both work when it's left out.
disabled?booleanfalsePrevents interaction with every option.
required?booleanfalseA choice is needed to submit the form.
name?string–Submits the chosen value with a form.

RadioGroupItem

One radio. Give it an id and a visible label with htmlFor.

PropTypeDefaultDescription
valuestring–The value this radio picks.
disabled?booleanfalsePrevents picking this option.

Related components

  • Checkbox

    An accessible React checkbox for Next.js forms, with a check that draws itself in and an indeterminate state, built on Radix Checkbox.

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

  • Switch

    An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.