07決済金額の桁が回転して変わるQRコードパネル
決済金額が変わるたびに各桁の値が縦に回転し、以前の数字から新しい数字へ切り替わる。
使いどころ加盟店の決済金額リアルタイム表示
AMOUNT_ROLL_DIGIT_STAGGER_MSeaseOutBackROLL_MS

<main class="stage">
<div class="ars is-demo" id="arsRoot">
<div class="ars__badge" id="arsBadge">決済金額</div>
<button class="ars__ctrl" id="arsCtrl" type="button">一時停止</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">ウォン</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 ? '更新中' : '決済金額';
// 계산 한 겹: 카드 하이라이트 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 ? '一時停止' : '再生';
});
ctrl.addEventListener('keydown', function (e) {
if ((e.key === 'r' || e.key === 'R') && !reduce) {
elapsed = 0;
playing = true;
ctrl.textContent = '一時停止';
}
});
})();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コードパネル — 計測 累積面積 11.454% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · qr コード アニメーション css 9選 — 残り時間
このデモが属する記事
qr コード アニメーション css 9選 — 残り時間スキャン失敗のせいでもなく、ただ長く浮いているせいでもない — qrコードローディングアニメーションの残り時間の比率の値ひとつが尽きることでリングが狭まり、角度が削られ、終わりに触れると揺れる。3番はその値でパルスリングの半径を狭め、4番は この記事のデモをすべて見る


