01Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in order
The instant the checked value turns on, the checkmark's line-length value draws from zero to full, and reverses when it turns off.
Best forA terms-agreement item, a completion mark on a to-do list
DRAW_MSstroke-dashoffsetgetTotalLength

<main class="stage">
<label class="cbd is-demo" id="cbd">
<input type="checkbox" class="cbd__input" id="cbdInput">
<span class="cbd__box" id="cbdBox" aria-hidden="true">
<svg class="cbd__svg" viewBox="0 0 20 20" width="14" height="14">
<path id="cbdCheck" class="cbd__path" d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</span>
<span class="cbd__text">I agree to the terms</span>
</label>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html 이 SVG path.getTotalLength() 로 체크 표시의 실제 선 길이를 측정해
// stroke-dasharray 를 만들고, easeOutCubic 을 rAF 로 직접 적분해 stroke-dashoffset 을
// 0(그려짐)과 길이값(안 그려짐) 사이로 보간한다 — CSS transition 이 아니라 계산이 선을 그린다.
@include stage($stage-ink);
.cbd {
display: flex;
align-items: center;
gap: $sp-3;
cursor: pointer;
user-select: none;
}
.cbd__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbd__box {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: $r-sm;
background: transparent;
border: 2px solid rgba($subject-cream, .5);
color: transparent;
display: grid;
place-items: center;
box-sizing: border-box;
transition: background-color $dur-quick $ease-out, border-color $dur-quick $ease-out, color $dur-quick $ease-out;
}
.cbd__box--on {
background: $subject-blue;
border-color: $subject-blue;
color: $subject-cream;
}
.cbd__input:focus-visible + .cbd__box {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.cbd__text {
color: $subject-cream;
font-size: 14px;
font-weight: 600;
}
@media (max-width: 340px) {
.cbd__text { font-size: 13px; }
}
@media (prefers-reduced-motion: reduce) {
.cbd__box { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cbd {
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
user-select: none;
}
.cbd__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbd__box {
flex: 0 0 auto;
width: 26px;
height: 26px;
border-radius: 8px;
background: transparent;
border: 2px solid rgba(255, 247, 230, 0.5);
color: transparent;
display: grid;
place-items: center;
box-sizing: border-box;
transition: background-color 220ms ease-out, border-color 220ms ease-out, color 220ms ease-out;
}
.cbd__box--on {
background: #2f6df6;
border-color: #2f6df6;
color: #fff7e6;
}
.cbd__input:focus-visible + .cbd__box {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cbd__text {
color: #fff7e6;
font-size: 14px;
font-weight: 600;
}
@media (max-width: 340px) {
.cbd__text {
font-size: 13px;
}
}
@media (prefers-reduced-motion: reduce) {
.cbd__box {
transition: none;
}
}(function () {
var DRAW_MS = 460; // 계산 겹 상수: 체크 선이 0에서 끝까지 그려지는 데 걸리는 실제 시간(ms)
var CYCLE_MS = 640; // 자동 루프가 체크·해제를 오가는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('cbd');
var input = document.getElementById('cbdInput');
var box = document.getElementById('cbdBox');
var path = document.getElementById('cbdCheck');
// 계산 겹: SVG path 의 실제 길이를 getTotalLength() 로 측정해 stroke-dasharray 를 만든다 — 짐작 px 를 쓰지 않는다
var LEN = path.getTotalLength();
path.style.strokeDasharray = String(LEN);
var isDemo = true;
var drawing = false, drawStart = 0, drawFrom = LEN, drawTo = LEN;
path.style.setProperty('stroke-dashoffset', String(LEN));
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function currentOffset(ts) {
if (!drawing) return drawTo;
var t = Math.min(1, (ts - drawStart) / DRAW_MS);
return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
}
function setChecked(next, ts) {
input.checked = next;
box.classList.toggle('cbd__box--on', next);
drawFrom = currentOffset(ts);
drawTo = next ? 0 : LEN;
drawStart = ts;
drawing = true;
}
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
input.addEventListener('change', function () {
human();
setChecked(input.checked, performance.now());
});
var cycleStart = 0;
var lastState = false;
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
var step = Math.floor((ts - cycleStart) / CYCLE_MS) % 2 === 1;
if (step !== lastState) { lastState = step; setChecked(step, ts); }
}
if (drawing) {
var off = currentOffset(ts);
path.style.setProperty('stroke-dashoffset', off.toFixed(2));
if (Math.abs(off - drawTo) < 0.05) { path.style.setProperty('stroke-dashoffset', String(drawTo)); drawing = false; }
}
requestAnimationFrame(loop);
}
if (reduce) {
// 계산 자체는 유지하되 중간 프레임 없이 목표 값으로 바로 놓는다
DRAW_MS = 1;
}
requestAnimationFrame(loop);
})();// CheckDraw — vanilla 판(run/368/demo/01-custom-checkbox-check-draw/index.html) 의 React 포트.
// path.getTotalLength() 로 실측한 체크 선 길이를 easeOutCubic 로 rAF 적분해 strokeDashoffset 을
// 그린다 — CSS transition 이 아니라 계산으로 선을 그린다.
import { useEffect, useRef } from "react";
import styles from "./CheckDraw.module.scss";
type Box<T> = { current: T };
export default function CheckDraw() {
const rootRef = useRef(null) as unknown as Box<HTMLLabelElement>;
const inputRef = useRef(null) as unknown as Box<HTMLInputElement>;
const boxRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const pathRef = useRef(null) as unknown as Box<SVGPathElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
let DRAW_MS = 460; // 계산 겹 상수: 체크 선이 0에서 끝까지 그려지는 데 걸리는 실제 시간(ms)
const CYCLE_MS = 640; // 자동 루프가 체크·해제를 오가는 간격
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const input = inputRef.current;
const path = pathRef.current;
if (!input || !path) return;
let isDemo = true;
const LEN = path.getTotalLength();
path.style.strokeDasharray = String(LEN);
path.style.setProperty("stroke-dashoffset", String(LEN));
let drawing = false, drawStart = 0, drawFrom = LEN, drawTo = LEN;
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function currentOffset(ts: number) {
if (!drawing) return drawTo;
const t = Math.min(1, (ts - drawStart) / DRAW_MS);
return drawFrom + (drawTo - drawFrom) * easeOutCubic(t);
}
function human() {
if (isDemo) { rootRef.current?.classList.remove(styles.isDemo); isDemo = false; }
}
function setChecked(next: boolean, ts: number) {
input!.checked = next;
boxRef.current?.classList.toggle(styles.boxOn, next);
drawFrom = currentOffset(ts);
drawTo = next ? 0 : LEN;
drawStart = ts;
drawing = true;
}
function onChange() { human(); setChecked(input!.checked, performance.now()); }
input.addEventListener("change", onChange);
let cycleStart = 0;
let lastState = false;
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
const step = Math.floor((ts - cycleStart) / CYCLE_MS) % 2 === 1;
if (step !== lastState) { lastState = step; setChecked(step, ts); }
}
if (drawing) {
const off = currentOffset(ts);
path!.style.setProperty("stroke-dashoffset", String(off));
if (Math.abs(off - drawTo) < 0.05) drawing = false;
}
rafRef.current = requestAnimationFrame(loop);
}
if (reduce) { DRAW_MS = 1; }
rafRef.current = requestAnimationFrame(loop);
return () => {
input.removeEventListener("change", onChange);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<label className={styles.cbd + " " + styles.isDemo} ref={rootRef}>
<input type="checkbox" className={styles.cbdInput} ref={inputRef} />
<span className={styles.cbdBox} ref={boxRef} aria-hidden="true">
<svg className={styles.cbdSvg} viewBox="0 0 20 20" width={14} height={14}>
<path ref={pathRef} className={styles.cbdPath} d="M4 10.5l3.5 3.5L16 5.5" fill="none" stroke="currentColor" strokeWidth={2.6} strokeLinecap="round" strokeLinejoin="round" />
</svg>
</span>
<span className={styles.cbdText}>약관에 동의합니다</span>
</label>
</main>
);
}01 Check-draw toggle — when the checked value turns on, the checkmark's line-length value draws from zero to full in order — Measured area 0.442% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · CSS Custom Checkbox: 9 Interaction Patterns
The post this demo lives in
CSS Custom Checkbox: 9 Interaction PatternsCSS Custom Checkbox: One of nine draws its checkmark length to full on check, one bounces the box size then settles on t See all demos in this post
If you like this effect - similar demos
Inputs03 Security-code focus value toggle — when the security-code field's focus value turns on, the view switches to a value showing the card's backCredit Card Input UI: 9 Patterns
Inputs03 Entropy-bit value display — the strength value comes from a calculation multiplying the character-set size value by the length valuePassword Strength Meter UI: 9 Calculation Patterns
Inputs03 Discount line that wedges into the order summaryCheckout Page Design — 9 Cart Patterns, No Library