04선택 개수 롤 전환 — 선택된 개수 값이 오르내릴 때마다 배지 숫자의 자리 값이 위아래로 굴러가듯 바뀌는 방식
선택 개수 값이 하나 늘거나 줄 때마다 배지 숫자의 세로 위치 값이 다음 숫자 쪽으로 굴러가듯 바뀝니다.
쓰임여러 항목을 골라 담는 목록, 선택 수를 실시간으로 보여주는 필터
ROLL_MStranslateYclip-path

<main class="stage">
<div class="cbc is-demo" id="cbc">
<div class="cbc__list">
<label class="cbc__row">
<input type="checkbox" class="cbc__input" id="cbcItem0">
<span class="cbc__box" id="cbcBox0" aria-hidden="true">
<svg viewBox="0 0 20 20" width="12" height="12"><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="cbc__text">기본 배송</span>
</label>
<label class="cbc__row">
<input type="checkbox" class="cbc__input" id="cbcItem1">
<span class="cbc__box" id="cbcBox1" aria-hidden="true">
<svg viewBox="0 0 20 20" width="12" height="12"><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="cbc__text">선물 포장</span>
</label>
<label class="cbc__row">
<input type="checkbox" class="cbc__input" id="cbcItem2">
<span class="cbc__box" id="cbcBox2" aria-hidden="true">
<svg viewBox="0 0 20 20" width="12" height="12"><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="cbc__text">설치 서비스</span>
</label>
</div>
<span class="cbc__badge">
<span class="cbc__window">
<span class="cbc__strip" id="cbcStrip">
<span>0</span><span>1</span><span>2</span><span>3</span>
</span>
</span>
</span>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹: index.html paint() 가 체크된 개수 → 목표 translateY 값을 만들고, easeOutCubic 을
// rAF 로 직접 적분해 숫자 strip 을 다음 칸으로 굴린다 — clip-path 가 그 칸 하나만 보이는
// 창을 만든다(overflow:hidden 이 아니라 계산된 사각 경계로 자른다).
@include stage($stage-paper);
.cbc {
display: flex;
align-items: center;
gap: $sp-6;
width: 100%;
max-width: 300px;
}
.cbc__list {
display: grid;
gap: $sp-2;
flex: 1 1 auto;
}
.cbc__row {
display: flex;
align-items: center;
gap: $sp-2;
cursor: pointer;
user-select: none;
}
.cbc__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbc__box {
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: $r-xs;
border: 2px solid rgba($subject-ink, .32);
background: transparent;
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;
}
.cbc__box--on {
background: $subject-blue;
border-color: $subject-blue;
color: $subject-cream;
}
.cbc__input:focus-visible + .cbc__box {
outline: 2px solid $subject-blue-light;
outline-offset: 2px;
}
.cbc__text {
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
.cbc__badge {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: $r-pill;
background: $subject-blue-deep;
display: grid;
place-items: center;
box-shadow: $shadow-raised;
}
.cbc__window {
display: block;
width: 14px;
height: 18px;
overflow: hidden;
clip-path: inset(0 0 0 0 round $r-xs);
}
.cbc__strip {
display: flex;
flex-direction: column;
align-items: center;
will-change: transform;
span {
height: 18px;
line-height: 18px;
color: $subject-cream;
font-weight: 800;
font-size: 13px;
}
}
@media (max-width: 340px) {
.cbc { gap: $sp-4; }
.cbc__text { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.cbc__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: #ffffff;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.cbc {
display: flex;
align-items: center;
gap: 24px;
width: 100%;
max-width: 300px;
}
.cbc__list {
display: grid;
gap: 8px;
flex: 1 1 auto;
}
.cbc__row {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
user-select: none;
}
.cbc__input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
}
.cbc__box {
flex: 0 0 auto;
width: 18px;
height: 18px;
border-radius: 4px;
border: 2px solid rgba(23, 20, 26, 0.32);
background: transparent;
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;
}
.cbc__box--on {
background: #2f6df6;
border-color: #2f6df6;
color: #fff7e6;
}
.cbc__input:focus-visible + .cbc__box {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.cbc__text {
color: #17141a;
font-size: 12px;
font-weight: 600;
}
.cbc__badge {
flex: 0 0 auto;
width: 36px;
height: 36px;
border-radius: 999px;
background: #0a4fe8;
display: grid;
place-items: center;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
}
.cbc__window {
display: block;
width: 14px;
height: 18px;
overflow: hidden;
clip-path: inset(0 0 0 0 round 4px);
}
.cbc__strip {
display: flex;
flex-direction: column;
align-items: center;
will-change: transform;
}
.cbc__strip span {
height: 18px;
line-height: 18px;
color: #fff7e6;
font-weight: 800;
font-size: 13px;
}
@media (max-width: 340px) {
.cbc {
gap: 16px;
}
.cbc__text {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.cbc__box {
transition: none;
}
}(function () {
var ROW_H = 18; // px, 숫자 롤 한 칸 높이
var ROLL_MS = 260; // 계산 겹 상수: 숫자가 다음 칸으로 굴러가는 데 걸리는 실제 시간(ms)
var CYCLE_MS = 560; // 자동 루프가 항목을 하나씩 체크하는 간격
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var root = document.getElementById('cbc');
var strip = document.getElementById('cbcStrip');
var inputs = [document.getElementById('cbcItem0'), document.getElementById('cbcItem1'), document.getElementById('cbcItem2')];
var boxes = [document.getElementById('cbcBox0'), document.getElementById('cbcBox1'), document.getElementById('cbcBox2')];
var isDemo = true;
var pos = 0, from = 0, to = 0, rollStart = 0, rolling = false;
function human() {
if (isDemo) { root.classList.remove('is-demo'); isDemo = false; }
}
function easeOutCubic(t) { return 1 - Math.pow(1 - t, 3); }
function currentPos(ts) {
if (!rolling) return to;
var t = Math.min(1, (ts - rollStart) / ROLL_MS);
return from + (to - from) * easeOutCubic(t);
}
function paint(ts) {
inputs.forEach(function (inp, i) { boxes[i].classList.toggle('cbc__box--on', inp.checked); });
var count = inputs.filter(function (i) { return i.checked; }).length;
var target = -count * ROW_H;
if (target !== to) {
from = currentPos(ts);
to = target;
rollStart = ts;
rolling = true;
}
}
inputs.forEach(function (inp) {
inp.addEventListener('change', function () { human(); paint(performance.now()); });
});
var cycleStart = 0;
var lastN = -1;
var SEQ = [[false, false, false], [true, false, false], [true, true, false], [true, true, true], [true, true, false], [true, false, false], [false, false, false]];
function loop(ts) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
var n = Math.floor((ts - cycleStart) / CYCLE_MS) % SEQ.length;
if (n !== lastN) {
lastN = n;
var state = SEQ[n];
inputs.forEach(function (inp, i) { inp.checked = state[i]; });
paint(ts);
}
}
pos = currentPos(ts);
strip.style.transform = 'translateY(' + pos.toFixed(2) + 'px)';
requestAnimationFrame(loop);
}
if (reduce) { ROLL_MS = 1; }
requestAnimationFrame(loop);
})();// CountBadgeRoll — vanilla 판(run/368/demo/04-custom-checkbox-count-badge-roll/index.html) 의 React 포트.
// 체크된 개수 → 목표 translateY 값을 만들고 easeOutCubic 을 rAF 로 직접 적분해 숫자 strip 을
// 다음 칸으로 굴린다. clip-path 가 그 칸 하나만 보이는 창을 만든다.
import { useEffect, useRef } from "react";
import styles from "./CountBadgeRoll.module.scss";
type Box<T> = { current: T };
const ITEMS = [
{ id: "item0", label: "기본 배송" },
{ id: "item1", label: "선물 포장" },
{ id: "item2", label: "설치 서비스" },
];
export default function CountBadgeRoll() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const stripRef = useRef(null) as unknown as Box<HTMLSpanElement>;
const inputRefs = useRef([null, null, null]) as unknown as Box<(HTMLInputElement | null)[]>;
const boxRefs = useRef([null, null, null]) as unknown as Box<(HTMLSpanElement | null)[]>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const ROW_H = 18; // px, 숫자 롤 한 칸 높이
let ROLL_MS = 260; // 계산 겹 상수: 숫자가 다음 칸으로 굴러가는 데 걸리는 실제 시간(ms)
const CYCLE_MS = 560; // 자동 루프가 항목을 하나씩 체크하는 간격
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const strip = stripRef.current;
const inputs = inputRefs.current;
const boxes = boxRefs.current;
if (!root || !strip) return;
let isDemo = true;
let pos = 0, from = 0, to = 0, rollStart = 0, rolling = false;
function human() {
if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; }
}
function easeOutCubic(t: number) { return 1 - Math.pow(1 - t, 3); }
function currentPos(ts: number) {
if (!rolling) return to;
const t = Math.min(1, (ts - rollStart) / ROLL_MS);
return from + (to - from) * easeOutCubic(t);
}
function paint(ts: number) {
inputs.forEach((inp, i) => { boxes[i]?.classList.toggle(styles.boxOn, inp ? inp.checked : false); });
const count = inputs.filter((i) => i?.checked).length;
const target = -count * ROW_H;
if (target !== to) {
from = currentPos(ts);
to = target;
rollStart = ts;
rolling = true;
}
}
const handlers = inputs.map((inp) => {
const fn = () => { human(); paint(performance.now()); };
inp?.addEventListener("change", fn);
return fn;
});
let cycleStart = 0;
let lastN = -1;
const SEQ = [
[false, false, false], [true, false, false], [true, true, false], [true, true, true],
[true, true, false], [true, false, false], [false, false, false],
];
function loop(ts: number) {
if (!cycleStart) cycleStart = ts;
if (isDemo) {
const n = Math.floor((ts - cycleStart) / CYCLE_MS) % SEQ.length;
if (n !== lastN) {
lastN = n;
const state = SEQ[n];
inputs.forEach((inp, i) => { if (inp) inp.checked = state[i]; });
paint(ts);
}
}
pos = currentPos(ts);
strip!.style.transform = "translateY(" + pos.toFixed(2) + "px)";
rafRef.current = requestAnimationFrame(loop);
}
if (reduce) { ROLL_MS = 1; }
rafRef.current = requestAnimationFrame(loop);
return () => {
inputs.forEach((inp, i) => inp?.removeEventListener("change", handlers[i]));
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.cbc + " " + styles.isDemo} ref={rootRef}>
<div className={styles.cbcList}>
{ITEMS.map((it, i) => (
<label className={styles.cbcRow} key={it.id}>
<input
type="checkbox"
className={styles.cbcInput}
ref={(el: HTMLInputElement | null) => { inputRefs.current[i] = el; }}
/>
<span
className={styles.cbcBox}
ref={(el: HTMLSpanElement | null) => { boxRefs.current[i] = el; }}
aria-hidden="true"
>
<svg viewBox="0 0 20 20" width={12} height={12}>
<path 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.cbcText}>{it.label}</span>
</label>
))}
</div>
<span className={styles.cbcBadge}>
<span className={styles.cbcWindow}>
<span className={styles.cbcStrip} ref={stripRef}>
<span>0</span><span>1</span><span>2</span><span>3</span>
</span>
</span>
</span>
</div>
</main>
);
}04 선택 개수 롤 전환 — 선택된 개수 값이 오르내릴 때마다 배지 숫자의 자리 값이 위아래로 굴러가듯 바뀌는 방식 — 측정 누적면적 0.765% · 움직인 프레임 13/23GODRICH 원작 · zip 에 SCSS·React 포함 · 커스텀 체크박스 9종 — 체크 값으로 바뀌는 아홉 가지 방식
이 데모가 속한 글
커스텀 체크박스 9종 — 체크 값으로 바뀌는 아홉 가지 방식커스텀 체크박스 아홉 종을 값 기준으로 나누면, 아홉 가지 중 하나는 체크 값이 켜지면 체크 표시의 선 길이 값이 0에서 끝까지 그려지고, 하나는 같은 값이 바뀌는 순간 박스의 크기 값이 잠깐 커졌다가 튕기듯 가라앉 이 글의 데모 전부 보기


