Forms
CheckboxBloom
A real checkbox that fills from the centre, draws its tick and throws a ring of petals when ticked.
Download
- React
- Tailwind CSS v4
- motion
components/ui/checkbox-bloom.tsx
"use client";
import { useState, type CSSProperties, type InputHTMLAttributes } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
type CheckboxBloomProps = Omit<InputHTMLAttributes<HTMLInputElement>, "type" | "checked" | "defaultChecked" | "onChange" | "className"> & {
/** Text next to the box. */
label: string;
/** Small text under the label. */
hint?: string;
/** Ticked or not (controlled). */
checked?: boolean;
/** Ticked or not at start (uncontrolled). */
defaultChecked?: boolean;
/** Called with the new state. */
onChange?: (checked: boolean) => void;
/** Box and petal colour. */
accent?: string;
/** How many petals burst out. */
petals?: number;
/** Wrapper look (width etc.). */
className?: string;
};
/* CheckboxBloom: a real checkbox that fills from the centre, draws its tick and throws a ring of petals when ticked. */
export function CheckboxBloom({
label,
hint,
checked,
defaultChecked = false,
onChange,
accent = "#a78bfa",
petals = 8,
className = "w-full max-w-xs",
...rest
}: CheckboxBloomProps) {
const reduce = useReducedMotion();
const [inner, setInner] = useState(defaultChecked);
const [bursts, setBursts] = useState(0);
const on = checked ?? inner;
return (
<label
style={{ "--cb": accent } as CSSProperties}
className={`flex cursor-pointer select-none items-start gap-3 has-disabled:cursor-not-allowed has-disabled:opacity-50 ${className}`}
>
<span className="relative mt-px size-6 shrink-0">
<input
type="checkbox"
checked={on}
onChange={(e) => {
if (checked === undefined) setInner(e.target.checked);
if (e.target.checked) setBursts((n) => n + 1);
onChange?.(e.target.checked);
}}
className="peer sr-only"
{...rest}
/>
<span className="absolute inset-0 rounded-lg border border-white/25 transition-colors duration-150 peer-checked:border-[var(--cb)] peer-focus-visible:ring-2 peer-focus-visible:ring-[var(--cb)] peer-focus-visible:ring-offset-2 peer-focus-visible:ring-offset-[#0b0a14] motion-reduce:transition-none" />
<AnimatePresence initial={false}>
{on && (
<motion.span
aria-hidden
className="absolute inset-0 rounded-lg bg-[var(--cb)]"
initial={reduce ? false : { scale: 0.2, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { scale: 0.4, opacity: 0, transition: { duration: 0.14 } }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 520, damping: 22 }}
>
<svg viewBox="0 0 24 24" fill="none" stroke="#0b0a14" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" className="size-full p-1">
<motion.path
d="m5 12.5 4.5 4.5L19 7.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduce ? 0 : 0.24, delay: reduce ? 0 : 0.06, ease: "easeOut" }}
/>
</svg>
</motion.span>
)}
</AnimatePresence>
{!reduce && bursts > 0 && (
<span key={bursts} aria-hidden className="pointer-events-none absolute inset-0">
{Array.from({ length: petals }, (_, i) => {
const angle = (i / petals) * Math.PI * 2;
return (
<motion.span
key={i}
className="absolute top-1/2 left-1/2 -mt-1 -ml-[3px] h-2 w-1.5 rounded-full bg-[var(--cb)]"
initial={{ x: 0, y: 0, opacity: 1, scale: 1, rotate: (angle * 180) / Math.PI + 90 }}
animate={{ x: Math.cos(angle) * 22, y: Math.sin(angle) * 22, opacity: 0, scale: 0.3 }}
transition={{ duration: 0.5, ease: [0.16, 0.8, 0.3, 1] }}
/>
);
})}
</span>
)}
</span>
<span className="min-w-0">
<span className="block text-[15px] font-medium leading-[26px] text-white">{label}</span>
{hint && <span className="block text-[13px] text-white/50">{hint}</span>}
</span>
</label>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/checkbox-bloom.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/checkbox-bloom-demo.tsx
"use client"; import { useState } from "react"; import { CheckboxBloom } from "@/components/ui/checkbox-bloom"; export default function CheckboxBloomExample() { const [terms, setTerms] = useState(false); return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <div className="grid w-full max-w-xs gap-5"> <CheckboxBloom label="Accept the terms" hint={terms ? "Thanks!" : "Needed to carry on"} checked={terms} onChange={setTerms} name="terms" /> <CheckboxBloom label="Send me product news" defaultChecked accent="#f472b6" /> <CheckboxBloom label="Priority support" hint="Pro plan only" disabled /> </div> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| label | string | — | Text next to the box. |
| hint | string | — | Small text under the label. |
| checked | boolean | — | Ticked or not (controlled). |
| defaultChecked | boolean | false | Ticked or not at start (uncontrolled). |
| onChange | (checked: boolean) => void | — | Called with the new state. |
| accent | string | "#a78bfa" | Box and petal colour. |
| petals | number | 8 | How many petals burst out. |
| className | string | "w-full max-w-xs" | Wrapper look. |
| ...rest | input props | — | name, disabled, required and so on go to the input. |