06保存数が一桁ずつ増えるバッジ
保存するたびにバッジ内の数字が一桁ずつ上にスライドして新しい数字に変わる。
使いどころ記事の保存数を示すカウンターバッジ
BOOKMARK_COUNT_STAGGER_MSDIGIT_ROLL_MSbeginRoll
<main class="stage">
<div class="cb is-demo" id="cb">
<p class="cb__caption">保存数</p>
<div class="cb__row" id="cbRow">
<span class="cb__rowTitle">デザインインスピレーション集</span>
<button class="cb__btn" id="cbBtn" type="button" aria-pressed="false">
<svg class="cb__icon" viewBox="0 0 24 24" width="20" height="20" aria-hidden="true" focusable="false">
<path class="cb__shape" d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z"/>
</svg>
<span class="cb__badge" id="cbBadge" aria-live="polite">
<span class="cb__digit"><span class="cb__digitOld" id="cbOld0">0</span><span class="cb__digitNew" id="cbNew0">0</span></span>
<span class="cb__digit"><span class="cb__digitOld" id="cbOld1">9</span><span class="cb__digitNew" id="cbNew1">9</span></span>
</span>
</button>
</div>
<p class="cb__status" id="cbStatus">未保存</p>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 값 하나: 자릿수 인덱스 × BOOKMARK_COUNT_STAGGER_MS 지연 → 그 칸이 구르기 시작하는 시각.
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue-deep;
@include stage($stage-paper);
.cb {
display: grid;
gap: $sp-3;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: $subject-ink;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: $sp-3;
width: 260px;
padding: $sp-4 $sp-5;
border-radius: $r-card;
background: $subject-cream;
box-shadow: $shadow-press;
transform: scale(1);
}
.cb__row--pulse {
background: mix($subject-cream, $subject-blue, 88%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: $subject-ink;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: $sp-1;
border: 0;
background: transparent;
cursor: pointer;
color: $color;
}
.cb__btn:focus-visible {
outline: 2px solid $subject-blue;
outline-offset: 3px;
}
.cb__shape {
fill: $color;
stroke: $color;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: $r-xs;
background: $subject-ink;
padding: $sp-1 $sp-2;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: $subject-ink;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row { width: 220px; padding: $sp-3 $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew { 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);
}
.cb {
display: grid;
gap: 12px;
justify-items: center;
width: 100%;
}
.cb__caption {
margin: 0;
font-size: 12px;
font-weight: 600;
color: #17141a;
}
.cb__row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
width: 260px;
padding: 16px 20px;
border-radius: 16px;
background: #fff7e6;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transform: scale(1);
}
.cb__row--pulse {
background: rgb(90.2117647059%, 90.368627451%, 90.9490196078%);
transform: scale(1.03);
}
.cb__rowTitle {
font-size: 13px;
font-weight: 700;
color: #17141a;
}
.cb__btn {
position: relative;
display: inline-flex;
align-items: center;
gap: 4px;
border: 0;
background: transparent;
cursor: pointer;
color: #0a4fe8;
}
.cb__btn:focus-visible {
outline: 2px solid #2f6df6;
outline-offset: 3px;
}
.cb__shape {
fill: #0a4fe8;
stroke: #0a4fe8;
stroke-width: 1.4;
}
.cb__badge {
display: inline-flex;
overflow: hidden;
border-radius: 4px;
background: #17141a;
padding: 4px 8px;
font-weight: 700;
font-size: 12px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.cb__digit {
position: relative;
display: inline-block;
width: 8px;
height: 14px;
overflow: hidden;
}
.cb__digitOld,
.cb__digitNew {
position: absolute;
inset: 0;
display: block;
will-change: transform;
}
.cb__status {
margin: 0;
font-size: 11px;
color: #17141a;
font-weight: 600;
}
@media (max-width: 340px) {
.cb__row {
width: 220px;
padding: 12px 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.cb__digitOld, .cb__digitNew {
transition: none;
}
}(function () {
"use strict";
var BOOKMARK_COUNT_STAGGER_MS = 40;
var DIGIT_ROLL_MS = 260;
var CYCLE_S = 2;
var root = document.getElementById("cb");
var row = document.getElementById("cbRow");
var btn = document.getElementById("cbBtn");
var status = document.getElementById("cbStatus");
var reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
var oldEls = [document.getElementById("cbOld0"), document.getElementById("cbOld1")];
var newEls = [document.getElementById("cbNew0"), document.getElementById("cbNew1")];
var count = 9;
var saved = false;
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
var columns = [
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" }
];
function pad2(n) { return (n < 10 ? "0" : "") + n; }
function beginRoll(now) {
var next = saved ? count + 1 : count - 1;
var fromStr = pad2(count);
var toStr = pad2(next);
count = next;
for (var i = 0; i < 2; i++) {
columns[i].from = fromStr[i];
columns[i].to = toStr[i];
columns[i].start = now + columns[i].delay;
}
status.textContent = saved ? "保存済み" : "未保存";
row.classList.add("cb__row--pulse");
window.setTimeout(function () { row.classList.remove("cb__row--pulse"); }, 320);
}
function renderColumns(now) {
for (var i = 0; i < 2; i++) {
var col = columns[i];
oldEls[i].textContent = col.from;
newEls[i].textContent = col.to;
var p = 0;
if (col.start !== null) {
var raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
var eased = 1 - Math.pow(1 - p, 2);
oldEls[i].style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEls[i].style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
var pendingRoll = false;
btn.addEventListener("click", function () {
root.classList.remove("is-demo");
saved = !saved;
btn.setAttribute("aria-pressed", String(saved));
pendingRoll = true;
});
var start = null;
var prevT = 0;
var toggledA = false;
var toggledB = false;
function loop(now) {
if (start === null) start = now;
if (pendingRoll) { beginRoll(now); pendingRoll = false; }
var t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && root.classList.contains("is-demo")) {
if (!toggledA && t > 0.3) {
toggledA = true; saved = true; btn.setAttribute("aria-pressed", "true"); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; saved = false; btn.setAttribute("aria-pressed", "false"); beginRoll(now);
}
}
if (reduce) {
for (var i = 0; i < 2; i++) { columns[i].start = null; }
}
renderColumns(now);
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// StackCountBadgeBookmark — vanilla 판(run/416/demo/06-bookmark-btn-stack-count-badge/index.html) 의 React 포트.
// 값 하나: 자릿수 인덱스(오른쪽부터) × BOOKMARK_COUNT_STAGGER_MS = 그 칸이 구르기 시작하는 지연 시간.
import { useEffect, useRef, useState } from "react";
import styles from "./StackCountBadgeBookmark.module.scss";
type Box<T> = { current: T };
type Column = { delay: number; start: number | null; from: string; to: string };
const BOOKMARK_COUNT_STAGGER_MS = 40;
const DIGIT_ROLL_MS = 260;
const CYCLE_S = 2;
function pad2(n: number): string {
return (n < 10 ? "0" : "") + n;
}
export default function StackCountBadgeBookmark() {
const rowRef = useRef<HTMLDivElement | null>(null);
const oldRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const newRefs = useRef<Array<HTMLSpanElement | null>>([null, null]) as Box<Array<HTMLSpanElement | null>>;
const countRef = useRef<number>(9) as Box<number>;
const savedRef = useRef<boolean>(false) as Box<boolean>;
const pendingRef = useRef<boolean>(false) as Box<boolean>;
const frameRef = useRef<number>(0) as Box<number>;
const columnsRef = useRef<Column[]>([
{ delay: 1 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "0", to: "0" },
{ delay: 0 * BOOKMARK_COUNT_STAGGER_MS, start: null, from: "9", to: "9" },
]) as Box<Column[]>;
const [status, setStatus] = useState("저장 전");
const [pressed, setPressed] = useState(false);
const [demo, setDemo] = useState(true);
const [reduce, setReduce] = useState(false);
function beginRoll(now: number): void {
const next = savedRef.current ? countRef.current + 1 : countRef.current - 1;
const fromStr = pad2(countRef.current);
const toStr = pad2(next);
countRef.current = next;
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
cols[i].from = fromStr[i];
cols[i].to = toStr[i];
cols[i].start = now + cols[i].delay;
}
setStatus(savedRef.current ? "저장됨" : "저장 전");
const row = rowRef.current;
if (row) {
row.classList.add(styles.rowPulse);
window.setTimeout(() => { row.classList.remove(styles.rowPulse); }, 320);
}
}
function renderColumns(now: number): void {
const cols = columnsRef.current;
for (let i = 0; i < 2; i++) {
const col = cols[i];
const oldEl = oldRefs.current[i];
const newEl = newRefs.current[i];
if (!oldEl || !newEl) continue;
oldEl.textContent = col.from;
newEl.textContent = col.to;
let p = 0;
if (col.start !== null) {
const raw = (now - col.start) / DIGIT_ROLL_MS;
p = Math.max(0, Math.min(1, raw));
if (raw >= 1) { col.start = null; }
}
const eased = 1 - Math.pow(1 - p, 2);
oldEl.style.transform = "translateY(" + (-eased * 100).toFixed(1) + "%)";
newEl.style.transform = "translateY(" + ((1 - eased) * 100).toFixed(1) + "%)";
}
}
useEffect(() => {
setReduce(window.matchMedia("(prefers-reduced-motion: reduce)").matches);
}, []);
useEffect(() => {
let start = 0;
let prevT = 0;
let toggledA = false;
let toggledB = false;
function loop(now: number): void {
if (start === 0) start = now;
if (pendingRef.current) { beginRoll(now); pendingRef.current = false; }
const t = ((now - start) / 1000) % CYCLE_S;
if (t < prevT) { toggledA = false; toggledB = false; }
prevT = t;
if (!reduce && demo) {
if (!toggledA && t > 0.3) {
toggledA = true; savedRef.current = true; setPressed(true); beginRoll(now);
}
if (!toggledB && t > 1.3) {
toggledB = true; savedRef.current = false; setPressed(false); beginRoll(now);
}
}
if (reduce) {
for (let i = 0; i < 2; i++) { columnsRef.current[i].start = null; }
}
renderColumns(now);
frameRef.current = requestAnimationFrame(loop);
}
frameRef.current = requestAnimationFrame(loop);
return () => cancelAnimationFrame(frameRef.current);
}, [reduce, demo]);
function onClick(): void {
setDemo(false);
const v = !savedRef.current;
savedRef.current = v;
setPressed(v);
pendingRef.current = true;
}
return (
<main className={styles.stage}>
<div className={styles.cb}>
<p className={styles.caption}>저장 수</p>
<div className={styles.row} ref={rowRef}>
<span className={styles.rowTitle}>디자인 영감 모음</span>
<button className={styles.btn} type="button" aria-pressed={pressed} onClick={onClick}>
<svg className={styles.icon} viewBox="0 0 24 24" width={20} height={20} aria-hidden="true" focusable="false">
<path className={styles.shape} d="M6.5 3A1.5 1.5 0 0 1 8 1.5h8A1.5 1.5 0 0 1 17.5 3v18.2a.9.9 0 0 1-1.38.76L12 19.1l-4.12 2.86A.9.9 0 0 1 6.5 21.2Z" />
</svg>
<span className={styles.badge} aria-live="polite">
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[0] = el; }}>0</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[0] = el; }}>0</span>
</span>
<span className={styles.digit}>
<span className={styles.digitOld} ref={(el: HTMLSpanElement | null) => { oldRefs.current[1] = el; }}>9</span>
<span className={styles.digitNew} ref={(el: HTMLSpanElement | null) => { newRefs.current[1] = el; }}>9</span>
</span>
</span>
</button>
</div>
<p className={styles.status}>{status}</p>
</div>
</main>
);
}06 保存数が一桁ずつ増えるバッジ — 計測 累積面積 12.285% · 動いたフレーム 12/23GODRICH オリジナル · SCSS と React は zip に同梱 · ブックマーク ボタン html 実装 9パターン

