07QR Code Panel Whose Payment Amount Digits Roll Over
Whenever the payment amount changes, each digit value rolls vertically from the previous number to the new one.
Best forMerchant payment amount live display
AMOUNT_ROLL_DIGIT_STAGGER_MSeaseOutBackROLL_MS

<main class="stage">
<div class="ars is-demo" id="arsRoot">
<div class="ars__badge" id="arsBadge">Payment amount</div>
<button class="ars__ctrl" id="arsCtrl" type="button">Pause</button>
<div class="ars__panel">
<div class="ars__sweep" id="arsSweep" aria-hidden="true"></div>
<div class="ars__digits" id="arsDigits"></div>
<span class="ars__unit">won</span>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 자릿수 인덱스(왼쪽부터)에 스태거 값을 곱해 컬럼별 시작 지연을 구하고,
// back-out 이징으로 되돌아오는 오버슛과 함께 각 자릿수가 순서대로 굴러 올라간다.
@include stage($stage-ink);
.ars {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.ars__badge {
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: rgba(255, 247, 230, .08);
color: $subject-cream;
font-weight: 700;
font-size: 11px;
}
.ars__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: $r-xs;
padding: $sp-1 $sp-2;
background: rgba(255, 247, 230, .12);
color: $subject-cream;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.ars__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.ars__panel {
position: relative;
display: flex;
align-items: baseline;
gap: $sp-2;
padding: $sp-5 $sp-6;
border-radius: $r-card;
background: rgba(255, 255, 255, .05);
box-shadow: $shadow-raised;
overflow: hidden;
}
// 계산된 하이라이트 sweep — 폭 240%(-120%~120%)로 대각선 가로지름, 불투명도·위치는 JS 가 롤 진행률로 제어
.ars__sweep {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(100deg, transparent 30%, rgba($subject-blue-light, .35) 50%, transparent 70%);
will-change: transform, opacity;
}
.ars__digits {
position: relative;
display: flex;
}
.ars__col {
position: relative;
width: 22px;
height: 40px;
overflow: hidden;
&--comma {
width: 10px;
}
}
.ars__track {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
height: 200%;
transform: translateY(0%);
will-change: transform;
}
.ars__d {
height: 50%;
display: grid;
place-items: center;
font-weight: 800;
font-size: 26px;
font-variant-numeric: tabular-nums;
color: $subject-cream;
}
.ars__unit {
color: $subject-cream;
font-weight: 700;
font-size: 16px;
opacity: .8;
}
@media (max-width: 340px) {
.ars__panel { padding: $sp-4 $sp-5; }
.ars__col { width: 18px; height: 32px; }
.ars__col--comma { width: 8px; }
.ars__d { font-size: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.ars__track { transition: none !important; }
}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);
}
.ars {
position: relative;
width: min(100%, 280px);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.ars__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
}
.ars__ctrl {
position: absolute;
top: 0;
right: 0;
border: 0;
border-radius: 4px;
padding: 4px 8px;
background: rgba(255, 247, 230, 0.12);
color: #fff7e6;
font-weight: 600;
font-size: 10px;
line-height: 1.4;
cursor: pointer;
}
.ars__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.ars__panel {
position: relative;
display: flex;
align-items: baseline;
gap: 8px;
padding: 20px 24px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
overflow: hidden;
}
.ars__sweep {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
background: linear-gradient(100deg, transparent 30%, rgba(91, 140, 248, 0.35) 50%, transparent 70%);
will-change: transform, opacity;
}
.ars__digits {
position: relative;
display: flex;
}
.ars__col {
position: relative;
width: 22px;
height: 40px;
overflow: hidden;
}
.ars__col--comma {
width: 10px;
}
.ars__track {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
height: 200%;
transform: translateY(0%);
will-change: transform;
}
.ars__d {
height: 50%;
display: grid;
place-items: center;
font-weight: 800;
font-size: 26px;
font-variant-numeric: tabular-nums;
color: #fff7e6;
}
.ars__unit {
color: #fff7e6;
font-weight: 700;
font-size: 16px;
opacity: 0.8;
}
@media (max-width: 340px) {
.ars__panel {
padding: 16px 20px;
}
.ars__col {
width: 18px;
height: 32px;
}
.ars__col--comma {
width: 8px;
}
.ars__d {
font-size: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.ars__track {
transition: none !important;
}
}(function () {
// AMOUNT_ROLL_DIGIT_STAGGER_MS — 변형 1: 60 → 110 으로 늘리면 자릿수가 더 크게 어긋나며 굴러간다.
var AMOUNT_ROLL_DIGIT_STAGGER_MS = 60;
var LOOP_MS = 2000;
var ROLL_MS = 480;
var FROM = '08,000';
var TO = '12,400';
// 시연 스케줄: 2초 루프 안에 굴러가는 방향을 두 번 넣는다(증가 → 감소) — 로직·상수는 그대로,
// 두 번째 구간은 같은 트랙(FROM/TO 두 자릿수)을 반대로 재생해 되돌아가는 롤을 보여준다.
var SEG_DURATION = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS + ROLL_MS;
var SEG2_START = 1050;
var root = document.getElementById('arsRoot');
var badge = document.getElementById('arsBadge');
var ctrl = document.getElementById('arsCtrl');
var digitsEl = document.getElementById('arsDigits');
var sweepEl = document.getElementById('arsSweep');
var cols = [];
for (var i = 0; i < TO.length; i += 1) {
var col = document.createElement('span');
col.className = 'ars__col';
if (TO[i] === ',') col.classList.add('ars__col--comma');
var track = document.createElement('span');
track.className = 'ars__track';
var a = document.createElement('span'); a.className = 'ars__d'; a.textContent = FROM[i];
var b = document.createElement('span'); b.className = 'ars__d'; b.textContent = TO[i];
track.appendChild(a); track.appendChild(b);
col.appendChild(track);
digitsEl.appendChild(col);
// 계산 한 겹: 자릿수 인덱스(왼쪽부터)에 스태거를 곱해 각 컬럼의 시작 지연을 구한다.
cols.push({ track: track, delay: i * AMOUNT_ROLL_DIGIT_STAGGER_MS, isDigit: TO[i] !== ',' });
}
var maxDelay = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS;
var ROLL_END = maxDelay + ROLL_MS;
function easeOutBack(t) {
var c1 = 1.4, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function progressFor(elapsed, segStart, delay) {
var local = elapsed - segStart - delay;
if (local <= 0) return 0;
if (local >= ROLL_MS) return 1;
return local / ROLL_MS;
}
function render(elapsed) {
var anyMoving = false;
// 계산 한 겹: 두 번째 구간(감소 방향)이 시작됐는지는 컬럼별 지연 없이 구간 전체로 판정한다 —
// 첫 구간 꼬리와 겹치지 않도록 SEG2_START 를 첫 구간 최대 종료(ROLL_END)보다 뒤에 둔다.
var seg2Active = elapsed >= SEG2_START;
cols.forEach(function (c) {
var translateY, p;
if (seg2Active) {
var p2 = progressFor(elapsed, SEG2_START, c.delay);
var eased2 = easeOutBack(p2);
translateY = -(1 - eased2) * 50;
p = p2;
} else {
var p1 = progressFor(elapsed, 0, c.delay);
var eased1 = easeOutBack(p1);
translateY = -eased1 * 50;
p = p1;
}
if (p > 0 && p < 1) anyMoving = true;
c.track.style.transform = 'translateY(' + translateY.toFixed(2) + '%)';
// 계산 한 겹: 롤 속도(사인 피크, 구간 중앙에서 최대)에 비례한 잔상 톤 — 형태는 바꾸지 않는다.
var velocity = Math.sin(Math.min(1, Math.max(0, p)) * Math.PI);
c.track.style.filter = velocity > 0.01 ? 'blur(' + (velocity * 1.1).toFixed(2) + 'px)' : 'none';
c.track.style.boxShadow = velocity > 0.01
? '0 0 ' + (velocity * 9).toFixed(1) + 'px rgba(91, 140, 248, ' + (velocity * 0.5).toFixed(2) + ')'
: 'none';
});
badge.textContent = anyMoving ? 'Updating' : 'Payment amount';
// 계산 한 겹: 카드 하이라이트 sweep — 롤 진행률(globalP)의 함수로 패널 전체를 가로지른다.
var globalP = -1;
if (elapsed >= 0 && elapsed < SEG_DURATION) {
globalP = elapsed / SEG_DURATION;
} else if (elapsed >= SEG2_START && elapsed < SEG2_START + SEG_DURATION) {
globalP = (elapsed - SEG2_START) / SEG_DURATION;
}
if (globalP >= 0) {
var sweepOpacity = Math.sin(globalP * Math.PI);
sweepEl.style.opacity = (sweepOpacity * 0.8).toFixed(2);
sweepEl.style.transform = 'translateX(' + (-120 + globalP * 240).toFixed(1) + '%)';
} else {
sweepEl.style.opacity = '0';
}
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? ROLL_END : 0;
if (reduce) {
playing = false;
root.classList.remove('is-demo');
}
var lastTs = null;
function tick(ts) {
if (lastTs === null) lastTs = ts;
var dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
render(elapsed);
requestAnimationFrame(tick);
}
render(elapsed);
requestAnimationFrame(tick);
ctrl.addEventListener('click', function () {
playing = !playing;
ctrl.textContent = playing ? 'Pause' : 'Play';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
playing = true;
ctrl.textContent = 'Pause';
}
});
})();import { useEffect, useRef, useState } from "react";
import styles from "./AmountRollSwap.module.scss";
export default function AmountRollSwap() {
const rootRef = useRef<HTMLDivElement>(null);
const digitsRef = useRef<HTMLDivElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const sweepRef = useRef<HTMLDivElement>(null);
const [badge, setBadge] = useState("결제 금액");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
useEffect(() => {
const root = rootRef.current;
const digitsEl = digitsRef.current;
const ctrl = ctrlRef.current;
const sweepEl = sweepRef.current;
if (!root || !digitsEl || !ctrl || !sweepEl) return;
// AMOUNT_ROLL_DIGIT_STAGGER_MS — 변형 1: 60 → 110 으로 늘리면 자릿수가 더 크게 어긋나며 굴러간다.
const AMOUNT_ROLL_DIGIT_STAGGER_MS = 60;
const LOOP_MS = 2000;
const ROLL_MS = 480;
const FROM = "08,000";
const TO = "12,400";
// 시연 스케줄: 2초 루프 안에 굴러가는 방향을 두 번 넣는다(증가 → 감소) — 로직·상수는 그대로,
// 두 번째 구간은 같은 트랙(FROM/TO 두 자릿수)을 반대로 재생해 되돌아가는 롤을 보여준다.
const SEG_DURATION = (TO.length - 1) * AMOUNT_ROLL_DIGIT_STAGGER_MS + ROLL_MS;
const SEG2_START = 1050;
digitsEl.innerHTML = "";
const cols: { track: HTMLSpanElement; delay: number }[] = [];
for (let i = 0; i < TO.length; i += 1) {
const col = document.createElement("span");
col.className = styles.ars__col;
if (TO[i] === ",") col.classList.add(styles["ars__col--comma"]);
const track = document.createElement("span");
track.className = styles.ars__track;
const a = document.createElement("span"); a.className = styles.ars__d; a.textContent = FROM[i];
const b = document.createElement("span"); b.className = styles.ars__d; b.textContent = TO[i];
track.appendChild(a); track.appendChild(b);
col.appendChild(track);
digitsEl.appendChild(col);
// 계산 한 겹: 자릿수 인덱스(왼쪽부터)에 스태거를 곱해 각 컬럼의 시작 지연을 구한다.
cols.push({ track, delay: i * AMOUNT_ROLL_DIGIT_STAGGER_MS });
}
function easeOutBack(t: number) {
const c1 = 1.4, c3 = c1 + 1;
return 1 + c3 * Math.pow(t - 1, 3) + c1 * Math.pow(t - 1, 2);
}
function progressFor(elapsed: number, segStart: number, delay: number) {
const local = elapsed - segStart - delay;
if (local <= 0) return 0;
if (local >= ROLL_MS) return 1;
return local / ROLL_MS;
}
function render(elapsed: number) {
let anyMoving = false;
const seg2Active = elapsed >= SEG2_START;
cols.forEach((c) => {
let translateY: number;
let p: number;
if (seg2Active) {
const p2 = progressFor(elapsed, SEG2_START, c.delay);
const eased2 = easeOutBack(p2);
translateY = -(1 - eased2) * 50;
p = p2;
} else {
const p1 = progressFor(elapsed, 0, c.delay);
const eased1 = easeOutBack(p1);
translateY = -eased1 * 50;
p = p1;
}
if (p > 0 && p < 1) anyMoving = true;
c.track.style.transform = "translateY(" + translateY.toFixed(2) + "%)";
// 계산 한 겹: 롤 속도(사인 피크, 구간 중앙에서 최대)에 비례한 잔상 톤 — 형태는 바꾸지 않는다.
const velocity = Math.sin(Math.min(1, Math.max(0, p)) * Math.PI);
c.track.style.filter = velocity > 0.01 ? "blur(" + (velocity * 1.1).toFixed(2) + "px)" : "none";
c.track.style.boxShadow = velocity > 0.01
? "0 0 " + (velocity * 9).toFixed(1) + "px rgba(91, 140, 248, " + (velocity * 0.5).toFixed(2) + ")"
: "none";
});
setBadge(anyMoving ? "갱신 중" : "결제 금액");
// 계산 한 겹: 카드 하이라이트 sweep — 롤 진행률(globalP)의 함수로 패널 전체를 가로지른다.
let globalP = -1;
if (elapsed >= 0 && elapsed < SEG_DURATION) {
globalP = elapsed / SEG_DURATION;
} else if (elapsed >= SEG2_START && elapsed < SEG2_START + SEG_DURATION) {
globalP = (elapsed - SEG2_START) / SEG_DURATION;
}
if (globalP >= 0) {
const sweepOpacity = Math.sin(globalP * Math.PI);
sweepEl!.style.opacity = (sweepOpacity * 0.8).toFixed(2);
sweepEl!.style.transform = "translateX(" + (-120 + globalP * 240).toFixed(1) + "%)";
} else {
sweepEl!.style.opacity = "0";
}
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = reduce ? SEG2_START + SEG_DURATION : 0;
if (reduce) {
playing = false;
root.classList.remove("is-demo");
}
let lastTs: number | null = null;
let raf = 0;
function tick(ts: number) {
if (lastTs === null) lastTs = ts;
const dt = ts - lastTs;
lastTs = ts;
if (playing) {
elapsed += dt;
if (elapsed >= LOOP_MS) elapsed -= LOOP_MS;
}
render(elapsed);
raf = requestAnimationFrame(tick);
}
render(elapsed);
raf = requestAnimationFrame(tick);
function onClick() {
playing = !playing;
setCtrlLabel(playing ? "일시정지" : "재생");
}
function onKeydown(e: KeyboardEvent) {
if ((e.key === "r" || e.key === "R") && !reduce) {
elapsed = 0;
playing = true;
setCtrlLabel("일시정지");
}
}
ctrl.addEventListener("click", onClick);
ctrl.addEventListener("keydown", onKeydown);
return () => {
cancelAnimationFrame(raf);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
digitsEl.innerHTML = "";
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.ars} is-demo`} ref={rootRef}>
<div className={styles.ars__badge}>{badge}</div>
<button className={styles.ars__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles.ars__panel}>
<div className={styles.ars__sweep} ref={sweepRef} aria-hidden="true" />
<div className={styles.ars__digits} ref={digitsRef} />
<span className={styles.ars__unit}>원</span>
</div>
</div>
</main>
);
}07 QR Code Panel Whose Payment Amount Digits Roll Over — Measured area 11.454% · moving frames 20/23Original by GODRICH · SCSS and React ship in the zip · QR Code Loading Animation — 9 CSS Expiry Patterns
The post this demo lives in
QR Code Loading Animation — 9 CSS Expiry PatternsNot a scan failure, not sitting too long — one time left ratio for qr code loading animation must run out: item 3 shrink See all demos in this post
If you like this effect - similar demos
Loaders01 Button Whose Label Crossfades Into a Spinner IconLoading Button UI — 9 States, Spinner to Success
Loaders01 Called Number Whose Digits Roll After Recalculating the Remaining CountQueue Number Display: 9 Waiting Board Patterns
Loaders07 Panel whose height stretches along as you move between tabsPage Loading Animation CSS: 9 Route-Change Demos