06Save-Count Badge That Rolls Up Digit by Digit
Each time it's saved the digit inside the badge slides upward one place at a time to the new number.
Best forCounter badge that shows how many times a post was saved
BOOKMARK_COUNT_STAGGER_MSDIGIT_ROLL_MSbeginRoll
<main class="stage">
<div class="cb is-demo" id="cb">
<p class="cb__caption">Save count</p>
<div class="cb__row" id="cbRow">
<span class="cb__rowTitle">Design Inspiration Collection</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">Not saved</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 ? "Saved" : "Not 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 Save-Count Badge That Rolls Up Digit by Digit — Measured area 12.285% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · Bookmark Icon Animation: 9 Patterns, One Icon
The post this demo lives in
If you like this effect - similar demos
Buttons02 Button that unlocks when the form is valid9 button state css patterns, loading to done
Buttons04 Favorite star that draws its line, then fills9 Icon Button CSS Patterns — Press, Drag, Expand
Buttons06 Drag-to-confirm — a button that reaches a confirmed state once a dragged position value passes a thresholdCTA Button Design: 9 Interaction Patterns