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

Checkbox

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

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

import { Checkbox } from "@/components/ui/checkbox"

export function Terms() {
  return (
    <>
      <Checkbox id="terms" />
      <label htmlFor="terms">Accept terms and conditions</label>
    </>
  )
}
default.tsx
import { Checkbox } from "@/components/ui/checkbox";

export default function CheckboxDefault() {
  return <Checkbox aria-label="Accept terms" defaultChecked />;
}

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/checkbox.tsx
    "use client";
    
    import { duration, ease } from "@/lib/motion";
    import { cn } from "@/lib/utils";
    import { cva, type VariantProps } from "class-variance-authority";
    import { motion, useReducedMotion } from "motion/react";
    import { Checkbox as CheckboxPrimitive } from "radix-ui";
    import { type ComponentProps, useState } from "react";
    
    /*
     * A checkbox on Radix Checkbox (owner, 2026-10-15).
     * - Motion (.agents/rules/motion.md): the fill grows from the centre as it fades in (CSS, the same `before:`
     *   layer as Button's tint) and the check draws itself in (pathLength, the tiny-stroke exception).
     *   Unchecking is checking played backwards: the check erases, then the fill shrinks and fades. Indeterminate is a dash that draws in the same way;
     *   between checked and indeterminate the fill stays and one mark erases as the other draws.
     * - Nothing animates on first paint. With reduced motion the marks appear and go at once.
     * - The state is held here and always passed to Radix, controlled or not, so the marks know what to draw.
     *   When its form resets, Radix restores the first state through onCheckedChange, so that still works.
     * - Sizes match Switch's track heights (16 and 20px), so the two line up in a form. Flat, no shadow.
     */
    
    const checkboxVariants = cva(
      [
        "peer relative isolate inline-flex shrink-0 cursor-pointer items-center justify-center border border-input bg-transparent text-primary-foreground",
        "transition-colors duration-(--duration-fast) ease-out enabled:hover:border-ring",
        // The fill covers the border, so checked is one solid shape. It grows from 60% as it fades in (owner,
        // 2026-10-15: "the BG can also animate a little"). Unchecking, it waits for the check to start erasing, then
        // shrinks and fades a little quicker.
        "before:pointer-events-none before:absolute before:-inset-px before:-z-10 before:bg-primary before:opacity-0 before:scale-60",
        "before:transition before:duration-(--duration-fast) before:ease-in data-[state=unchecked]:before:delay-(--duration-instant)",
        "not-data-[state=unchecked]:before:scale-100 not-data-[state=unchecked]:before:opacity-100 not-data-[state=unchecked]:before:duration-(--duration-base) not-data-[state=unchecked]:before:ease-out",
        "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)",
      ],
      {
        variants: {
          size: {
            sm: "size-4 rounded-xs before:rounded-xs",
            md: "size-5 rounded-sm before:rounded-sm",
          },
        },
        defaultVariants: { size: "md" },
      },
    );
    
    /** The same check as Copy Button and Loading Button, and a dash for indeterminate */
    const CHECK = "M5 12.5l4.5 4.5L19 7.5";
    const DASH = "M7 12h10";
    
    /**
     * A mark that draws itself in, after the fill has started, and erases the way it came. Its opacity goes
     * with it, so a round cap never leaves a dot behind.
     */
    function Mark({ d, shown, reduce }: { d: string; shown: boolean; reduce: boolean }) {
      return (
        <motion.path
          d={d}
          stroke="currentColor"
          strokeWidth="2.5"
          strokeLinecap="round"
          strokeLinejoin="round"
          initial={false}
          animate={{ pathLength: shown ? 1 : 0, opacity: shown ? 1 : 0 }}
          transition={
            reduce
              ? { duration: 0 }
              : shown
                ? {
                    pathLength: { duration: duration.base, ease: ease.out, delay: duration.instant },
                    opacity: { duration: 0, delay: duration.instant },
                  }
                : { duration: duration.fast, ease: ease.in }
          }
        />
      );
    }
    
    export type CheckboxProps = ComponentProps<typeof CheckboxPrimitive.Root> &
      VariantProps<typeof checkboxVariants>;
    
    /**
     * A checkbox for choices submitted with a form, built on Radix Checkbox (role="checkbox", Space toggles).
     * Set `checked` to "indeterminate" for a select-all box when only some are picked. Give it a visible label
     * (`<label htmlFor>`).
     */
    function Checkbox({ className, size, checked, defaultChecked, onCheckedChange, ...props }: CheckboxProps) {
      const [uncontrolled, setUncontrolled] = useState(defaultChecked ?? false);
      const state = checked ?? uncontrolled;
      const reduce = useReducedMotion() ?? false;
    
      return (
        <CheckboxPrimitive.Root
          data-slot="checkbox"
          data-size={size ?? "md"}
          checked={state}
          onCheckedChange={(value) => {
            if (checked === undefined) setUncontrolled(value);
            onCheckedChange?.(value);
          }}
          className={cn(checkboxVariants({ size }), className)}
          {...props}
        >
          <svg data-slot="checkbox-indicator" viewBox="0 0 24 24" fill="none" aria-hidden className="size-full">
            <Mark d={CHECK} shown={state === true} reduce={reduce} />
            <Mark d={DASH} shown={state === "indeterminate"} reduce={reduce} />
          </svg>
        </CheckboxPrimitive.Root>
      );
    }
    
    export { Checkbox, checkboxVariants };
  5. 5

    Update the import paths to match your project.

