Checkbox
Checkbox on a real checkbox input, with a check mark that pops in rather than just appearing.
"use client";
import { Checkbox } from "@/components/motion/checkbox";
const TASKS = [
{ id: "wireframes", title: "Review wireframes", done: true },
{ id: "handoff", title: "Send design handoff", done: true },
{ id: "qa", title: "QA the checkout flow", done: false },
];
export function CheckboxPreview() {
return (
<div className="flex w-full max-w-xs flex-col rounded-2xl bg-background p-1.5 shadow-[0_0_0_1px_var(--border)]">
{TASKS.map((task) => (
<label
key={task.id}
htmlFor={`task-${task.id}`}
className="flex cursor-pointer items-center gap-3 rounded-xl px-3 py-2.5 transition-colors duration-150 hover:bg-muted"
>
<Checkbox id={`task-${task.id}`} defaultChecked={task.done} />
<span className="text-sm font-medium text-foreground">{task.title}</span>
</label>
))}
</div>
);
}
"use client";
// easeui.dev/components/motion/checkbox
import { Check } from "lucide-react";
import { forwardRef, type InputHTMLAttributes, useState } from "react";
import { cn } from "@/lib/utils";
export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> {
/** Controlled checked state. */
checked?: boolean;
/** Starting state when uncontrolled. Default false. */
defaultChecked?: boolean;
/** Called with the next state each time the checkbox is toggled. */
onCheckedChange?: (checked: boolean) => void;
}
/**
* A checkbox on a real checkbox input, with a check mark that pops in rather
* than just appearing. Put it inside a label and the whole row becomes the
* tap target, with no dead space between the text and the box.
*/
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox(
{ checked, defaultChecked = false, onCheckedChange, className, onChange, ...props },
ref,
) {
const [uncontrolled, setUncontrolled] = useState(defaultChecked);
const isControlled = checked !== undefined;
const on = isControlled ? checked : uncontrolled;
return (
<span className={cn("relative inline-flex h-5 w-5 shrink-0 items-center justify-center", className)}>
<input
ref={ref}
type="checkbox"
checked={on}
onChange={(event) => {
onChange?.(event);
if (!isControlled) setUncontrolled(event.target.checked);
onCheckedChange?.(event.target.checked);
}}
className={cn(
"peer absolute inset-0 m-0 h-5 w-5 shrink-0 touch-manipulation cursor-pointer appearance-none rounded-md outline-none",
"shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150 ease-out",
"checked:bg-accent checked:shadow-[0_0_0_1px_var(--accent)]",
"focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background",
"disabled:cursor-not-allowed disabled:opacity-50",
// Grows the hit area to about 44px without changing the layout.
"after:absolute after:-inset-3",
)}
{...props}
/>
<Check
aria-hidden="true"
strokeWidth={3}
className="pointer-events-none relative h-3.5 w-3.5 scale-50 text-accent-foreground opacity-0 transition-[opacity,scale] duration-150 ease-out peer-checked:scale-100 peer-checked:opacity-100 motion-reduce:transition-none"
/>
</span>
);
});
Installation
$ bunx --bun shadcn add @easeui/checkbox
- 1
Set up the theme tokens
Do this once per project. Follow the theme setup or skip it if you already ran shadcn init.
- 2
Install the dependencies
terminal npm install clsx lucide-react tailwind-merge - 3
Add the source files
components/motion/checkbox.tsx "use client"; // easeui.dev/components/motion/checkbox import { Check } from "lucide-react"; import { forwardRef, type InputHTMLAttributes, useState } from "react"; import { cn } from "@/lib/utils"; export interface CheckboxProps extends Omit<InputHTMLAttributes<HTMLInputElement>, "type"> { /** Controlled checked state. */ checked?: boolean; /** Starting state when uncontrolled. Default false. */ defaultChecked?: boolean; /** Called with the next state each time the checkbox is toggled. */ onCheckedChange?: (checked: boolean) => void; } /** * A checkbox on a real checkbox input, with a check mark that pops in rather * than just appearing. Put it inside a label and the whole row becomes the * tap target, with no dead space between the text and the box. */ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(function Checkbox( { checked, defaultChecked = false, onCheckedChange, className, onChange, ...props }, ref, ) { const [uncontrolled, setUncontrolled] = useState(defaultChecked); const isControlled = checked !== undefined; const on = isControlled ? checked : uncontrolled; return ( <span className={cn("relative inline-flex h-5 w-5 shrink-0 items-center justify-center", className)}> <input ref={ref} type="checkbox" checked={on} onChange={(event) => { onChange?.(event); if (!isControlled) setUncontrolled(event.target.checked); onCheckedChange?.(event.target.checked); }} className={cn( "peer absolute inset-0 m-0 h-5 w-5 shrink-0 touch-manipulation cursor-pointer appearance-none rounded-md outline-none", "shadow-[0_0_0_1px_var(--border-strong)] transition-colors duration-150 ease-out", "checked:bg-accent checked:shadow-[0_0_0_1px_var(--accent)]", "focus-visible:ring-2 focus-visible:ring-foreground/40 focus-visible:ring-offset-2 focus-visible:ring-offset-background", "disabled:cursor-not-allowed disabled:opacity-50", // Grows the hit area to about 44px without changing the layout. "after:absolute after:-inset-3", )} {...props} /> <Check aria-hidden="true" strokeWidth={3} className="pointer-events-none relative h-3.5 w-3.5 scale-50 text-accent-foreground opacity-0 transition-[opacity,scale] duration-150 ease-out peer-checked:scale-100 peer-checked:opacity-100 motion-reduce:transition-none" /> </span> ); });lib/utils.ts import { clsx, type ClassValue } from "clsx" import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) }
API reference
Updated