Drawn checkbox and strike
Checking a task draws the tick stroke inside the box, then a line strikes through the label from left to right.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useState } from "react";
import styles from "./demo.module.css";
const TASKS = ["Confirm arrival time", "Send the check-in guide", "Book the cleaner", "Restock coffee and milk"];
export default function Demo() {
const [done, setDone] = useState([true, false, false, false]);
const count = done.filter(Boolean).length;
return (
<div className={styles.root}>
<div className={styles.card}>
<div className={styles.head}>
<p className={styles.title}>Before Friday</p>
<p className={styles.count}>{count} of {TASKS.length} done</p>
</div>
<ul className={styles.list}>
{TASKS.map((task, i) => (
<li key={task}>
<label className={styles.item}>
<input
type="checkbox"
className={styles.input}
checked={done[i]}
onChange={() => setDone(done.map((d, j) => (j === i ? !d : d)))}
/>
<span className={styles.box} aria-hidden="true">
<svg viewBox="0 0 16 16" width="14" height="14">
<path d="M3.5 8.5 6.5 11.5 12.5 4.5" pathLength="1" />
</svg>
</span>
<span className={styles.text}>{task}</span>
</label>
</li>
))}
</ul>
</div>
</div>
);
}
demo.module.css
.root { width: 100%; height: 100%; display: grid; place-items: center; padding: 16px; }
.card { width: min(100%, 360px); padding: 14px 18px 10px; background: var(--white); border: 1px solid var(--line); border-radius: 16px; }
.head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; margin-bottom: 4px; }
.title { font-size: 15px; font-weight: 650; }
.count { font-size: 13px; color: var(--muted); font-variant-numeric: tabular-nums; }
.list { list-style: none; margin: 0; padding: 0; }
.item { position: relative; display: flex; align-items: center; gap: 12px; min-height: 40px; cursor: pointer; -webkit-tap-highlight-color: transparent; }
.input { position: absolute; left: 0; width: 20px; height: 20px; margin: 0; opacity: 0; cursor: pointer; }
.box {
flex: none; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 6px;
border: 1.5px solid var(--muted); transition: background-color .2s, border-color .2s, scale .2s var(--out);
}
.box path { fill: none; stroke: var(--white); stroke-width: 2.4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 1; stroke-dashoffset: 1; transition: stroke-dashoffset .3s var(--out); }
.item:hover .box { border-color: var(--ink); }
.item:active .box { scale: .9; }
.input:checked + .box { background: var(--accent-text); border-color: var(--accent-text); }
.input:checked + .box path { stroke-dashoffset: 0; transition-delay: .06s; }
.input:focus-visible + .box { outline: 2px solid var(--ink); outline-offset: 2px; }
.text { position: relative; font-size: 15px; transition: color .3s; }
.text::after {
content: ""; position: absolute; left: -2px; right: -2px; top: 52%; height: 1.5px; border-radius: 1px;
background: currentColor; transform: scaleX(0); transform-origin: left; transition: transform .35s var(--out);
}
.input:checked ~ .text { color: var(--muted); }
.input:checked ~ .text::after { transform: scaleX(1); transition-delay: .14s; }
@media (prefers-reduced-motion: reduce) {
.box path, .text::after { transition: none; }
}
Use it for
- Task lists, onboarding checklists and packing lists
- Any list where finishing an item should feel rewarding
Avoid
- Form consent checkboxes, where strike-through implies something was removed
- Bulk selection in tables, where speed matters more than delight
How it works
Each row is a native checkbox inside a label, visually hidden over a custom box. The tick path uses pathLength 1 with stroke-dasharray 1 so stroke-dashoffset can go from 1 to 0 on :checked. The label has a pseudo element line that scales from 0 to 1 on the x axis with a left origin, delayed slightly so it follows the tick.
Accessibility
Native inputs keep keyboard, Space toggling and screen reader states for free, and the whole row is the click target. Focus shows an outline on the custom box. Reduced motion shows the tick and strike immediately.