yugodesign

Input·02.8

Animated Checkbox

The tick draws itself, the label strikes through.

Implement Checkbox
Write documentation
Add tests
animated-checkbox

Install

One dependency. The component is copied into your project, so the file is yours after that.

terminal
bun add motion

Or let the shadcn CLI do the copying: same file, landing in components/yugo.

terminal
bunx shadcn@latest add https://yugo.click/r/animated-checkbox.json

Usage

stats.tsx
"use client";

import { AnimatedCheckbox } from "@/components/yugo/animated-checkbox";

export function TaskList() {
  return (
    <div className="flex flex-col gap-2">
      <AnimatedCheckbox title="Task to complete" />
      <AnimatedCheckbox title="Already done" defaultChecked />
      <AnimatedCheckbox title="Ship it" onCheckedChange={(checked) => save(checked)} />
    </div>
  );
}

Source

components/yugo/animated-checkbox.tsx
"use client";

import { motion } from "motion/react";
import { useState } from "react";

interface AnimatedCheckboxProps {
  title?: string;
  defaultChecked?: boolean;
  className?: string;
  onCheckedChange?: (checked: boolean) => void;
}

const springTransition = {
  type: "spring" as const,
  duration: 0.4,
  bounce: 0.2,
};

export function AnimatedCheckbox({
  title = "Implement Checkbox",
  defaultChecked = false,
  className = "",
  onCheckedChange,
}: AnimatedCheckboxProps) {
  const [checked, setChecked] = useState(defaultChecked);

  const handleClick = () => {
    const newChecked = !checked;
    setChecked(newChecked);
    onCheckedChange?.(newChecked);
  };

  return (
    <div
      className={`flex cursor-pointer select-none items-center gap-3 ${className}`}
      onClick={handleClick}
    >
      <div
        className={`size-4.5 flex items-center justify-center rounded-[6px] border-[1.5px] transition-colors duration-200 ${
          checked
            ? "border-transparent bg-stone-900 dark:bg-stone-100"
            : "border-stone-400/40 bg-transparent hover:border-stone-400/60 dark:border-stone-500/40 dark:hover:border-stone-500/60"
        }`}
      >
        <svg
          viewBox="0 0 20 20"
          className="size-full text-white dark:text-stone-900"
        >
          <motion.path
            d="M 0 4.5 L 3.182 8 L 10 0"
            fill="transparent"
            stroke="currentColor"
            strokeWidth={1.5}
            strokeLinecap="round"
            strokeLinejoin="round"
            transform="translate(5 6)"
            initial={{
              pathLength: defaultChecked ? 1 : 0,
              opacity: defaultChecked ? 1 : 0,
            }}
            animate={{
              pathLength: checked ? 1 : 0,
              opacity: checked ? 1 : 0,
            }}
            transition={{
              pathLength: { ease: "easeOut", duration: 0.3 },
              opacity: { duration: 0 },
            }}
          />
        </svg>
      </div>
      <div className="relative">
        <span
          className={`text-base font-medium transition-colors duration-200 ${
            checked
              ? "text-stone-400 dark:text-stone-500"
              : "text-stone-800 dark:text-stone-100"
          }`}
        >
          {title}
        </span>
        <motion.div
          className="absolute left-0 top-1/2 h-[1.5px] -translate-y-1/2 bg-stone-400 dark:bg-stone-500"
          initial={{
            width: defaultChecked ? "100%" : 0,
            opacity: defaultChecked ? 1 : 0,
          }}
          animate={{
            width: checked ? "100%" : 0,
            opacity: checked ? 1 : 0,
          }}
          transition={springTransition}
        />
      </div>
    </div>
  );
}

Props

title"Implement Checkbox"
string

The label text for the checkbox.

defaultCheckedfalse
boolean

Initial checked state. It renders already ticked rather than animating on mount.

onCheckedChange
(checked: boolean) => void

Callback when checked state changes.

className""
string

Additional CSS classes, appended to the row.