Checkbox
Form choice control with a draw-on checkmark, spring press feedback and indeterminate state support.
Checkbox
checkbox.tsxDraw-on checkmark, spring press feedback and indeterminate state.
"use client";
import { useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
export function CheckboxPreview() {
const [terms, setTerms] = useState(true);
const [updates, setUpdates] = useState(false);
return (
<div className="flex flex-col gap-3">
<Checkbox
checked={terms}
onCheckedChange={setTerms}
label="Accept terms and conditions"
/>
<Checkbox
checked={updates}
onCheckedChange={setUpdates}
label="Email me product updates"
/>
<Checkbox checked indeterminate onCheckedChange={() => {}} label="Select all (partial)" />
<Checkbox checked disabled onCheckedChange={() => {}} label="Disabled" />
</div>
);
}
"use client";
// beui.dev/components/motion/checkbox
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
description,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label && !ariaLabel ? `${id}-label` : undefined;
const describedBy =
[ariaDescribedBy, descriptionId].filter(Boolean).join(" ") || undefined;
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-labelledby={labelId}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
With Description
checkbox.tsxOptional supporting text under each label, exposed to assistive technology as the description.
"use client";
import { useState } from "react";
import { Checkbox } from "@/components/motion/checkbox";
export function CheckboxDescriptionPreview() {
const [comments, setComments] = useState(true);
const [mentions, setMentions] = useState(true);
const [digest, setDigest] = useState(false);
return (
<div className="flex w-full max-w-64 flex-col gap-3">
<Checkbox
checked={comments}
onCheckedChange={setComments}
label="Comments"
description="Replies on threads you started."
/>
<Checkbox
checked={mentions}
onCheckedChange={setMentions}
label="Mentions"
description="Whenever someone tags you by name."
/>
<Checkbox
checked={digest}
onCheckedChange={setDigest}
label="Weekly digest"
description="A summary of activity every Monday."
/>
<Checkbox
checked={false}
disabled
onCheckedChange={() => {}}
label="SMS alerts"
description="Add a phone number to enable."
/>
</div>
);
}
"use client";
// beui.dev/components/motion/checkbox
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
description,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label && !ariaLabel ? `${id}-label` : undefined;
const describedBy =
[ariaDescribedBy, descriptionId].filter(Boolean).join(" ") || undefined;
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-labelledby={labelId}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
API Reference
checkedboolean—onCheckedChange(checked: boolean) => void—disabled?boolean—indeterminate?boolean—label?string—description?stringSupporting text under the label, announced as the description rather than the name.
—className?string—id?string—aria-label?string—aria-describedby?stringAssociates an external message (e.g. a form error) with the control.
—Install
Add it with the shadcn CLI, or copy the source manually.
shadcn init? You are set. Theme setupInstall dependencies
npm i clsx motion tailwind-mergeAdd util files
// Shared motion tokens. Easing curves mirror the CSS custom properties in
// globals.css; springs are the canonical physics used across components.
// Strong custom variants — defaults like `ease-in`/`ease-out` feel weak.
export const EASE_OUT = [0.16, 1, 0.3, 1] as const;
export const EASE_IN_OUT = [0.77, 0, 0.175, 1] as const;
export const EASE_DRAWER = [0.32, 0.72, 0, 1] as const;
/** CSS string form of EASE_OUT for inline style transitions. */
export const EASE_OUT_CSS = "cubic-bezier(0.16, 1, 0.3, 1)";
/** Press feedback on buttons and other tappable surfaces. */
export const SPRING_PRESS = {
type: "spring",
stiffness: 500,
damping: 30,
mass: 0.6,
} as const;
/** Content swaps — label/icon slots trading places inside a control. */
export const SPRING_SWAP = {
type: "spring",
stiffness: 460,
damping: 30,
mass: 0.55,
} as const;
/** Overlay panel entrances — modals and sheets summoned by pointer. */
export const SPRING_PANEL = {
type: "spring",
stiffness: 420,
damping: 40,
mass: 0.5,
} as const;
/** Shared-layout glides — pills, indicators and panels morphing between positions. */
export const SPRING_LAYOUT = {
type: "spring",
stiffness: 360,
damping: 32,
mass: 0.6,
} as const;
/** Cursor-follow physics for decorative mouse tracking (magnetic, tilt, dock). */
export const SPRING_MOUSE = {
stiffness: 200,
damping: 15,
mass: 0.3,
} as const;
/** Dragged handles and fills (sliders) — critically damped `useSpring` config,
* so the value follows the pointer butterily and never rebounds off an end. */
export const SPRING_GLIDE = {
stiffness: 700,
damping: 50,
mass: 0.5,
} as const;
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
Copy the source code
"use client";
// beui.dev/components/motion/checkbox
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useId } from "react";
import { EASE_OUT, SPRING_PRESS } from "@/lib/ease";
import { cn } from "@/lib/utils";
const CHECK_PATH = "M5 13l4 4L19 7";
const INDETERMINATE_PATH = "M6 12h12";
export interface CheckboxProps {
checked: boolean;
onCheckedChange: (checked: boolean) => void;
disabled?: boolean;
indeterminate?: boolean;
label?: string;
/** Supporting text under the label, announced as the description rather than the name. */
description?: string;
className?: string;
id?: string;
"aria-label"?: string;
/** Associates an external message (e.g. a form error) with the control. */
"aria-describedby"?: string;
}
export function Checkbox({
checked,
onCheckedChange,
disabled,
indeterminate,
label,
description,
className,
id: idProp,
"aria-label": ariaLabel,
"aria-describedby": ariaDescribedBy,
}: CheckboxProps) {
const autoId = useId();
const id = idProp ?? autoId;
const reduce = useReducedMotion();
const showMark = checked || indeterminate;
const path = indeterminate ? INDETERMINATE_PATH : CHECK_PATH;
const descriptionId = description ? `${id}-description` : undefined;
// The wrapping <label> would fold the description into the name, so name from the label text alone.
const labelId = description && label && !ariaLabel ? `${id}-label` : undefined;
const describedBy =
[ariaDescribedBy, descriptionId].filter(Boolean).join(" ") || undefined;
return (
<label
htmlFor={id}
className={cn(
"inline-flex gap-3",
description ? "items-start" : "items-center",
disabled ? "cursor-not-allowed" : "cursor-pointer",
className,
)}
>
<motion.button
id={id}
type="button"
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-label={ariaLabel}
aria-labelledby={labelId}
aria-describedby={describedBy}
disabled={disabled}
onClick={() => !disabled && onCheckedChange(!checked)}
whileTap={reduce || disabled ? undefined : { scale: 0.92 }}
transition={SPRING_PRESS}
data-state={
checked ? "checked" : indeterminate ? "indeterminate" : "unchecked"
}
className={cn(
"inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-md border-2 outline-none transition-colors duration-200",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-60",
showMark
? "border-primary bg-primary text-primary-foreground"
: "border-muted-foreground/50 bg-background hover:border-muted-foreground",
)}
>
<AnimatePresence initial={false}>
{showMark ? (
<motion.svg
key={indeterminate ? "indeterminate" : "checked"}
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth={3}
strokeLinecap="round"
strokeLinejoin="round"
initial={reduce ? { opacity: 1 } : { opacity: 0, scale: 0.5 }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1 }}
exit={
reduce
? { opacity: 0 }
: { opacity: 0, scale: 0.5, filter: "blur(4px)" }
}
transition={
reduce ? { duration: 0 } : { duration: 0.16, ease: EASE_OUT }
}
aria-hidden
>
<title>{indeterminate ? "Partially selected" : "Selected"}</title>
<motion.path
d={path}
initial={reduce ? { pathLength: 1 } : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={
reduce
? { duration: 0 }
: {
duration: indeterminate ? 0.2 : 0.3,
ease: EASE_OUT,
delay: 0.04,
}
}
/>
</motion.svg>
) : null}
</AnimatePresence>
</motion.button>
{label || description ? (
<span className={cn("flex min-w-0 flex-col gap-0.5 select-none", disabled && "opacity-60")}>
{label ? (
<span
id={labelId}
// Geist sits low in a 20px line; a 16px line lifts the text onto the control.
className={cn("text-sm text-foreground", description && "leading-4")}
>
{label}
</span>
) : null}
{description ? (
<span id={descriptionId} className="text-xs text-muted-foreground">
{description}
</span>
) : null}
</span>
) : null}
</label>
);
}
Related components
Radio Group
Single-select choice control with a gliding layoutId indicator dot and spring press feedback.
Input
Text input with label, left/right icons, optional stable error row, error shake and success check draw.
Select
Composable select primitives whose panel unfolds out of the trigger on one spring and pulls away into its own pill, plus a Morph variant where the trigger grows into the panel via shared layout.
Updated