Examples

Label and hint

A visible label, and a hint linked with aria-describedby.

You agree to the Terms of Service and the Privacy Policy.

hint.tsx
import { Checkbox } from "@/components/ui/checkbox";
import { useId } from "react";

export default function CheckboxHint() {
  const id = useId();
  return (
    <div className="flex w-full max-w-sm items-start gap-3">
      <Checkbox id={id} aria-describedby={`${id}-hint`} />
      <div className="grid gap-0.5">
        <label htmlFor={id} className="text-sm font-medium">
          Accept terms and conditions
        </label>
        <p id={`${id}-hint`} className="text-sm text-muted-foreground">
          You agree to the Terms of Service and the Privacy Policy.
        </p>
      </div>
    </div>
  );
}

Select all

The parent shows a dash while only some are picked; checking it picks them all. Checked and dash swap marks without the fill moving.

Notification channels
select-all.tsx
"use client";

import { Checkbox } from "@/components/ui/checkbox";
import { useId, useState } from "react";

const channels = [
  { key: "email", label: "Email" },
  { key: "push", label: "Push notifications" },
  { key: "sms", label: "Text messages" },
];

/** The parent is checked when every channel is, a dash when only some are, and empty when none are */
export default function CheckboxSelectAll() {
  const id = useId();
  const [picked, setPicked] = useState<string[]>(["email"]);
  const all = picked.length === channels.length ? true : picked.length > 0 ? "indeterminate" : false;

  return (
    <fieldset className="grid gap-3">
      <legend className="sr-only">Notification channels</legend>
      <div className="flex items-center gap-3">
        <Checkbox
          id={`${id}-all`}
          checked={all}
          onCheckedChange={(checked) =>
            setPicked(checked === true ? channels.map((channel) => channel.key) : [])
          }
        />
        <label htmlFor={`${id}-all`} className="text-sm font-medium">
          All channels
        </label>
      </div>
      {/* Indented to line up with the parent's label: the 20px box plus the 12px gap */}
      <div className="grid gap-3 ps-8">
        {channels.map((channel) => (
          <div key={channel.key} className="flex items-center gap-3">
            <Checkbox
              id={`${id}-${channel.key}`}
              checked={picked.includes(channel.key)}
              onCheckedChange={(checked) =>
                setPicked((current) =>
                  checked === true ? [...current, channel.key] : current.filter((key) => key !== channel.key),
                )
              }
            />
            <label htmlFor={`${id}-${channel.key}`} className="text-sm">
              {channel.label}
            </label>
          </div>
        ))}
      </div>
    </fieldset>
  );
}

Sizes

sizes.tsx
import { Checkbox } from "@/components/ui/checkbox";

export default function CheckboxSizes() {
  return (
    <div className="flex items-center gap-6">
      <Checkbox size="sm" aria-label="Small checkbox" defaultChecked />
      <Checkbox aria-label="Medium checkbox" defaultChecked />
    </div>
  );
}

Disabled

disabled.tsx
import { Checkbox } from "@/components/ui/checkbox";
import { useId } from "react";

export default function CheckboxDisabled() {
  const id = useId();
  return (
    <div className="grid gap-3">
      <div className="flex items-center gap-3">
        <Checkbox id={`${id}-backup`} disabled defaultChecked />
        <label htmlFor={`${id}-backup`} className="text-sm font-medium text-muted-foreground">
          Back up automatically
        </label>
      </div>
      <div className="flex items-center gap-3">
        <Checkbox id={`${id}-mobile`} disabled />
        <label htmlFor={`${id}-mobile`} className="text-sm font-medium text-muted-foreground">
          Sync over mobile data
        </label>
      </div>
    </div>
  );
}

API reference

Also accepts every Radix Checkbox prop. Inside a form it submits through a hidden native checkbox, and a form reset restores its first state.

PropTypeDefaultDescription
checked?boolean | "indeterminate"–The state, when you control it. Indeterminate shows a dash.
defaultChecked?boolean | "indeterminate"falseThe initial state.
onCheckedChange?(checked: boolean | "indeterminate") => void–Called when the user checks or unchecks it. An indeterminate box becomes checked.
size?"sm" | "md""md"16 or 20px, the heights of Switch's sizes.
disabled?booleanfalsePrevents interaction.
required?booleanfalseMust be checked to submit the form.
name?string–Submits the value with a form.
value?string"on"The value submitted when checked.

Related components

  • Switch

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

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

  • Input

    An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.