Components
Checkbox
Checkbox
Ultra-modern checkbox component with custom animations, multiple variants, and enhanced user interaction for form controls.
Basic States
Basic Checkbox
Status: Unchecked
With Description
Error State
Disabled State
Variants
Default Variant
Filled Variant
Outlined Variant
Sizes
Indeterminate State
Interactive Form Demo
Account Preferences
Keyboard Navigation
Try these keyboard shortcuts:
- Tab: Move focus to checkbox
- Space/Enter: Toggle checkbox
- Arrow Keys: Navigate between options
Installation
1
Install the packages
npm i motion clsx tailwind-merge lucide-react2
Add util file
lib/util.ts
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
import { ClassValue, clsx } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
3
Copy and paste the following code into your project
checkbox.tsx
"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { Check, Minus, AlertCircle } from "lucide-react";
interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
error?: string;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
indeterminate?: boolean;
className?: string;
}
const Checkbox: React.FC<CheckboxProps> = ({
checked = false,
onChange,
label,
description,
disabled = false,
required = false,
error,
size = "md",
variant = "default",
indeterminate = false,
className,
}) => {
const [isFocused, setIsFocused] = useState(false);
const checkboxId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const iconSizeClasses = {
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
const variantClasses = {
default: "border-gray-300 bg-white hover:border-gray-400 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-gray-500",
filled: "border-transparent bg-gray-100 hover:bg-gray-200 focus:bg-white focus:border-blue-500 dark:bg-gray-700 dark:hover:bg-gray-600",
outlined: "border-2 border-gray-300 bg-transparent hover:border-gray-400 focus:border-blue-500 dark:border-gray-600",
};
const checkedClasses = {
default: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
filled: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
outlined: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
};
const handleClick = () => {
if (!disabled) {
onChange?.(!checked);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div className={cn("relative", className)}>
<div className="flex items-start space-x-3">
{/* Checkbox Input */}
<div className="relative flex-shrink-0">
<motion.input
id={checkboxId}
type="checkbox"
checked={checked}
onChange={() => {}} // Handled by click
disabled={disabled}
className="sr-only"
/>
{/* Custom Checkbox */}
<motion.div
className={cn(
"relative rounded-md border-2 cursor-pointer transition-all duration-200 flex items-center justify-center",
sizeClasses[size],
checked ? checkedClasses[variant] : variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed",
hasError && !checked && "border-red-500",
isFocused && "ring-2 ring-blue-500/20"
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-labelledby={label ? checkboxId : undefined}
animate={{
scale: isFocused ? 1.05 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
{/* Background Animation */}
<AnimatePresence>
{(checked || indeterminate) && (
<motion.div
className="absolute inset-0 bg-current rounded-md"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
{/* Check Icon */}
<AnimatePresence mode="wait">
{checked && !indeterminate && (
<motion.div
key="check"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<Check className={cn("text-white", iconSizeClasses[size])} />
</motion.div>
)}
{indeterminate && (
<motion.div
key="minus"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<Minus className={cn("text-white", iconSizeClasses[size])} />
</motion.div>
)}
</AnimatePresence>
{/* Ripple Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 bg-white/30 rounded-md"
initial={{ scale: 0, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.4 }}
/>
)}
</AnimatePresence>
</motion.div>
{/* Focus Ring */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-md border-2 border-blue-500"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Label and Description */}
<div className="flex-1 min-w-0">
{label && (
<motion.label
htmlFor={checkboxId}
className={cn(
"block font-medium cursor-pointer transition-colors duration-200",
textSizeClasses[size],
hasError ? "text-red-600 dark:text-red-400" :
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-200"
)}
onClick={handleClick}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{description && (
<motion.p
className={cn(
"mt-1 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};
export default Checkbox;"use client";
import { motion, AnimatePresence } from "motion/react";
import React, { useState, useId } from "react";
import { cn } from "@/lib/utils";
import { Check, Minus, AlertCircle } from "lucide-react";
interface CheckboxProps {
checked?: boolean;
onChange?: (checked: boolean) => void;
label?: string;
description?: string;
disabled?: boolean;
required?: boolean;
error?: string;
size?: "sm" | "md" | "lg";
variant?: "default" | "filled" | "outlined";
indeterminate?: boolean;
className?: string;
}
const Checkbox: React.FC<CheckboxProps> = ({
checked = false,
onChange,
label,
description,
disabled = false,
required = false,
error,
size = "md",
variant = "default",
indeterminate = false,
className,
}) => {
const [isFocused, setIsFocused] = useState(false);
const checkboxId = useId();
const hasError = !!error;
const sizeClasses = {
sm: "w-4 h-4",
md: "w-5 h-5",
lg: "w-6 h-6",
};
const iconSizeClasses = {
sm: "w-3 h-3",
md: "w-4 h-4",
lg: "w-5 h-5",
};
const textSizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
const variantClasses = {
default: "border-gray-300 bg-white hover:border-gray-400 focus:border-blue-500 dark:border-gray-600 dark:bg-gray-800 dark:hover:border-gray-500",
filled: "border-transparent bg-gray-100 hover:bg-gray-200 focus:bg-white focus:border-blue-500 dark:bg-gray-700 dark:hover:bg-gray-600",
outlined: "border-2 border-gray-300 bg-transparent hover:border-gray-400 focus:border-blue-500 dark:border-gray-600",
};
const checkedClasses = {
default: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
filled: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
outlined: "bg-blue-500 border-blue-500 text-white dark:bg-blue-600 dark:border-blue-600",
};
const handleClick = () => {
if (!disabled) {
onChange?.(!checked);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
handleClick();
}
};
return (
<div className={cn("relative", className)}>
<div className="flex items-start space-x-3">
{/* Checkbox Input */}
<div className="relative flex-shrink-0">
<motion.input
id={checkboxId}
type="checkbox"
checked={checked}
onChange={() => {}} // Handled by click
disabled={disabled}
className="sr-only"
/>
{/* Custom Checkbox */}
<motion.div
className={cn(
"relative rounded-md border-2 cursor-pointer transition-all duration-200 flex items-center justify-center",
sizeClasses[size],
checked ? checkedClasses[variant] : variantClasses[variant],
disabled && "opacity-50 cursor-not-allowed",
hasError && !checked && "border-red-500",
isFocused && "ring-2 ring-blue-500/20"
)}
onClick={handleClick}
onKeyDown={handleKeyDown}
tabIndex={disabled ? -1 : 0}
role="checkbox"
aria-checked={indeterminate ? "mixed" : checked}
aria-labelledby={label ? checkboxId : undefined}
animate={{
scale: isFocused ? 1.05 : 1,
boxShadow: isFocused
? "0 0 0 3px rgba(59, 130, 246, 0.1)"
: "0 0 0 0px rgba(0, 0, 0, 0)",
}}
transition={{ duration: 0.2 }}
whileHover={!disabled ? { scale: 1.05 } : {}}
whileTap={!disabled ? { scale: 0.95 } : {}}
>
{/* Background Animation */}
<AnimatePresence>
{(checked || indeterminate) && (
<motion.div
className="absolute inset-0 bg-current rounded-md"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
{/* Check Icon */}
<AnimatePresence mode="wait">
{checked && !indeterminate && (
<motion.div
key="check"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<Check className={cn("text-white", iconSizeClasses[size])} />
</motion.div>
)}
{indeterminate && (
<motion.div
key="minus"
initial={{ scale: 0, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.2, delay: 0.1 }}
>
<Minus className={cn("text-white", iconSizeClasses[size])} />
</motion.div>
)}
</AnimatePresence>
{/* Ripple Effect */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 bg-white/30 rounded-md"
initial={{ scale: 0, opacity: 1 }}
animate={{ scale: 2, opacity: 0 }}
exit={{ scale: 0, opacity: 0 }}
transition={{ duration: 0.4 }}
/>
)}
</AnimatePresence>
</motion.div>
{/* Focus Ring */}
<AnimatePresence>
{isFocused && (
<motion.div
className="absolute inset-0 rounded-md border-2 border-blue-500"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
/>
)}
</AnimatePresence>
</div>
{/* Label and Description */}
<div className="flex-1 min-w-0">
{label && (
<motion.label
htmlFor={checkboxId}
className={cn(
"block font-medium cursor-pointer transition-colors duration-200",
textSizeClasses[size],
hasError ? "text-red-600 dark:text-red-400" :
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-200"
)}
onClick={handleClick}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2 }}
>
{label}
{required && <span className="text-red-500 ml-1">*</span>}
</motion.label>
)}
{description && (
<motion.p
className={cn(
"mt-1 text-sm transition-colors duration-200",
disabled ? "text-gray-400 dark:text-gray-500" :
"text-gray-600 dark:text-gray-400"
)}
animate={{
x: isFocused ? 2 : 0,
}}
transition={{ duration: 0.2, delay: 0.05 }}
>
{description}
</motion.p>
)}
{/* Error Message */}
<AnimatePresence>
{hasError && (
<motion.div
initial={{ opacity: 0, y: -10, height: 0 }}
animate={{ opacity: 1, y: 0, height: "auto" }}
exit={{ opacity: 0, y: -10, height: 0 }}
transition={{ duration: 0.2 }}
className="mt-2 text-sm text-red-600 dark:text-red-400 flex items-center gap-1"
>
<AlertCircle className="w-4 h-4 flex-shrink-0" />
{error}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
);
};
export default Checkbox;4
Update the import paths to match your project setup
Props
| Prop | Type | Default | Description |
|---|---|---|---|
| checked | boolean | false | Whether the checkbox is checked. |
| onChange | (checked: boolean) => void | () => {} | Callback function when checkbox state changes. |
| label | string | undefined | Label text for the checkbox. |
| description | string | undefined | Description text below the label. |
| disabled | boolean | false | Disable the checkbox interaction. |
| required | boolean | false | Mark checkbox as required. |
| error | string | undefined | Error message to display. |
| size | 'sm' | 'md' | 'lg' | 'md' | Size of the checkbox. |
| variant | 'default' | 'filled' | 'outlined' | 'default' | Visual style variant. |
| indeterminate | boolean | false | Show indeterminate state (dash instead of check). |