09QR Carousel That Advances Codes by an Index Value
Coupon, point, and payment codes each map to an index value, and whenever that value changes, the code on screen slides over to the next one.
Best forMembership app code-switch screen
MULTI_CODE_CAROUSEL_STEP_MSeaseInOutgrids

<main class="stage">
<div class="mcc is-demo" id="mccRoot">
<div class="mcc__badge" id="mccBadge">1 / 5</div>
<button class="mcc__ctrl" id="mccCtrl" type="button">Pause</button>
<div class="mcc__canvas-wrap">
<canvas class="mcc__canvas" id="mccCanvas" width="240" height="200"></canvas>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 핵심: 현재 위치와 각 코드 사이 원형 거리로 원근 스케일·투명도·좌우 이동량을
// 한 번에 계산한다 — 가까울수록 크고 진하게, 멀수록 작고 흐리게.
@include stage($stage-ink);
.mcc {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: $sp-3;
}
.mcc__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;
font-variant-numeric: tabular-nums;
}
.mcc__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;
}
.mcc__ctrl:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
.mcc__canvas-wrap {
width: 100%;
height: 220px;
border-radius: $r-card;
background: rgba(255, 255, 255, .04);
display: grid;
place-items: center;
overflow: hidden;
}
.mcc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mcc { gap: $sp-1; }
.mcc__badge { padding: $sp-1 $sp-2; font-size: 10px; }
.mcc__canvas-wrap { height: 124px; }
}
@media (prefers-reduced-motion: reduce) {
.mcc__canvas-wrap { background: rgba(255, 255, 255, .04); }
}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);
}
.mcc {
position: relative;
width: 100%;
max-width: 424px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
}
.mcc__badge {
padding: 4px 12px;
border-radius: 999px;
background: rgba(255, 247, 230, 0.08);
color: #fff7e6;
font-weight: 700;
font-size: 11px;
font-variant-numeric: tabular-nums;
}
.mcc__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;
}
.mcc__ctrl:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
.mcc__canvas-wrap {
width: 100%;
height: 220px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.04);
display: grid;
place-items: center;
overflow: hidden;
}
.mcc__canvas {
width: 100%;
height: 100%;
display: block;
}
@media (max-width: 340px) {
.mcc {
gap: 4px;
}
.mcc__badge {
padding: 4px 8px;
font-size: 10px;
}
.mcc__canvas-wrap {
height: 124px;
}
}
@media (prefers-reduced-motion: reduce) {
.mcc__canvas-wrap {
background: rgba(255, 255, 255, 0.04);
}
}(function () {
// MULTI_CODE_CAROUSEL_STEP_MS — 변형 1: 650 → 1000 으로 늘리면 코드가 더 느긋하게 넘어간다.
var MULTI_CODE_CAROUSEL_STEP_MS = 650;
var COUNT = 5;
var LOOP_MS = MULTI_CODE_CAROUSEL_STEP_MS * COUNT;
var canvas = document.getElementById('mccCanvas');
var ctx = canvas.getContext('2d');
var wrap = canvas.parentElement;
var badge = document.getElementById('mccBadge');
var ctrl = document.getElementById('mccCtrl');
var root = document.getElementById('mccRoot');
function seedRand(seed) {
var s = seed >>> 0;
return function () { s = (s * 1664525 + 1013904223) >>> 0; return s / 4294967296; };
}
var grids = [];
for (var g = 0; g < COUNT; g += 1) {
var rand = seedRand(407090 + g * 17);
var dots = [];
for (var y = 0; y < 7; y += 1) {
for (var x = 0; x < 7; x += 1) {
if (rand() < 0.44) dots.push({ x: x, y: y });
}
}
grids.push(dots);
}
function easeInOut(t) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 카드 간격·크기를 다시 잰다 —
// 카드 이동·원근 스케일이 스테이지를 채우도록(278px 캡 없이 반응형).
var dpr = 1, cw = 240, ch = 200, cy = 100, stepX = 74, boxBase = 60;
function fit() {
var r = wrap.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
cw = r.width; ch = r.height;
canvas.width = Math.round(cw * dpr);
canvas.height = Math.round(ch * dpr);
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
cy = ch / 2;
stepX = cw * 0.31;
boxBase = Math.min(cw * 0.24, ch * 0.5);
draw(lastElapsed);
}
var lastElapsed = 0;
function draw(elapsed) {
lastElapsed = elapsed;
ctx.clearRect(0, 0, cw, ch);
var raw = elapsed / MULTI_CODE_CAROUSEL_STEP_MS;
var index = Math.floor(raw) % COUNT;
var localT = raw - Math.floor(raw);
var eased = easeInOut(localT);
var pos = index + eased;
for (var i = 0; i < COUNT; i += 1) {
// 계산 한 겹: 현재 위치와의 거리 → 원근 스케일·투명도·좌우 이동량을 함께 구한다.
var diff = i - pos;
while (diff > COUNT / 2) diff -= COUNT;
while (diff < -COUNT / 2) diff += COUNT;
var dist = Math.abs(diff);
if (dist > 1.8) continue;
var scale = Math.max(0.35, 1 - dist * 0.42);
var alpha = Math.max(0, 1 - dist * 0.55);
var cx = cw / 2 + diff * stepX;
var box = boxBase * scale;
ctx.globalAlpha = alpha;
ctx.fillStyle = 'rgba(20,26,38,.92)';
ctx.fillRect(cx - box / 2, cy - box / 2, box, box);
var cell = box / 7;
ctx.fillStyle = 'rgb(255,247,230)';
grids[i].forEach(function (d) {
var s = cell * 0.62;
ctx.fillRect(cx - box / 2 + d.x * cell + (cell - s) / 2, cy - box / 2 + d.y * cell + (cell - s) / 2, s, s);
});
}
ctx.globalAlpha = 1;
badge.textContent = (index + 1) + ' / ' + COUNT;
}
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var playing = true;
var elapsed = reduce ? 0 : 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;
}
draw(elapsed);
requestAnimationFrame(tick);
}
fit();
window.addEventListener('resize', fit);
draw(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 "./MultiCodeCarousel.module.scss";
export default function MultiCodeCarousel() {
const rootRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const ctrlRef = useRef<HTMLButtonElement>(null);
const [badge, setBadge] = useState("1 / 5");
const [ctrlLabel, setCtrlLabel] = useState("일시정지");
useEffect(() => {
const root = rootRef.current;
const canvas = canvasRef.current;
const ctrl = ctrlRef.current;
if (!root || !canvas || !ctrl) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
// MULTI_CODE_CAROUSEL_STEP_MS — 변형 1: 650 → 1000 으로 늘리면 코드가 더 느긋하게 넘어간다.
const MULTI_CODE_CAROUSEL_STEP_MS = 650;
const COUNT = 5;
const LOOP_MS = MULTI_CODE_CAROUSEL_STEP_MS * COUNT;
const wrap = canvas.parentElement as HTMLElement;
function seedRand(seed: number) {
let s = seed >>> 0;
return function () {
s = (s * 1664525 + 1013904223) >>> 0;
return s / 4294967296;
};
}
const grids: { x: number; y: number }[][] = [];
for (let g = 0; g < COUNT; g += 1) {
const rand = seedRand(407090 + g * 17);
const dots: { x: number; y: number }[] = [];
for (let y = 0; y < 7; y += 1) {
for (let x = 0; x < 7; x += 1) {
if (rand() < 0.44) dots.push({ x, y });
}
}
grids.push(dots);
}
function easeInOut(t: number) { return t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; }
// 계산된 시각 레이어: 캔버스 크기가 바뀌면 카드 간격·크기를 다시 잰다 —
// 카드 이동·원근 스케일이 스테이지를 채우도록(278px 캡 없이 반응형).
let dpr = 1, cw = 240, ch = 200, cy = 100, stepX = 74, boxBase = 60;
function fit() {
const r = wrap.getBoundingClientRect();
dpr = window.devicePixelRatio || 1;
cw = r.width; ch = r.height;
canvas!.width = Math.round(cw * dpr);
canvas!.height = Math.round(ch * dpr);
ctx!.setTransform(dpr, 0, 0, dpr, 0, 0);
cy = ch / 2;
stepX = cw * 0.31;
boxBase = Math.min(cw * 0.24, ch * 0.5);
draw(lastElapsed);
}
let lastElapsed = 0;
function draw(elapsed: number) {
lastElapsed = elapsed;
ctx!.clearRect(0, 0, cw, ch);
const raw = elapsed / MULTI_CODE_CAROUSEL_STEP_MS;
const index = Math.floor(raw) % COUNT;
const localT = raw - Math.floor(raw);
const eased = easeInOut(localT);
const pos = index + eased;
for (let i = 0; i < COUNT; i += 1) {
// 계산 한 겹: 현재 위치와의 거리 → 원근 스케일·투명도·좌우 이동량을 함께 구한다.
let diff = i - pos;
while (diff > COUNT / 2) diff -= COUNT;
while (diff < -COUNT / 2) diff += COUNT;
const dist = Math.abs(diff);
if (dist > 1.8) continue;
const scale = Math.max(0.35, 1 - dist * 0.42);
const alpha = Math.max(0, 1 - dist * 0.55);
const cx = cw / 2 + diff * stepX;
const box = boxBase * scale;
ctx!.globalAlpha = alpha;
ctx!.fillStyle = "rgba(20,26,38,.92)";
ctx!.fillRect(cx - box / 2, cy - box / 2, box, box);
const cell = box / 7;
ctx!.fillStyle = "rgb(255,247,230)";
grids[i].forEach((d) => {
const s = cell * 0.62;
ctx!.fillRect(cx - box / 2 + d.x * cell + (cell - s) / 2, cy - box / 2 + d.y * cell + (cell - s) / 2, s, s);
});
}
ctx!.globalAlpha = 1;
setBadge((index + 1) + " / " + COUNT);
}
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let playing = true;
let elapsed = 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;
}
draw(elapsed);
raf = requestAnimationFrame(tick);
}
fit();
window.addEventListener("resize", fit);
draw(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);
window.removeEventListener("resize", fit);
ctrl.removeEventListener("click", onClick);
ctrl.removeEventListener("keydown", onKeydown);
};
}, []);
return (
<main className={styles.stage}>
<div className={`${styles.mcc} is-demo`} ref={rootRef}>
<div className={styles.mcc__badge}>{badge}</div>
<button className={styles.mcc__ctrl} type="button" ref={ctrlRef}>{ctrlLabel}</button>
<div className={styles["mcc__canvas-wrap"]}>
<canvas className={styles.mcc__canvas} ref={canvasRef} width={240} height={200} />
</div>
</div>
</main>
);
}09 QR Carousel That Advances Codes by an Index Value — Measured area 18.378% · moving frames 23/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
Loaders08 Ghost-text Reveal That Shows One More Letter as a Token-index Value RisesAI Loading Animation: 9 Patterns by One Value
Loaders04 Multi-file stagger — progress rows that queue in one by oneFile Upload Progress Bar Loader: 9 UI Demos
Loaders03 Load trigger threshold — a list recomputing how early it starts loading from the scroll speed valueInfinite Scroll Loading: 9 Essential UI Patterns