カウントアップ アニメーション css 9種 — 転がって止まる計数器から桁上がりが続く掲示板まで
カウントアップ アニメーション css は、桁ごとに0〜9を書いた帯を値の分だけ押し上げて、数字を転がして止める動きです。数字はなぜ一瞬で切り替わるより転がって止まるほうが信じられるのでしょうか。
数字は変わる値ではなく転がる帯です — カウントアップ アニメーション css は桁ごとに0〜9の帯を値の分だけ押し上げるところから始まります
01 odo-digit-strip-roll
02 odo-spring-overshoot-settle
03 odo-reel-speed-blur
04 odo-carry-cascade
05 odo-price-tick-direction
06 odo-split-flap-fold
07 odo-drum-perspective
08 odo-friction-dial-snap
09 odo-hero-stat-ticker
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 桁ごとの数字の帯が転がって止まる計数器
- 02 目標を行き過ぎてばねで戻って落ち着く数字
- 03 速いとにじみ遅くなるとくっきりするリール
- 04 桁上がりが上の桁へ続く連鎖の計数器
- 05 上がれば上へ下がれば下へ転がり色が光る値
- 06 半分に折れてめくれる羽根のフリップボード
- 07 丸いドラムの遠近でつぶれて見える数字
- 08 摩擦で減速して目盛りに吸いつく慣性ダイヤル
- 09 統計の一行が順に転がり上がるヒーロー
十二マスの帯は9から0へ変わっても途切れません
01〜05・08・09 は桁ひとつが窓ひとつで、窓の中には0〜9のあとに0と1をもう一度つなげた十二マスの帯が入っています。帯は translateY(-(pos mod 10)/12·100%) で押されるので、9から0へ変わる境目のうしろにも0が続き、帯は巻き戻らずに上へ進み続けます。九つの場面は tabular-nums を使っていて、数字ごとの幅の違いで桁がぶれることはありません。
転がすマスの数を先に決めて、最後の一マスだけ目標に合わせます
数字を値としてではなく、何マス転がすかで数えます。01 は 10 + mod(b−a, 10) で一周多く回り、03 は 30 + 6i マスから始めて目標の数字に合わせて0〜9マスを足し、07 の時刻ドラムは12〜23マスを回ります。距離が長いから速さがつき、速さがつくからにじみやばねに意味が出ます。
数字 カウントアップ アニメーション のほかの記事とちがう点
css 数字 カウントアップ の記事は css だけで数字が上がるコードを扱い、この記事は js の時間表で桁ごとの速さ・にじみ・桁上がりを計算するところがちがいます。値を手で上げ下げする入力部品は 数字 ステッパー ui の記事に、減っていく残り時間は css カウントダウン タイマー の記事があります。摩擦で止まる08と同じ計算の仲間は、ルーレット 回す の記事の回転盤です。
カウントアップ アニメーション css 9種 — 桁ごとに転がって止まる計数器・目標を行き過ぎて戻るばねの数字・速いとにじむリール・桁上がりが続く連鎖の計数器・向きで光る値・半分に折れるフリップボード・円柱ドラムの遠近の数字・摩擦で減速するダイヤル・統計一行のヒーロー
01〜05 は同じ縦の帯に、桁の遅れ・ばね・速さのにじみ・桁上がり・向きという別々の決まりを重ねます。06 は帯をやめて羽根を二枚折り、07 は円柱の角度で数字をつぶして描きます。08 は摩擦で目盛りに吸いつき、09 は前の桁の遅れを統計一行のヒーローにまとめます。
九つの場面の面積・動き・強さの実測
| 場面 | 累積面積 | 動いたフレーム | 強さの平均 |
|---|---|---|---|
| 01 桁ごとの数字の帯 | 13.2% | 13/23 | 164.6 |
| 02 ばねの落ち着き | 15.7% | 14/23 | 148.9 |
| 03 速さのにじみのリール | 12.3% | 21/23 | 84.7 |
| 04 桁上がりの連鎖 | 11.1% | 18/23 | 132.3 |
| 05 向きで光る値 | 22.9% | 15/23 | 120.9 |
| 06 羽根のフリップ | 10.6% | 15/23 | 117.7 |
| 07 ドラムの遠近 | 13.9% | 16/23 | 88.5 |
| 08 摩擦のダイヤル | 6.2% | 10/23 | 105.2 |
| 09 統計一行 | 21.6% | 16/23 | 143.3 |
いちばん広く変わるのは板全体が光る05の22.9%で、いちばん狭いのは目盛りに吸いついてすぐ止まる08の6.2%です。動いたフレームは、リール五つが長く回る03が21/23で最多、08が10/23で最少になります。強さの平均は01の164.6が最高、03の84.7が最低です。
01桁ごとの数字の帯が転がって止まる計数器
ダッシュボードの累計訪問者数カードに使うと、五桁が0.25秒に一の位から出発して右の桁が先に止まり、1.67秒に左の桁が止まります。placeU(k) が桁 k(一の位が0)の進み具合を PLACE_DELAY_MS 80ms ずつ遅らせ、転がすマスの数は 10 + mod(b−a, 10) なので一周ぶん多く回ります。下の小さな行は数字の値をそのまま帯の位置に使う posBad で、小さな数へ向かうと逆向きになぞります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="五桁の数字の帯が低い桁から80msずつ遅れて出発し、右の桁が先に止まって左があとから止まります。下の小さな行は、帯の端で逆向きに巻き戻って止まる誤った方式を見せます"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.4;
const REST_T = 3.0;
// 첫 굴림은 시작 0.25초 뒤 — 낮은 자리(오른쪽)부터 80ms 씩 늦게 출발한다
const START_S = 0.25;
const PLACE_DELAY_MS = 80;
// 한 자리가 굴러 멈출 때까지 걸리는 시간(초)
const ROLL_S = 1.1;
const PLACES = 5;
const VALUES = [48372, 51904, 20617, 93058];
let strips = [];
let naive = [];
let fromV = VALUES[0];
let toV = VALUES[1];
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '桁ごとに転がって止まる数字の帯';
strips = buildRow(stack, 'dd,ddd', 'lg', 0);
mk('span', 'cap cap--bad', stack).textContent = '巻き戻る帯(端ではねる)';
naive = buildRow(stack, 'dd,ddd', 'sm', 0);
}
function reset() {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt) {
// 이 장면은 시계(sceneT)만으로 그려서 매 프레임 따로 쌓는 값이 없다
void dt;
}
// 자리 k(0 이 일의 자리)의 굴림 진행률 0 부터 1 — 낮은 자리부터 PLACE_DELAY_MS 씩 늦게 출발
function placeU(k) {
return (sceneT - START_S - k * PLACE_DELAY_MS / 1000) / ROLL_S;
}
function draw() {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 한 바퀴(10칸)를 더 돌고 목표 숫자에 멈춘다
const adv = 10 + mod(b[i] - a[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], speed / BLUR_REF * 0.9);
// 되감기는 쪽 — 숫자 값을 그대로 띠 위치로 쓰니 작은 수로 가면 거꾸로 훑고 간다
const posBad = lerp(a[i], b[i], easeOut3(u));
moveStrip(naive[i], posBad, 0);
glowStrip(naive[i], u > 0 && u < 1 && b[i] < a[i] ? 0.8 : 0);
}
}
function settledPlaces() {
let n = 0;
for (let k = 0; k < PLACES; k += 1) {
if (placeU(k) >= 1) {
n += 1;
}
}
return n;
}
function isSettled() { return settledPlaces() >= PLACES; }
function statusText() {
return '止まった桁 {k}/5・目標 {n}'.replace('{k}', String(settledPlaces())).replace('{n}', fmtNum(toV));
}
function finalText() {
return '累計訪問者数が {n} で止まりました'.replace('{n}', fmtNum(toV));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DigitStripRoll - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DigitStripRoll.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DigitStripRoll() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
// 한 바퀴 길이(초)와 움직임 줄이기에서 몇 초 뒤의 장면을 그릴지
const LOOP_S = 3.4;
const REST_T = 3.0;
// 첫 굴림은 시작 0.25초 뒤 — 낮은 자리(오른쪽)부터 80ms 씩 늦게 출발한다
const START_S = 0.25;
const PLACE_DELAY_MS = 80;
// 한 자리가 굴러 멈출 때까지 걸리는 시간(초)
const ROLL_S = 1.1;
const PLACES = 5;
const VALUES = [48372, 51904, 20617, 93058];
let strips: Strip[] = [];
let naive: Strip[] = [];
let fromV = VALUES[0];
let toV = VALUES[1];
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '자리마다 굴러 멈추는 숫자띠';
strips = buildRow(stack, 'dd,ddd', 'lg', 0);
mk('span', 'cap cap--bad', stack).textContent = '되감기는 띠 (끝에서 튄다)';
naive = buildRow(stack, 'dd,ddd', 'sm', 0);
}
function reset(): void {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt: number): void {
// 이 장면은 시계(sceneT)만으로 그려서 매 프레임 따로 쌓는 값이 없다
void dt;
}
// 자리 k(0 이 일의 자리)의 굴림 진행률 0 부터 1 — 낮은 자리부터 PLACE_DELAY_MS 씩 늦게 출발
function placeU(k: number): number {
return (sceneT - START_S - k * PLACE_DELAY_MS / 1000) / ROLL_S;
}
function draw(): void {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 한 바퀴(10칸)를 더 돌고 목표 숫자에 멈춘다
const adv = 10 + mod(b[i] - a[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], speed / BLUR_REF * 0.9);
// 되감기는 쪽 — 숫자 값을 그대로 띠 위치로 쓰니 작은 수로 가면 거꾸로 훑고 간다
const posBad = lerp(a[i], b[i], easeOut3(u));
moveStrip(naive[i], posBad, 0);
glowStrip(naive[i], u > 0 && u < 1 && b[i] < a[i] ? 0.8 : 0);
}
}
function settledPlaces(): number {
let n = 0;
for (let k = 0; k < PLACES; k += 1) { if (placeU(k) >= 1) { n += 1; } }
return n;
}
function isSettled(): boolean { return settledPlaces() >= PLACES; }
function statusText(): string {
return '멈춘 자리 {k}/5 · 목표 {n}'.replace('{k}', String(settledPlaces())).replace('{n}', fmtNum(toV));
}
function finalText(): string {
return '누적 방문자 수가 {n} 으로 멈췄습니다'.replace('{n}', fmtNum(toV));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'다섯 자리 숫자띠가 낮은 자리부터 80ms 씩 늦게 출발해 오른쪽 자리가 먼저 멈추고 왼쪽이 뒤따라 멈추며, 아래 작은 줄은 띠 끝에서 거꾸로 되감기며 멈추는 잘못된 방식을 보여 준다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}01 · 桁ごとの数字の帯が転がって止まる計数器 — 計測 累積面積 13.194% · 動いたフレーム 13/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function placeU(k) {
return (sceneT - START_S - k * PLACE_DELAY_MS / 1000) / ROLL_S;
}
function draw() {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 一周(10マス)多く回って目標の数字に止まる
const adv = 10 + mod(b[i] - a[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], speed / BLUR_REF * 0.9);
// 巻き戻る側 — 数字の値をそのまま帯の位置に使うので、小さな数へ向かうと逆向きになぞる
const posBad = lerp(a[i], b[i], easeOut3(u));
moveStrip(naive[i], posBad, 0);
glowStrip(naive[i], u > 0 && u < 1 && b[i] < a[i] ? 0.8 : 0);
}
}
02目標を行き過ぎてばねで戻って落ち着く数字
決済完了画面の合計金額に使うと、数字が目標を少し行き過ぎてから戻って落ち着きます。update が一フレームを ceil(dt·240) 個に刻んで acc = K(goal−x) − C·v を積むので、フレームが長くてもばねが暴れません。SPRING_K 120 と SPRING_C 12 では減衰比が0.548で、行き過ぎる幅は移動距離の12.8%、最初の到達は出発の0.23秒後です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="数字の帯が目標の数字を少し行き過ぎてからばねの力で戻り、何度か震えて止まります。桁ごとの下線が残りの距離を見せ、行き過ぎたときはピンク色になります"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.6;
const REST_T = 3.3;
const START_S = 0.25;
// 자리 사이 출발 간격(초)
const PLACE_DELAY_S = 0.06;
// 스프링 — 목표와 떨어진 칸 수에 SPRING_K 를 곱한 힘으로 당기고, 속도에 SPRING_C 를 곱한 만큼 브레이크를 건다
const SPRING_K = 120;
const SPRING_C = 12;
const PLACES = 5;
const VALUES = [31260, 12480, 86350, 47910];
let springs = [];
let strips = [];
let bars = [];
let toV = VALUES[0];
// 일의 자리가 목표를 지나친 가장 큰 폭(칸)
let peakOver = 0;
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '行き過ぎて戻り落ち着く合計';
strips = buildRow(stack, 'dd,ddd', 'xl', 0);
bars = [];
for (const s of strips) {
bars.push(mk('span', 'dg__bar', s.win));
}
}
function reset() {
toV = VALUES[cycle % VALUES.length];
const prev = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
const a = digitsOf(prev, PLACES);
const b = digitsOf(toV, PLACES);
springs = [];
peakOver = 0;
for (let i = 0; i < PLACES; i += 1) {
// 4 칸 이상은 반드시 굴러 간다 — 같은 숫자여도 눈에 보이게
const dist = 4 + mod(b[i] - a[i] - 4, 10);
springs.push({ x: a[i], v: 0, start: START_S + (PLACES - 1 - i) * PLACE_DELAY_S, goal: a[i] + dist });
}
}
function update(dt) {
// 240Hz 로 잘게 나눠 쌓는다 — 프레임이 길어도 스프링이 터지지 않는다
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < springs.length; i += 1) {
const sp = springs[i];
if (sceneT < sp.start) {
continue;
}
for (let j = 0; j < n; j += 1) {
const acc = SPRING_K * (sp.goal - sp.x) - SPRING_C * sp.v;
sp.v += acc * h;
sp.x += sp.v * h;
}
}
const one = springs[PLACES - 1];
if (sceneT >= one.start) {
peakOver = Math.max(peakOver, one.x - one.goal);
}
}
function draw() {
for (let i = 0; i < PLACES; i += 1) {
const sp = springs[i];
moveStrip(strips[i], sp.x, sp.v);
glowStrip(strips[i], Math.abs(sp.v) / BLUR_REF * 2);
// 남은 거리를 밑줄 길이로 — 지나쳤을 때는 분홍, 모자랄 때는 하늘색
const gap = sp.goal - sp.x;
bars[i].style.transform = 'scaleX(' + clamp(Math.abs(gap) / 4, 0, 1).toFixed(3) + ')';
bars[i].classList.toggle(cls('is-over'), gap < 0);
}
}
function isSettled() {
if (sceneT < START_S + PLACES * PLACE_DELAY_S + 0.3) {
return false;
}
for (const sp of springs) {
if (Math.abs(sp.goal - sp.x) > 0.01 || Math.abs(sp.v) > 0.05) {
return false;
}
}
return true;
}
function statusText() {
return '目標 {n}・行き過ぎた幅 {o}マス'.replace('{n}', fmtNum(toV)).replace('{o}', peakOver.toFixed(1));
}
function finalText() {
return '合計が {n} に落ち着きました'.replace('{n}', fmtNum(toV));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SpringOvershootSettle - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SpringOvershootSettle.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SpringOvershootSettle() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.6;
const REST_T = 3.3;
const START_S = 0.25;
// 자리 사이 출발 간격(초)
const PLACE_DELAY_S = 0.06;
// 스프링 — 목표와 떨어진 칸 수에 SPRING_K 를 곱한 힘으로 당기고, 속도에 SPRING_C 를 곱한 만큼 브레이크를 건다
const SPRING_K = 120;
const SPRING_C = 12;
const PLACES = 5;
const VALUES = [31260, 12480, 86350, 47910];
interface Spring { x: number; v: number; start: number; goal: number; }
let springs: Spring[] = [];
let strips: Strip[] = [];
let bars: HTMLElement[] = [];
let toV = VALUES[0];
// 일의 자리가 목표를 지나친 가장 큰 폭(칸)
let peakOver = 0;
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '지나쳤다 돌아와 앉는 합계';
strips = buildRow(stack, 'dd,ddd', 'xl', 0);
bars = [];
for (const s of strips) { bars.push(mk('span', 'dg__bar', s.win)); }
}
function reset(): void {
toV = VALUES[cycle % VALUES.length];
const prev = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
const a = digitsOf(prev, PLACES);
const b = digitsOf(toV, PLACES);
springs = [];
peakOver = 0;
for (let i = 0; i < PLACES; i += 1) {
// 4 칸 이상은 반드시 굴러 간다 — 같은 숫자여도 눈에 보이게
const dist = 4 + mod(b[i] - a[i] - 4, 10);
springs.push({ x: a[i], v: 0, start: START_S + (PLACES - 1 - i) * PLACE_DELAY_S, goal: a[i] + dist });
}
}
function update(dt: number): void {
// 240Hz 로 잘게 나눠 쌓는다 — 프레임이 길어도 스프링이 터지지 않는다
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < springs.length; i += 1) {
const sp = springs[i];
if (sceneT < sp.start) { continue; }
for (let j = 0; j < n; j += 1) {
const acc = SPRING_K * (sp.goal - sp.x) - SPRING_C * sp.v;
sp.v += acc * h;
sp.x += sp.v * h;
}
}
const one = springs[PLACES - 1];
if (sceneT >= one.start) { peakOver = Math.max(peakOver, one.x - one.goal); }
}
function draw(): void {
for (let i = 0; i < PLACES; i += 1) {
const sp = springs[i];
moveStrip(strips[i], sp.x, sp.v);
glowStrip(strips[i], Math.abs(sp.v) / BLUR_REF * 2);
// 남은 거리를 밑줄 길이로 — 지나쳤을 때는 분홍, 모자랄 때는 하늘색
const gap = sp.goal - sp.x;
bars[i].style.transform = 'scaleX(' + clamp(Math.abs(gap) / 4, 0, 1).toFixed(3) + ')';
bars[i].classList.toggle(cls('is-over'), gap < 0);
}
}
function isSettled(): boolean {
if (sceneT < START_S + PLACES * PLACE_DELAY_S + 0.3) { return false; }
for (const sp of springs) { if (Math.abs(sp.goal - sp.x) > 0.01 || Math.abs(sp.v) > 0.05) { return false; } }
return true;
}
function statusText(): string {
return '목표 {n} · 지나친 폭 {o}칸'.replace('{n}', fmtNum(toV)).replace('{o}', peakOver.toFixed(1));
}
function finalText(): string {
return '합계가 {n} 으로 자리를 잡았습니다'.replace('{n}', fmtNum(toV));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'숫자띠가 목표 숫자를 조금 지나쳤다가 스프링 힘으로 돌아와 몇 번 떨고 멈추며, 자리마다 밑줄이 남은 거리를 보여 주고 지나쳤을 때는 분홍색이 된다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}02 · 目標を行き過ぎてばねで戻って落ち着く数字 — 計測 累積面積 15.678% · 動いたフレーム 14/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function update(dt) {
// 240Hz に細かく刻んで積む — フレームが長くてもばねが暴れない
const n = Math.max(1, Math.ceil(dt * 240));
const h = dt / n;
for (let i = 0; i < springs.length; i += 1) {
const sp = springs[i];
if (sceneT < sp.start) {
continue;
}
for (let j = 0; j < n; j += 1) {
const acc = SPRING_K * (sp.goal - sp.x) - SPRING_C * sp.v;
sp.v += acc * h;
sp.x += sp.v * h;
}
}
const one = springs[PLACES - 1];
if (sceneT >= one.start) {
peakOver = Math.max(peakOver, one.x - one.goal);
}
}
03速いとにじみ遅くなるとくっきりするリール
イベント抽選の番号発表画面では、回っている間は読めなかった数字が止まるときにくっきりします。moveStrip は速さを BLUR_REF 60 で割った割合 sp で本体の帯の不透明度を 1 − 0.5·sp まで下げ、尾の帯二枚を sp·BLUR_LAG·g マスうしろに敷いてにじみを作ります。リールは五つで、左から1.2秒を起点に STOP_STEP 0.24秒ずつ遅れて止まり、転がすマスの数は reelDist が 30 + 6i に0〜9マスを足して30〜63マスの範囲で決めます。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="五つのリールは速く回る間、速さに比例して縦ににじんで薄くなり、左から順に遅くなってくっきりした抽選番号で止まります。止まる瞬間には灯りがつきます"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.2;
const REST_T = 3.0;
const START_S = 0.2;
const REELS = 5;
// 릴이 멈추는 시간 — 왼쪽 릴이 가장 먼저 STOP_BASE 초 뒤, 오른쪽으로 갈수록 STOP_STEP 초씩 늦게
const STOP_BASE = 1.0;
const STOP_STEP = 0.24;
const VALUES = [30729, 85416, 12963, 70508];
let strips = [];
let fromV = VALUES[0];
let toV = VALUES[1];
let topSpeed = 0;
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '速いとにじみ止まるとくっきりする抽選番号';
// 꼬리 띠 두 장을 더 둔다 — 속도에 따라 번짐이 된다
strips = buildRow(stack, 'ddddd', 'lg', 2);
}
function reset() {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
topSpeed = 0;
}
function update(dt) {
void dt;
}
function reelDur(i) { return STOP_BASE + STOP_STEP * i; }
// 릴 i 의 총 이동 칸 수 — 오른쪽일수록 더 많이 돌고, 마지막은 목표 숫자에 정확히 닿도록 10 으로 나눈 나머지를 맞춘다
function reelDist(i, a, b) {
const base = 30 + 6 * i;
return base + mod(b - a - base, 10);
}
function draw() {
const a = digitsOf(fromV, REELS);
const b = digitsOf(toV, REELS);
let fast = 0;
for (let i = 0; i < REELS; i += 1) {
const u = clamp((sceneT - START_S) / reelDur(i), 0, 1);
const dist = reelDist(i, a[i], b[i]);
const pos = a[i] + dist * easeOut3(u);
// 속도 = 위치의 미분 — 처음이 가장 빠르고 멈출 때 0
const speed = u > 0 && u < 1 ? dist * 3 * (1 - u) * (1 - u) / reelDur(i) : 0;
moveStrip(strips[i], pos, speed);
// 멈추는 순간 켜진 옅은 하늘·라일락 불빛이 0.35초 동안 꺼진다
const sinceStop = sceneT - START_S - reelDur(i);
glowStrip(strips[i], sinceStop >= 0 ? 1 - sinceStop / 0.35 : 0);
if (speed > fast) {
fast = speed;
}
}
topSpeed = fast;
}
function stoppedReels() {
let n = 0;
for (let i = 0; i < REELS; i += 1) {
if (sceneT - START_S >= reelDur(i)) {
n += 1;
}
}
return n;
}
function isSettled() { return stoppedReels() >= REELS; }
function statusText() {
return 'いちばん速いリール {v}マス/秒・止まったリール {k}/5'.replace('{v}', String(Math.round(topSpeed))).replace('{k}', String(stoppedReels()));
}
function finalText() {
return '抽選番号は {n} です'.replace('{n}', String(toV).padStart(5, '0'));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// ReelSpeedBlur - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./ReelSpeedBlur.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function ReelSpeedBlur() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.2;
const REST_T = 3.0;
const START_S = 0.2;
const REELS = 5;
// 릴이 멈추는 시간 — 왼쪽 릴이 가장 먼저 STOP_BASE 초 뒤, 오른쪽으로 갈수록 STOP_STEP 초씩 늦게
const STOP_BASE = 1.0;
const STOP_STEP = 0.24;
const VALUES = [30729, 85416, 12963, 70508];
let strips: Strip[] = [];
let fromV = VALUES[0];
let toV = VALUES[1];
let topSpeed = 0;
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '빠르면 번지고 멈추면 또렷한 추첨 번호';
// 꼬리 띠 두 장을 더 둔다 — 속도에 따라 번짐이 된다
strips = buildRow(stack, 'ddddd', 'lg', 2);
}
function reset(): void {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
topSpeed = 0;
}
function update(dt: number): void {
void dt;
}
function reelDur(i: number): number { return STOP_BASE + STOP_STEP * i; }
// 릴 i 의 총 이동 칸 수 — 오른쪽일수록 더 많이 돌고, 마지막은 목표 숫자에 정확히 닿도록 10 으로 나눈 나머지를 맞춘다
function reelDist(i: number, a: number, b: number): number {
const base = 30 + 6 * i;
return base + mod(b - a - base, 10);
}
function draw(): void {
const a = digitsOf(fromV, REELS);
const b = digitsOf(toV, REELS);
let fast = 0;
for (let i = 0; i < REELS; i += 1) {
const u = clamp((sceneT - START_S) / reelDur(i), 0, 1);
const dist = reelDist(i, a[i], b[i]);
const pos = a[i] + dist * easeOut3(u);
// 속도 = 위치의 미분 — 처음이 가장 빠르고 멈출 때 0
const speed = u > 0 && u < 1 ? dist * 3 * (1 - u) * (1 - u) / reelDur(i) : 0;
moveStrip(strips[i], pos, speed);
// 멈추는 순간 켜진 옅은 하늘·라일락 불빛이 0.35초 동안 꺼진다
const sinceStop = sceneT - START_S - reelDur(i);
glowStrip(strips[i], sinceStop >= 0 ? 1 - sinceStop / 0.35 : 0);
if (speed > fast) { fast = speed; }
}
topSpeed = fast;
}
function stoppedReels(): number {
let n = 0;
for (let i = 0; i < REELS; i += 1) { if (sceneT - START_S >= reelDur(i)) { n += 1; } }
return n;
}
function isSettled(): boolean { return stoppedReels() >= REELS; }
function statusText(): string {
return '가장 빠른 릴 {v}칸/초 · 멈춘 릴 {k}/5'.replace('{v}', String(Math.round(topSpeed))).replace('{k}', String(stoppedReels()));
}
function finalText(): string {
return '추첨 번호는 {n} 입니다'.replace('{n}', String(toV).padStart(5, '0'));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'다섯 개 릴이 빠르게 돌 때는 속도에 비례해 세로로 번지고 옅어지다가 왼쪽부터 차례로 느려지며 또렷한 추첨 번호 숫자에 멈추고 멈추는 순간 불빛이 켜진다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}03 · 速いとにじみ遅くなるとくっきりするリール — 計測 累積面積 12.345% · 動いたフレーム 21/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
// pos = いま帯が押し上げられたマス数(小数)、speed = マス/秒(向き込み)。尾の帯があれば速さに比例してにじむ
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
04桁上がりが上の桁へ続く連鎖の計数器
リアルタイムの接続者数カウンターでは、999が1000になるとき四つの桁が時間差で続けて上がります。placePos(k, lag) は開始値のその桁の商に、値が上がってその桁が変わったティックごとに一マスを足し、桁 k の出発を k·lag だけ遅らせます。上の行は CARRY_LAG_S 0.07秒なので、一・十・百・千の位が1.08・1.15・1.22・1.29秒に出発します。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="上の計数器は、9から0へ変わるとき上の桁が時間差で一つずつ続けて上がります。下の計数器は上の桁の桁上がりが別々に遅れて届き、しばらく値が違って見えます"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.6;
const REST_T = 3.3;
const PLACES = 5;
// 첫 한 칸은 0.3초 뒤, 이후 TICK_S 초마다 값이 하나씩 오른다 — 모두 TICKS 번
const FIRST_S = 0.3;
const TICK_S = 0.26;
const TICKS = 7;
// 한 칸 굴러 오르는 시간
const ROLL_S = 0.3;
// 자리가 한 칸 위로 올라갈 때마다 출발이 이만큼(초) 더 늦다 — 올림 연쇄는 CARRY_LAG_S, 잘못된 쪽은 윗자리마다 LATE_S
const CARRY_LAG_S = 0.07;
const LATE_S = 0.3;
const STARTS = [996, 9995, 1997];
let carry = [];
let late = [];
let v0 = STARTS[0];
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '桁上がりが上の桁へ続く計数器';
carry = buildRow(stack, 'ddddd', 'lg', 0);
mk('span', 'cap cap--bad', stack).textContent = '桁上がりが別々に遅れて届く計数器';
late = buildRow(stack, 'ddddd', 'sm', 0);
}
function reset() {
v0 = STARTS[cycle % STARTS.length];
}
function update(dt) {
void dt;
}
function tenPow(k) { return Math.pow(10, k); }
// 자리 k 의 굴러 오른 칸 수 — 시작 값의 그 자리 몫에, 값이 올라 그 자리가 바뀐 틱마다 한 칸(올림 순서대로 지연)을 더한다
function placePos(k, lag) {
let pos = Math.floor(v0 / tenPow(k));
for (let j = 1; j <= TICKS; j += 1) {
if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
const s = FIRST_S + (j - 1) * TICK_S + k * lag;
pos += easeOut3((sceneT - s) / ROLL_S);
}
}
return pos;
}
function placeSpeed(k, lag) {
let sp = 0;
for (let j = 1; j <= TICKS; j += 1) {
if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
const u = (sceneT - (FIRST_S + (j - 1) * TICK_S + k * lag)) / ROLL_S;
if (u > 0 && u < 1) {
sp += 3 * (1 - u) * (1 - u) / ROLL_S;
}
}
}
return sp;
}
function ticksDone() {
return clamp(Math.floor((sceneT - FIRST_S) / TICK_S) + 1, 0, TICKS);
}
function draw() {
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
moveStrip(carry[i], placePos(k, CARRY_LAG_S), 0);
glowStrip(carry[i], placeSpeed(k, CARRY_LAG_S) * 0.5);
moveStrip(late[i], placePos(k, LATE_S), 0);
glowStrip(late[i], placeSpeed(k, LATE_S) * 0.5);
}
}
function isSettled() { return sceneT >= 3.0; }
// 방금 오른 값에서 같이 넘어간 자리 수(일의 자리 포함)
function chainOf(n) {
let c = 0;
for (let k = 0; k < PLACES; k += 1) {
if (Math.floor(n / tenPow(k)) !== Math.floor((n - 1) / tenPow(k))) {
c += 1;
}
}
return c;
}
function statusText() {
const d = ticksDone();
const n = v0 + d;
return 'いま {n}・今回の桁上がり {c}桁'.replace('{n}', fmtNum(n)).replace('{c}', String(d > 0 ? chainOf(n) : 0));
}
function finalText() {
return '{a} から {b} まで上がりました'.replace('{a}', fmtNum(v0)).replace('{b}', fmtNum(v0 + TICKS));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// CarryCascade - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./CarryCascade.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function CarryCascade() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.6;
const REST_T = 3.3;
const PLACES = 5;
// 첫 한 칸은 0.3초 뒤, 이후 TICK_S 초마다 값이 하나씩 오른다 — 모두 TICKS 번
const FIRST_S = 0.3;
const TICK_S = 0.26;
const TICKS = 7;
// 한 칸 굴러 오르는 시간
const ROLL_S = 0.3;
// 자리가 한 칸 위로 올라갈 때마다 출발이 이만큼(초) 더 늦다 — 올림 연쇄는 CARRY_LAG_S, 잘못된 쪽은 윗자리마다 LATE_S
const CARRY_LAG_S = 0.07;
const LATE_S = 0.3;
const STARTS = [996, 9995, 1997];
let carry: Strip[] = [];
let late: Strip[] = [];
let v0 = STARTS[0];
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '올림이 윗자리로 이어지는 계수기';
carry = buildRow(stack, 'ddddd', 'lg', 0);
mk('span', 'cap cap--bad', stack).textContent = '올림이 따로 늦게 도착하는 계수기';
late = buildRow(stack, 'ddddd', 'sm', 0);
}
function reset(): void {
v0 = STARTS[cycle % STARTS.length];
}
function update(dt: number): void {
void dt;
}
function tenPow(k: number): number { return Math.pow(10, k); }
// 자리 k 의 굴러 오른 칸 수 — 시작 값의 그 자리 몫에, 값이 올라 그 자리가 바뀐 틱마다 한 칸(올림 순서대로 지연)을 더한다
function placePos(k: number, lag: number): number {
let pos = Math.floor(v0 / tenPow(k));
for (let j = 1; j <= TICKS; j += 1) {
if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
const s = FIRST_S + (j - 1) * TICK_S + k * lag;
pos += easeOut3((sceneT - s) / ROLL_S);
}
}
return pos;
}
function placeSpeed(k: number, lag: number): number {
let sp = 0;
for (let j = 1; j <= TICKS; j += 1) {
if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
const u = (sceneT - (FIRST_S + (j - 1) * TICK_S + k * lag)) / ROLL_S;
if (u > 0 && u < 1) { sp += 3 * (1 - u) * (1 - u) / ROLL_S; }
}
}
return sp;
}
function ticksDone(): number {
return clamp(Math.floor((sceneT - FIRST_S) / TICK_S) + 1, 0, TICKS);
}
function draw(): void {
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
moveStrip(carry[i], placePos(k, CARRY_LAG_S), 0);
glowStrip(carry[i], placeSpeed(k, CARRY_LAG_S) * 0.5);
moveStrip(late[i], placePos(k, LATE_S), 0);
glowStrip(late[i], placeSpeed(k, LATE_S) * 0.5);
}
}
function isSettled(): boolean { return sceneT >= 3.0; }
// 방금 오른 값에서 같이 넘어간 자리 수(일의 자리 포함)
function chainOf(n: number): number {
let c = 0;
for (let k = 0; k < PLACES; k += 1) {
if (Math.floor(n / tenPow(k)) !== Math.floor((n - 1) / tenPow(k))) { c += 1; }
}
return c;
}
function statusText(): string {
const d = ticksDone();
const n = v0 + d;
return '지금 {n} · 이번 올림 {c}자리'.replace('{n}', fmtNum(n)).replace('{c}', String(d > 0 ? chainOf(n) : 0));
}
function finalText(): string {
return '{a} 에서 {b} 까지 올라갔습니다'.replace('{a}', fmtNum(v0)).replace('{b}', fmtNum(v0 + TICKS));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'위 계수기는 9에서 0으로 넘어갈 때 윗자리가 시간차로 한 칸씩 이어 올라가고, 아래 계수기는 윗자리 올림이 따로 늦게 도착해 한동안 값이 틀리게 보인다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}04 · 桁上がりが上の桁へ続く連鎖の計数器 — 計測 累積面積 11.077% · 動いたフレーム 18/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function tenPow(k) { return Math.pow(10, k); }
// 桁 k の転がり上がったマス数 — 開始値のその桁の商に、値が上がってその桁が変わったティックごとに一マス(桁上がりの順に遅れ)を足す
function placePos(k, lag) {
let pos = Math.floor(v0 / tenPow(k));
for (let j = 1; j <= TICKS; j += 1) {
if (Math.floor((v0 + j) / tenPow(k)) !== Math.floor((v0 + j - 1) / tenPow(k))) {
const s = FIRST_S + (j - 1) * TICK_S + k * lag;
pos += easeOut3((sceneT - s) / ROLL_S);
}
}
return pos;
}
05上がれば上へ下がれば下へ転がり色が光る値
為替・在庫数の電光掲示板では、値が上がると帯が上へ、下がると下へ転がるので、向きが先に目に入ります。goesUp() が真なら mod(b−a, 10) マスを前へ、偽なら −mod(a−b, 10) マスをうしろへ転がし、板に is-up(ミント)と is-down(ピンク)を切り替えてつけます。光の不透明度は 0.9·exp(−(t−0.2)/0.35) でしずまります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="相場の数字が上がると帯が上へ転がってパネル全体がミント色に光ってしずまり、下がると下へ転がってピンク色に光ります"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 1.7;
const REST_T = 1.5;
const START_S = 0.2;
// 자리 사이 출발 간격과 한 자리가 굴러 멈추는 시간(초)
const STAGGER_S = 0.04;
const ROLL_S = 0.7;
// 번쩍임 감쇠 시상수(초) — 변화가 시작되고 이 시간이 지나면 처음 밝기의 1/e 로 줄어든다
const FLASH_TAU = 0.35;
const PLACES = 5;
// 센트 단위 정수 — 13356 은 133.56
const VALUES = [13356, 13274, 13319, 13292];
let strips = [];
let panel = scene;
let flash = scene;
let fromV = VALUES[3];
let toV = VALUES[0];
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '上がれば上へ下がれば下へ転がって光る相場';
panel = mk('div', 'panel', stack);
strips = buildRow(panel, 'ddd.dd', 'lg', 0);
// 번쩍임 층은 숫자 위에 얹는다 — 옅은 색이라 글자는 그대로 읽힌다
flash = mk('span', 'panel__flash', panel);
}
function reset() {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt) {
void dt;
}
function goesUp() { return toV > fromV; }
function fixed2(v) { return (v / 100).toFixed(2); }
function placeU(k) { return (sceneT - START_S - k * STAGGER_S) / ROLL_S; }
function draw() {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
const up = goesUp();
panel.classList.toggle(cls('is-up'), up);
panel.classList.toggle(cls('is-down'), !up);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 올랐으면 앞으로(위로), 내렸으면 뒤로(아래로) 가장 짧은 방향이 아니라 값의 방향대로 굴린다
const adv = up ? mod(b[i] - a[i], 10) : -mod(a[i] - b[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], 0);
}
const since = sceneT - START_S;
flash.style.opacity = (since >= 0 ? 0.9 * Math.exp(-since / FLASH_TAU) : 0).toFixed(3);
}
function isSettled() { return sceneT - START_S >= (PLACES - 1) * STAGGER_S + ROLL_S; }
function statusText() {
const d = Math.abs(toV - fromV);
const word = goesUp() ? '上へ転がり' : '下へ転がり';
return '{a} → {b} · {w} {d}'.replace('{a}', fixed2(fromV)).replace('{b}', fixed2(toV)).replace('{w}', word).replace('{d}', fixed2(d));
}
function finalText() {
return '値が {b} に変わりました({w} {d})'.replace('{b}', fixed2(toV)).replace('{w}', goesUp() ? '上へ転がり' : '下へ転がり').replace('{d}', fixed2(Math.abs(toV - fromV)));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// PriceTickDirection - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./PriceTickDirection.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function PriceTickDirection() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 1.7;
const REST_T = 1.5;
const START_S = 0.2;
// 자리 사이 출발 간격과 한 자리가 굴러 멈추는 시간(초)
const STAGGER_S = 0.04;
const ROLL_S = 0.7;
// 번쩍임 감쇠 시상수(초) — 변화가 시작되고 이 시간이 지나면 처음 밝기의 1/e 로 줄어든다
const FLASH_TAU = 0.35;
const PLACES = 5;
// 센트 단위 정수 — 13356 은 133.56
const VALUES = [13356, 13274, 13319, 13292];
let strips: Strip[] = [];
let panel: HTMLElement = scene;
let flash: HTMLElement = scene;
let fromV = VALUES[3];
let toV = VALUES[0];
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '오르면 위로, 내리면 아래로 굴러 번쩍이는 시세';
panel = mk('div', 'panel', stack);
strips = buildRow(panel, 'ddd.dd', 'lg', 0);
// 번쩍임 층은 숫자 위에 얹는다 — 옅은 색이라 글자는 그대로 읽힌다
flash = mk('span', 'panel__flash', panel);
}
function reset(): void {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt: number): void {
void dt;
}
function goesUp(): boolean { return toV > fromV; }
function fixed2(v: number): string { return (v / 100).toFixed(2); }
function placeU(k: number): number { return (sceneT - START_S - k * STAGGER_S) / ROLL_S; }
function draw(): void {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
const up = goesUp();
panel.classList.toggle(cls('is-up'), up);
panel.classList.toggle(cls('is-down'), !up);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 올랐으면 앞으로(위로), 내렸으면 뒤로(아래로) 가장 짧은 방향이 아니라 값의 방향대로 굴린다
const adv = up ? mod(b[i] - a[i], 10) : -mod(a[i] - b[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], 0);
}
const since = sceneT - START_S;
flash.style.opacity = (since >= 0 ? 0.9 * Math.exp(-since / FLASH_TAU) : 0).toFixed(3);
}
function isSettled(): boolean { return sceneT - START_S >= (PLACES - 1) * STAGGER_S + ROLL_S; }
function statusText(): string {
const d = Math.abs(toV - fromV);
const word = goesUp() ? '위로 굴림' : '아래로 굴림';
return '{a} → {b} · {w} {d}'.replace('{a}', fixed2(fromV)).replace('{b}', fixed2(toV)).replace('{w}', word).replace('{d}', fixed2(d));
}
function finalText(): string {
return '값이 {b} 로 바뀌었습니다 ({w} {d})'.replace('{b}', fixed2(toV)).replace('{w}', goesUp() ? '위로 굴림' : '아래로 굴림').replace('{d}', fixed2(Math.abs(toV - fromV)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'시세 숫자가 오르면 띠가 위로 굴러 박하색으로, 내리면 아래로 굴러 분홍색으로 판 전체가 번쩍였다 가라앉는다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}05 · 上がれば上へ下がれば下へ転がり色が光る値 — 計測 累積面積 22.903% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function goesUp() { return toV > fromV; }
function fixed2(v) { return (v / 100).toFixed(2); }
function placeU(k) { return (sceneT - START_S - k * STAGGER_S) / ROLL_S; }
function draw() {
const a = digitsOf(fromV, PLACES);
const b = digitsOf(toV, PLACES);
const up = goesUp();
panel.classList.toggle(cls('is-up'), up);
panel.classList.toggle(cls('is-down'), !up);
for (let i = 0; i < PLACES; i += 1) {
const k = PLACES - 1 - i;
const u = clamp(placeU(k), 0, 1);
// 上がったら前へ(上へ)、下がったら後ろへ — 最短の向きではなく値の向きどおりに転がす
const adv = up ? mod(b[i] - a[i], 10) : -mod(a[i] - b[i], 10);
const pos = a[i] + adv * easeOut3(u);
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(strips[i], pos, speed);
glowStrip(strips[i], 0);
}
const since = sceneT - START_S;
flash.style.opacity = (since >= 0 ? 0.9 * Math.exp(-since / FLASH_TAU) : 0).toFixed(3);
}
06半分に折れてめくれる羽根のフリップボード
到着案内の時刻板のように、マスごとに数字が何枚もめくれてから目標に止まります。flipsOf(a, b) が 5 + mod(b−a−5, 10) で5〜14枚をめくり、一枚ごとに前半は上の羽根が rotateX(−180u°) で折れ、後半は下の羽根が90度から0度へ開きます。0618から0847へ行くとマスごとに10・12・13・9枚、合わせて44枚がめくれ、マスが止まる時刻は1.25・1.55・1.75・1.45秒で、いちばん遅いのは3番目のマスの1.75秒です。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="四マスの時刻板は、各マスが上半分と下半分の二枚に分かれ、上の一枚が手前に折れて下り、下の一枚が開いて、たくさんの数字を素早く通って到着時刻に止まります"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.4;
const REST_T = 3.0;
const START_S = 0.25;
// 한 장이 접혀 넘어가는 시간(초)과 칸 사이 출발 간격(초)
const FLIP_S = 0.1;
const CELL_DELAY_S = 0.1;
// 시각판 네 칸(시 두 자리·분 두 자리)의 목표 — 0847 은 08:47
const VALUES = [847, 1123, 2305, 618];
let cards = [];
let fromV = VALUES[3];
let toV = VALUES[0];
function half(parent, name) {
const h = mk('span', 'fl__half ' + name, parent);
mk('span', 'fl__num', h);
return h;
}
function setNum(h, d) {
const n = h.firstElementChild;
if (n && n.textContent !== String(d)) {
n.textContent = String(d);
}
}
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '半分に折れてめくれる到着時刻板';
const row = mk('span', 'odo xl', stack);
cards = [];
for (let i = 0; i < 4; i += 1) {
if (i === 2) {
mk('span', 'odo__sep', row).textContent = ':';
}
const fl = mk('span', 'fl', row);
const top = half(fl, 'fl__top');
const bot = half(fl, 'fl__bot');
// 위 장이 내려오며 아래 장에 드리우는 그림자 — 접히는 각도(sin)로 짙어졌다 사라진다
const shade = mk('span', 'fl__shade', fl);
const ft = half(fl, 'fl__ft');
const fb = half(fl, 'fl__fb');
cards.push({ top: top, bot: bot, ft: ft, fb: fb, shade: shade });
mk('span', 'fl__seam', fl);
}
}
function reset() {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt) {
void dt;
}
// 칸이 접어야 하는 장 수 — 최소 다섯 장을 넘기되, 마지막 장이 정확히 목표 숫자가 되도록 10 으로 나눈 나머지를 맞춘다(5 부터 14 장)
function flipsOf(a, b) { return 5 + mod(b - a - 5, 10); }
function elapsed(i) { return (sceneT - START_S - i * CELL_DELAY_S) / FLIP_S; }
function draw() {
const a = digitsOf(fromV, 4);
const b = digitsOf(toV, 4);
for (let i = 0; i < 4; i += 1) {
const total = flipsOf(a[i], b[i]);
const e = clamp(elapsed(i), 0, total - 0.0001);
const s = Math.floor(e);
const u = e - s;
const cur = mod(a[i] + s, 10);
const nxt = mod(a[i] + s + 1, 10);
const c = cards[i];
setNum(c.top, nxt);
setNum(c.bot, cur);
setNum(c.ft, cur);
setNum(c.fb, nxt);
// 앞 절반 동안 위 장이 앞으로 -90도까지 접혀 내려오고, 뒤 절반 동안 아래 장이 90도에서 0도로 펴진다
const front = u < 0.5;
c.ft.style.opacity = front ? (1 - u * 0.5).toFixed(3) : '0';
c.ft.style.transform = 'rotateX(' + (front ? (-180 * u).toFixed(2) : '-90') + 'deg)';
c.fb.style.opacity = front ? '0' : '1';
c.fb.style.transform = 'rotateX(' + (front ? '90' : (90 - 180 * (u - 0.5)).toFixed(2)) + 'deg)';
c.shade.style.opacity = (0.45 * Math.sin(Math.PI * u)).toFixed(3);
}
}
function foldedCount() {
let n = 0;
for (let i = 0; i < 4; i += 1) {
n += Math.min(Math.floor(Math.max(elapsed(i), 0)), flipsOf(digitsOf(fromV, 4)[i], digitsOf(toV, 4)[i]));
}
return n;
}
function totalCount() {
let n = 0;
for (let i = 0; i < 4; i += 1) {
n += flipsOf(digitsOf(fromV, 4)[i], digitsOf(toV, 4)[i]);
}
return n;
}
function isSettled() { return foldedCount() >= totalCount(); }
function clock(v) {
return String(Math.floor(v / 100)).padStart(2, '0') + ':' + String(v % 100).padStart(2, '0');
}
function statusText() {
return '到着 {t}・折れた枚数 {n}/{m}'.replace('{t}', clock(toV)).replace('{n}', String(foldedCount())).replace('{m}', String(totalCount()));
}
function finalText() {
return '到着時刻が {t} に変わりました'.replace('{t}', clock(toV));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// SplitFlapFold - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./SplitFlapFold.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function SplitFlapFold() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.4;
const REST_T = 3.0;
const START_S = 0.25;
// 한 장이 접혀 넘어가는 시간(초)과 칸 사이 출발 간격(초)
const FLIP_S = 0.1;
const CELL_DELAY_S = 0.1;
// 시각판 네 칸(시 두 자리·분 두 자리)의 목표 — 0847 은 08:47
const VALUES = [847, 1123, 2305, 618];
interface Card { top: HTMLElement; bot: HTMLElement; ft: HTMLElement; fb: HTMLElement; shade: HTMLElement; }
let cards: Card[] = [];
let fromV = VALUES[3];
let toV = VALUES[0];
function half(parent: HTMLElement, name: string): HTMLElement {
const h = mk('span', 'fl__half ' + name, parent);
mk('span', 'fl__num', h);
return h;
}
function setNum(h: HTMLElement, d: number): void {
const n = h.firstElementChild;
if (n && n.textContent !== String(d)) { n.textContent = String(d); }
}
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '반으로 접혀 넘어가는 도착 시각판';
const row = mk('span', 'odo xl', stack);
cards = [];
for (let i = 0; i < 4; i += 1) {
if (i === 2) { mk('span', 'odo__sep', row).textContent = ':'; }
const fl = mk('span', 'fl', row);
const top = half(fl, 'fl__top');
const bot = half(fl, 'fl__bot');
// 위 장이 내려오며 아래 장에 드리우는 그림자 — 접히는 각도(sin)로 짙어졌다 사라진다
const shade = mk('span', 'fl__shade', fl);
const ft = half(fl, 'fl__ft');
const fb = half(fl, 'fl__fb');
cards.push({ top: top, bot: bot, ft: ft, fb: fb, shade: shade });
mk('span', 'fl__seam', fl);
}
}
function reset(): void {
toV = VALUES[cycle % VALUES.length];
fromV = VALUES[(cycle + VALUES.length - 1) % VALUES.length];
}
function update(dt: number): void {
void dt;
}
// 칸이 접어야 하는 장 수 — 최소 다섯 장을 넘기되, 마지막 장이 정확히 목표 숫자가 되도록 10 으로 나눈 나머지를 맞춘다(5 부터 14 장)
function flipsOf(a: number, b: number): number { return 5 + mod(b - a - 5, 10); }
function elapsed(i: number): number { return (sceneT - START_S - i * CELL_DELAY_S) / FLIP_S; }
function draw(): void {
const a = digitsOf(fromV, 4);
const b = digitsOf(toV, 4);
for (let i = 0; i < 4; i += 1) {
const total = flipsOf(a[i], b[i]);
const e = clamp(elapsed(i), 0, total - 0.0001);
const s = Math.floor(e);
const u = e - s;
const cur = mod(a[i] + s, 10);
const nxt = mod(a[i] + s + 1, 10);
const c = cards[i];
setNum(c.top, nxt);
setNum(c.bot, cur);
setNum(c.ft, cur);
setNum(c.fb, nxt);
// 앞 절반 동안 위 장이 앞으로 -90도까지 접혀 내려오고, 뒤 절반 동안 아래 장이 90도에서 0도로 펴진다
const front = u < 0.5;
c.ft.style.opacity = front ? (1 - u * 0.5).toFixed(3) : '0';
c.ft.style.transform = 'rotateX(' + (front ? (-180 * u).toFixed(2) : '-90') + 'deg)';
c.fb.style.opacity = front ? '0' : '1';
c.fb.style.transform = 'rotateX(' + (front ? '90' : (90 - 180 * (u - 0.5)).toFixed(2)) + 'deg)';
c.shade.style.opacity = (0.45 * Math.sin(Math.PI * u)).toFixed(3);
}
}
function foldedCount(): number {
let n = 0;
for (let i = 0; i < 4; i += 1) {
n += Math.min(Math.floor(Math.max(elapsed(i), 0)), flipsOf(digitsOf(fromV, 4)[i], digitsOf(toV, 4)[i]));
}
return n;
}
function totalCount(): number {
let n = 0;
for (let i = 0; i < 4; i += 1) { n += flipsOf(digitsOf(fromV, 4)[i], digitsOf(toV, 4)[i]); }
return n;
}
function isSettled(): boolean { return foldedCount() >= totalCount(); }
function clock(v: number): string {
return String(Math.floor(v / 100)).padStart(2, '0') + ':' + String(v % 100).padStart(2, '0');
}
function statusText(): string {
return '도착 {t} · 접힌 장 {n}/{m}'.replace('{t}', clock(toV)).replace('{n}', String(foldedCount())).replace('{m}', String(totalCount()));
}
function finalText(): string {
return '도착 시각이 {t} 로 바뀌었습니다'.replace('{t}', clock(toV));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'네 칸 시각판의 각 칸이 위 절반과 아래 절반 두 장으로 나뉘어 위 장이 앞으로 접혀 내려오고 아래 장이 펴지며 빠르게 여러 숫자를 거쳐 도착 시각에 선다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}06 · 半分に折れてめくれる羽根のフリップボード — 計測 累積面積 10.572% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function draw() {
const a = digitsOf(fromV, 4);
const b = digitsOf(toV, 4);
for (let i = 0; i < 4; i += 1) {
const total = flipsOf(a[i], b[i]);
const e = clamp(elapsed(i), 0, total - 0.0001);
const s = Math.floor(e);
const u = e - s;
const cur = mod(a[i] + s, 10);
const nxt = mod(a[i] + s + 1, 10);
const c = cards[i];
setNum(c.top, nxt);
setNum(c.bot, cur);
setNum(c.ft, cur);
setNum(c.fb, nxt);
// 前半は上の羽根が前へ-90度まで折れて下り、後半は下の羽根が90度から0度へ開く
const front = u < 0.5;
c.ft.style.opacity = front ? (1 - u * 0.5).toFixed(3) : '0';
c.ft.style.transform = 'rotateX(' + (front ? (-180 * u).toFixed(2) : '-90') + 'deg)';
c.fb.style.opacity = front ? '0' : '1';
c.fb.style.transform = 'rotateX(' + (front ? '90' : (90 - 180 * (u - 0.5)).toFixed(2)) + 'deg)';
c.shade.style.opacity = (0.45 * Math.sin(Math.PI * u)).toFixed(3);
}
}
07丸いドラムの遠近でつぶれて見える数字
時・分の選択ホイールでは、円柱のまわりに置いた数字が角度でつぶれ、真ん中の一つだけが大きく明るく見えます。マスごとに θ = (idx−pos)·STEP_DEG 30度を求め、translateY に R·sinθ、scale に (0.7+0.3cosθ, cosθ)、不透明度に cos²θ を与え、cosθ が0.05以下の裏側のマスは描きません。5時52分から7時25分へ行くと、時のドラムは14マスを1.55秒に、分のドラムは33マスを1.95秒に止まります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="時と分の二つの円柱ドラムが回り、まん中の数字だけが大きくくっきりして、上下の数字は遠近でつぶれて薄くなり、一つずつ読めます"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.4;
const REST_T = 3.0;
const START_S = 0.25;
// 드럼 한 칸이 원통에서 차지하는 각도(도) — 12 칸이면 한 바퀴(시 바퀴는 열두 칸). 보이는 쪽은 앞면 ±90도 안쪽뿐
const STEP_DEG = 30;
const STEP = STEP_DEG * Math.PI / 180;
// 원통 반지름 — 바퀴 높이에 대한 비율. 각도 θ 의 칸은 가운데에서 반지름×sin θ 만큼 떨어지고 높이는 cos θ 배로 눌린다
const RADIUS = 0.42;
// 바퀴 칸 높이 비율(바퀴 높이 대비 %) — 이 높이 상자를 가운데에 두고 각도만큼 옮긴다
const ITEM_H = 16;
const POOL = 9;
const HOURS = [7, 10, 2, 5];
const MINUTES = [25, 48, 3, 52];
let wheelH;
let wheelM;
let fromH = HOURS[3];
let toH = HOURS[0];
let fromM = MINUTES[3];
let toM = MINUTES[0];
function makeWheel(parent, n, offset) {
const box = mk('span', 'dr', parent);
mk('span', 'dr__band', box);
const items = [];
for (let i = 0; i < POOL; i += 1) {
items.push(mk('span', 'dr__item', box));
}
// 칸 사이 홈 — 원통을 감싼 가는 띠. 칸과 같은 각도 식으로 반 칸 어긋나게 놓여 같이 돈다
const grooves = [];
for (let i = 0; i < POOL; i += 1) {
grooves.push(mk('span', 'dr__groove', box));
}
return { box: box, items: items, grooves: grooves, n: n, offset: offset };
}
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '円柱ドラムの上でつぶれて見える時間選び';
const row = mk('span', 'odo lg', stack);
wheelH = makeWheel(row, 12, 1);
mk('span', 'odo__sep', row).textContent = ':';
wheelM = makeWheel(row, 60, 0);
}
function reset() {
toH = HOURS[cycle % HOURS.length];
toM = MINUTES[cycle % MINUTES.length];
fromH = HOURS[(cycle + HOURS.length - 1) % HOURS.length];
fromM = MINUTES[(cycle + MINUTES.length - 1) % MINUTES.length];
}
function update(dt) {
void dt;
}
// 바퀴가 돌아간 칸 수 — 분 바퀴는 12~71 칸, 시 바퀴는 12~23 칸을 돌고 목표 칸에 멈춘다(처음이 가장 빠르다)
const HOUR_S = 1.3;
const MINUTE_S = 1.7;
function distOf(a, b, n, base) { return base + mod(b - a - base, n); }
function posH() {
const u = (sceneT - START_S) / HOUR_S;
return (fromH - 1) + distOf(fromH - 1, toH - 1, 12, 12) * easeOut3(u);
}
function posM() {
const u = (sceneT - START_S) / MINUTE_S;
return fromM + distOf(fromM, toM, 60, 12) * easeOut3(u);
}
function speedM() {
const u = (sceneT - START_S) / MINUTE_S;
return u > 0 && u < 1 ? distOf(fromM, toM, 60, 12) * 3 * (1 - u) * (1 - u) / MINUTE_S : 0;
}
function drawWheel(w, pos, fade) {
const mid = Math.round(pos);
const half = (POOL - 1) / 2;
for (let j = -half; j <= half; j += 1) {
const idx = mid + j;
const th = (idx - pos) * STEP;
// 홈은 칸보다 반 칸 아래(+0.5)에 놓는다
const gth = (idx - pos + 0.5) * STEP;
const gc = Math.cos(gth);
const g = w.grooves[j + half];
if (gc <= 0.05) {
g.style.opacity = '0';
}
else {
g.style.top = (50 + RADIUS * Math.sin(gth) * 100).toFixed(2) + '%';
g.style.transform = 'scaleX(' + (0.72 + 0.28 * gc).toFixed(3) + ')';
g.style.opacity = (0.5 * gc * gc).toFixed(3);
}
const e = w.items[j + half];
const c = Math.cos(th);
if (c <= 0.05) {
e.style.opacity = '0';
continue;
}
const text = String(mod(idx, w.n) + w.offset).padStart(2, '0');
if (e.textContent !== text) {
e.textContent = text;
}
// 높이는 cos θ 배로 눌리고 폭도 조금 줄며, 흐려지는 정도는 cos θ 의 제곱에 속도 번짐을 곱한다
// 가운데에서 떨어진 거리(바퀴 높이 비율)를 칸 자신의 높이 비율(%)로 바꿔 translateY 에 준다
const y = RADIUS * Math.sin(th) * 100 / (ITEM_H / 100);
e.style.transform = 'translateY(' + y.toFixed(2) + '%) scale(' + (0.7 + 0.3 * c).toFixed(3) + ',' + c.toFixed(3) + ')';
e.style.opacity = (c * c * fade).toFixed(3);
}
}
function draw() {
drawWheel(wheelH, posH(), 1);
drawWheel(wheelM, posM(), 1 - 0.35 * clamp(speedM() / 80, 0, 1));
}
function isSettled() { return (sceneT - START_S) / MINUTE_S >= 1; }
function two(v) { return String(v).padStart(2, '0'); }
function statusText() {
return '{h}:{m}・ホイールの速さ {v}度/秒'.replace('{h}', two(mod(Math.round(posH()), 12) + 1)).replace('{m}', two(mod(Math.round(posM()), 60))).replace('{v}', String(Math.round(speedM() * STEP_DEG)));
}
function finalText() {
return '選んだ時間は {h}時{m}分です'.replace('{h}', String(toH)).replace('{m}', String(toM));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// DrumPerspective - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./DrumPerspective.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function DrumPerspective() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.4;
const REST_T = 3.0;
const START_S = 0.25;
// 드럼 한 칸이 원통에서 차지하는 각도(도) — 12 칸이면 한 바퀴(시 바퀴는 열두 칸). 보이는 쪽은 앞면 ±90도 안쪽뿐
const STEP_DEG = 30;
const STEP = STEP_DEG * Math.PI / 180;
// 원통 반지름 — 바퀴 높이에 대한 비율. 각도 θ 의 칸은 가운데에서 반지름×sin θ 만큼 떨어지고 높이는 cos θ 배로 눌린다
const RADIUS = 0.42;
// 바퀴 칸 높이 비율(바퀴 높이 대비 %) — 이 높이 상자를 가운데에 두고 각도만큼 옮긴다
const ITEM_H = 16;
const POOL = 9;
const HOURS = [7, 10, 2, 5];
const MINUTES = [25, 48, 3, 52];
interface Wheel { box: HTMLElement; items: HTMLElement[]; grooves: HTMLElement[]; n: number; offset: number; }
let wheelH: Wheel;
let wheelM: Wheel;
let fromH = HOURS[3];
let toH = HOURS[0];
let fromM = MINUTES[3];
let toM = MINUTES[0];
function makeWheel(parent: HTMLElement, n: number, offset: number): Wheel {
const box = mk('span', 'dr', parent);
mk('span', 'dr__band', box);
const items: HTMLElement[] = [];
for (let i = 0; i < POOL; i += 1) { items.push(mk('span', 'dr__item', box)); }
// 칸 사이 홈 — 원통을 감싼 가는 띠. 칸과 같은 각도 식으로 반 칸 어긋나게 놓여 같이 돈다
const grooves: HTMLElement[] = [];
for (let i = 0; i < POOL; i += 1) { grooves.push(mk('span', 'dr__groove', box)); }
return { box: box, items: items, grooves: grooves, n: n, offset: offset };
}
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '원통 드럼 위에서 눌려 보이는 시간 선택';
const row = mk('span', 'odo lg', stack);
wheelH = makeWheel(row, 12, 1);
mk('span', 'odo__sep', row).textContent = ':';
wheelM = makeWheel(row, 60, 0);
}
function reset(): void {
toH = HOURS[cycle % HOURS.length];
toM = MINUTES[cycle % MINUTES.length];
fromH = HOURS[(cycle + HOURS.length - 1) % HOURS.length];
fromM = MINUTES[(cycle + MINUTES.length - 1) % MINUTES.length];
}
function update(dt: number): void {
void dt;
}
// 바퀴가 돌아간 칸 수 — 분 바퀴는 12~71 칸, 시 바퀴는 12~23 칸을 돌고 목표 칸에 멈춘다(처음이 가장 빠르다)
const HOUR_S = 1.3;
const MINUTE_S = 1.7;
function distOf(a: number, b: number, n: number, base: number): number { return base + mod(b - a - base, n); }
function posH(): number {
const u = (sceneT - START_S) / HOUR_S;
return (fromH - 1) + distOf(fromH - 1, toH - 1, 12, 12) * easeOut3(u);
}
function posM(): number {
const u = (sceneT - START_S) / MINUTE_S;
return fromM + distOf(fromM, toM, 60, 12) * easeOut3(u);
}
function speedM(): number {
const u = (sceneT - START_S) / MINUTE_S;
return u > 0 && u < 1 ? distOf(fromM, toM, 60, 12) * 3 * (1 - u) * (1 - u) / MINUTE_S : 0;
}
function drawWheel(w: Wheel, pos: number, fade: number): void {
const mid = Math.round(pos);
const half = (POOL - 1) / 2;
for (let j = -half; j <= half; j += 1) {
const idx = mid + j;
const th = (idx - pos) * STEP;
// 홈은 칸보다 반 칸 아래(+0.5)에 놓는다
const gth = (idx - pos + 0.5) * STEP;
const gc = Math.cos(gth);
const g = w.grooves[j + half];
if (gc <= 0.05) { g.style.opacity = '0'; } else {
g.style.top = (50 + RADIUS * Math.sin(gth) * 100).toFixed(2) + '%';
g.style.transform = 'scaleX(' + (0.72 + 0.28 * gc).toFixed(3) + ')';
g.style.opacity = (0.5 * gc * gc).toFixed(3);
}
const e = w.items[j + half];
const c = Math.cos(th);
if (c <= 0.05) { e.style.opacity = '0'; continue; }
const text = String(mod(idx, w.n) + w.offset).padStart(2, '0');
if (e.textContent !== text) { e.textContent = text; }
// 높이는 cos θ 배로 눌리고 폭도 조금 줄며, 흐려지는 정도는 cos θ 의 제곱에 속도 번짐을 곱한다
// 가운데에서 떨어진 거리(바퀴 높이 비율)를 칸 자신의 높이 비율(%)로 바꿔 translateY 에 준다
const y = RADIUS * Math.sin(th) * 100 / (ITEM_H / 100);
e.style.transform = 'translateY(' + y.toFixed(2) + '%) scale(' + (0.7 + 0.3 * c).toFixed(3) + ',' + c.toFixed(3) + ')';
e.style.opacity = (c * c * fade).toFixed(3);
}
}
function draw(): void {
drawWheel(wheelH, posH(), 1);
drawWheel(wheelM, posM(), 1 - 0.35 * clamp(speedM() / 80, 0, 1));
}
function isSettled(): boolean { return (sceneT - START_S) / MINUTE_S >= 1; }
function two(v: number): string { return String(v).padStart(2, '0'); }
function statusText(): string {
return '{h}:{m} · 바퀴 속도 {v}도/초'.replace('{h}', two(mod(Math.round(posH()), 12) + 1)).replace('{m}', two(mod(Math.round(posM()), 60))).replace('{v}', String(Math.round(speedM() * STEP_DEG)));
}
function finalText(): string {
return '선택한 시간은 {h}시 {m}분입니다'.replace('{h}', String(toH)).replace('{m}', String(toM));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'시와 분 두 개의 원통 드럼이 돌며 가운데 숫자만 크고 또렷하고 위아래 숫자는 원근으로 눌리고 흐려져 한 칸씩 읽힌다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}07 · 丸いドラムの遠近でつぶれて見える数字 — 計測 累積面積 13.901% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
const e = w.items[j + half];
const c = Math.cos(th);
if (c <= 0.05) {
e.style.opacity = '0';
continue;
}
const text = String(mod(idx, w.n) + w.offset).padStart(2, '0');
if (e.textContent !== text) {
e.textContent = text;
}
// 高さは cos θ 倍につぶれ幅も少し縮み、薄くなる度合いは cos θ の二乗に速さのにじみを掛ける
// 中心からの距離(ホイールの高さの比)を、マス自身の高さの比(%)に直して translateY に渡す
const y = RADIUS * Math.sin(th) * 100 / (ITEM_H / 100);
e.style.transform = 'translateY(' + y.toFixed(2) + '%) scale(' + (0.7 + 0.3 * c).toFixed(3) + ',' + c.toFixed(3) + ')';
e.style.opacity = (c * c * fade).toFixed(3);
08摩擦で減速して目盛りに吸いつく慣性ダイヤル
数量・年齢入力用のダイヤルのように、最初の速さをもらったあとすべって、最も近い目盛りに吸いつきます。step が毎フレーム v *= FRICTION^(dt·60) で速さを削り、SNAP_V 3 を下回ると Math.round(x) の目盛りへ 1 − e^(−14dt) の割合ずつ引かれます。速さ90マス/秒で出発すると0.68秒に引き込みが始まり、2から18マス進んだ20、つまり数字の0に約1.07秒で止まります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="最初の速さをもらったダイヤルは摩擦で次第に遅くなり、いちばん近い目盛りに引き込まれて止まります。下の小さな数字は、隠れたタブが戻った長い一コマをそのまま受けて一気に転がり、別の数字で止まります"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 3.0;
const REST_T = 2.6;
const START_S = 0.25;
// 마찰 — 60Hz 한 프레임마다 속도가 이 비율만 남는다. 프레임 길이가 달라도 같게 pow(FRICTION, dt*60) 으로 환산한다
const FRICTION = 0.92;
// 속도(칸/초)가 이 아래로 내려오면 가장 가까운 눈금으로 끌려 들어간다 · 끌려 들어가는 빠르기(1/초)
const SNAP_V = 3;
const SNAP_RATE = 14;
// 처음 건 속도(칸/초)와 시작 눈금
const FLICKS = [90, -76, 110];
const STARTS = [2, 7, 4];
// 잘못된 쪽 — 탭이 숨었다 돌아온 것처럼 한 프레임에 이만큼(초)의 시간이 한꺼번에 넘어온다
const SPIKE_S = 0.5;
const SPIKE_EXTRA = 0.4;
const TICK_CQW = 7;
const TICK_COUNT = 17;
let good = { x: 0, v: 0, snapTo: 0, flicked: false, snapping: false, spiked: false };
let bad = { x: 0, v: 0, snapTo: 0, flicked: false, snapping: false, spiked: false };
let mainStrip;
let badStrip;
let ruler;
let ticks = [];
function freshDial(x) { return { x: x, v: 0, snapTo: x, flicked: false, snapping: false, spiked: false }; }
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '摩擦で遅くなり目盛りに吸いつくダイヤル';
const row = mk('span', 'odo lg', stack);
mainStrip = makeStrip(row, 'lg', 2);
const rw = mk('span', 'ruler', stack);
ruler = mk('span', 'ruler__track', rw);
ticks = [];
for (let i = 0; i < TICK_COUNT; i += 1) {
const t = mk('span', 'ruler__tick', ruler);
t.style.left = ((i - (TICK_COUNT - 1) / 2) * TICK_CQW).toFixed(1) + 'cqw';
ticks.push(t);
}
mk('span', 'ruler__needle', rw);
mk('span', 'cap cap--bad', stack).textContent = 'タブが隠れて戻ったあと一気に転がった数字';
const row2 = mk('span', 'odo sm', stack);
badStrip = makeStrip(row2, 'sm', 0);
}
function reset() {
good = freshDial(STARTS[cycle % STARTS.length]);
bad = freshDial(STARTS[cycle % STARTS.length]);
}
// 다이얼 한 걸음 — 처음 속도를 받아 미끄러지다가 속도가 SNAP_V 아래면 가장 가까운 눈금으로 끌려 들어간다
function step(d, dt) {
if (!d.flicked && sceneT >= START_S) {
d.flicked = true;
d.v = FLICKS[cycle % FLICKS.length];
}
if (!d.flicked) {
return;
}
if (!d.snapping) {
d.x += d.v * dt;
d.v *= Math.pow(FRICTION, dt * 60);
if (Math.abs(d.v) < SNAP_V) {
d.snapping = true;
d.snapTo = Math.round(d.x);
d.v = 0;
}
}
else {
d.x += (d.snapTo - d.x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(d.snapTo - d.x) < 0.002) {
d.x = d.snapTo;
}
}
}
function update(dt) {
step(good, dt);
// 잘못된 쪽은 시간 상한 없이 한 번 크게 받는다
let dtBad = dt;
if (!bad.spiked && sceneT >= SPIKE_S) {
bad.spiked = true;
dtBad = dt + SPIKE_EXTRA;
}
step(bad, dtBad);
}
function drawRuler(x) {
const fl = Math.floor(x);
ruler.style.transform = 'translateX(' + (-(x - fl) * TICK_CQW).toFixed(3) + 'cqw)';
const mid = (TICK_COUNT - 1) / 2;
for (let i = 0; i < TICK_COUNT; i += 1) {
const at = fl + i - mid;
ticks[i].classList.toggle(cls('is-major'), mod(at, 5) === 0);
const label = String(mod(at, 10));
if (ticks[i].textContent !== label) {
ticks[i].textContent = label;
}
}
}
function draw() {
moveStrip(mainStrip, good.x, good.snapping ? 0 : good.v);
glowStrip(mainStrip, Math.abs(good.v) / BLUR_REF);
drawRuler(good.x);
moveStrip(badStrip, bad.x, 0);
glowStrip(badStrip, bad.spiked && Math.abs(bad.x - good.x) > 0.5 ? 0.9 : 0);
}
function isSettled() { return good.snapping && Math.abs(good.snapTo - good.x) < 0.002; }
function statusText() {
return '速さ {v}マス/秒・目盛り {n}'.replace('{v}', String(Math.round(Math.abs(good.v)))).replace('{n}', String(mod(Math.round(good.x), 10)));
}
function finalText() {
return 'ダイヤルが {n} で止まりました'.replace('{n}', String(mod(good.snapTo, 10)));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// FrictionDialSnap - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./FrictionDialSnap.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function FrictionDialSnap() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 3.0;
const REST_T = 2.6;
const START_S = 0.25;
// 마찰 — 60Hz 한 프레임마다 속도가 이 비율만 남는다. 프레임 길이가 달라도 같게 pow(FRICTION, dt*60) 으로 환산한다
const FRICTION = 0.92;
// 속도(칸/초)가 이 아래로 내려오면 가장 가까운 눈금으로 끌려 들어간다 · 끌려 들어가는 빠르기(1/초)
const SNAP_V = 3;
const SNAP_RATE = 14;
// 처음 건 속도(칸/초)와 시작 눈금
const FLICKS = [90, -76, 110];
const STARTS = [2, 7, 4];
// 잘못된 쪽 — 탭이 숨었다 돌아온 것처럼 한 프레임에 이만큼(초)의 시간이 한꺼번에 넘어온다
const SPIKE_S = 0.5;
const SPIKE_EXTRA = 0.4;
const TICK_CQW = 7;
const TICK_COUNT = 17;
interface Dial { x: number; v: number; snapTo: number; flicked: boolean; snapping: boolean; spiked: boolean; }
let good: Dial = { x: 0, v: 0, snapTo: 0, flicked: false, snapping: false, spiked: false };
let bad: Dial = { x: 0, v: 0, snapTo: 0, flicked: false, snapping: false, spiked: false };
let mainStrip: Strip;
let badStrip: Strip;
let ruler: HTMLElement;
let ticks: HTMLElement[] = [];
function freshDial(x: number): Dial { return { x: x, v: 0, snapTo: x, flicked: false, snapping: false, spiked: false }; }
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '마찰로 느려져 눈금에 붙는 다이얼';
const row = mk('span', 'odo lg', stack);
mainStrip = makeStrip(row, 'lg', 2);
const rw = mk('span', 'ruler', stack);
ruler = mk('span', 'ruler__track', rw);
ticks = [];
for (let i = 0; i < TICK_COUNT; i += 1) {
const t = mk('span', 'ruler__tick', ruler);
t.style.left = ((i - (TICK_COUNT - 1) / 2) * TICK_CQW).toFixed(1) + 'cqw';
ticks.push(t);
}
mk('span', 'ruler__needle', rw);
mk('span', 'cap cap--bad', stack).textContent = '탭이 숨었다 돌아온 뒤 몰려 굴러간 숫자';
const row2 = mk('span', 'odo sm', stack);
badStrip = makeStrip(row2, 'sm', 0);
}
function reset(): void {
good = freshDial(STARTS[cycle % STARTS.length]);
bad = freshDial(STARTS[cycle % STARTS.length]);
}
// 다이얼 한 걸음 — 처음 속도를 받아 미끄러지다가 속도가 SNAP_V 아래면 가장 가까운 눈금으로 끌려 들어간다
function step(d: Dial, dt: number): void {
if (!d.flicked && sceneT >= START_S) {
d.flicked = true;
d.v = FLICKS[cycle % FLICKS.length];
}
if (!d.flicked) { return; }
if (!d.snapping) {
d.x += d.v * dt;
d.v *= Math.pow(FRICTION, dt * 60);
if (Math.abs(d.v) < SNAP_V) {
d.snapping = true;
d.snapTo = Math.round(d.x);
d.v = 0;
}
} else {
d.x += (d.snapTo - d.x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(d.snapTo - d.x) < 0.002) { d.x = d.snapTo; }
}
}
function update(dt: number): void {
step(good, dt);
// 잘못된 쪽은 시간 상한 없이 한 번 크게 받는다
let dtBad = dt;
if (!bad.spiked && sceneT >= SPIKE_S) { bad.spiked = true; dtBad = dt + SPIKE_EXTRA; }
step(bad, dtBad);
}
function drawRuler(x: number): void {
const fl = Math.floor(x);
ruler.style.transform = 'translateX(' + (-(x - fl) * TICK_CQW).toFixed(3) + 'cqw)';
const mid = (TICK_COUNT - 1) / 2;
for (let i = 0; i < TICK_COUNT; i += 1) {
const at = fl + i - mid;
ticks[i].classList.toggle(cls('is-major'), mod(at, 5) === 0);
const label = String(mod(at, 10));
if (ticks[i].textContent !== label) { ticks[i].textContent = label; }
}
}
function draw(): void {
moveStrip(mainStrip, good.x, good.snapping ? 0 : good.v);
glowStrip(mainStrip, Math.abs(good.v) / BLUR_REF);
drawRuler(good.x);
moveStrip(badStrip, bad.x, 0);
glowStrip(badStrip, bad.spiked && Math.abs(bad.x - good.x) > 0.5 ? 0.9 : 0);
}
function isSettled(): boolean { return good.snapping && Math.abs(good.snapTo - good.x) < 0.002; }
function statusText(): string {
return '속도 {v}칸/초 · 눈금 {n}'.replace('{v}', String(Math.round(Math.abs(good.v)))).replace('{n}', String(mod(Math.round(good.x), 10)));
}
function finalText(): string {
return '다이얼이 {n} 에 멈췄습니다'.replace('{n}', String(mod(good.snapTo, 10)));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'처음 속도를 받은 다이얼이 마찰로 점점 느려지다 가장 가까운 눈금에 끌려 들어가 멈추고, 아래 작은 숫자는 숨은 탭이 돌아온 긴 한 프레임을 그대로 받아 몰려 굴러가 다른 숫자에 선다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}08 · 摩擦で減速して目盛りに吸いつく慣性ダイヤル — 計測 累積面積 6.247% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function step(d, dt) {
if (!d.flicked && sceneT >= START_S) {
d.flicked = true;
d.v = FLICKS[cycle % FLICKS.length];
}
if (!d.flicked) {
return;
}
if (!d.snapping) {
d.x += d.v * dt;
d.v *= Math.pow(FRICTION, dt * 60);
if (Math.abs(d.v) < SNAP_V) {
d.snapping = true;
d.snapTo = Math.round(d.x);
d.v = 0;
}
}
else {
d.x += (d.snapTo - d.x) * (1 - Math.exp(-SNAP_RATE * dt));
if (Math.abs(d.snapTo - d.x) < 0.002) {
d.x = d.snapTo;
}
}
}
09統計の一行が順に転がり上がるヒーロー
サービス紹介ページの最初の画面では、訪問者・レビュー・評価が順に入ってきて桁ごとに転がり上がります。startOf(i) がカードを0.2・0.5・0.8秒に入れ、statU(i, k) が低い桁から PLACE_DELAY_S 0.05秒ずつ遅らせて転がし、各桁は0から 10 + 目標の数字 マスを転がり上がります。評価は整数48を10で割って4.8と見せ、2.03秒に全部が終わります。

<main class="stage">
<div class="vj is-demo" id="vj">
<div class="vj__scene" data-role="scene" role="img" aria-label="訪問者・レビュー・評価の三つの数値が画面に入った順に浮かび上がり、桁ごとに遅れをおいて0から満ちて、最初の一画面が数字の満ちる場面になります"></div>
<button class="vj__pad" data-role="pad" type="button" aria-label="数字の場面の舞台 — 押すか Enter、右・上の矢印キーで次の値を転がす"></button>
<p class="vj__status" data-role="status" aria-hidden="true">数字が転がる場面</p>
<p class="vj__live" data-role="live" role="status" aria-live="off"></p>
<button class="vj__pause" data-role="pause" type="button" aria-pressed="false">一時停止</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
@include stage($stage-paper);
// 숫자띠 한 줄 — 창 하나에 0~9 띠(열두 칸)를 세로로 놓고 translateY 로 밀어 올린다. 글자 크기는 무대 폭(cqw)에 비례
$ink-soft: rgba($subject-ink, .6);
$win-bg: rgba(255, 255, 255, .92);
$flap-top: mix($subject-lilac, #ffffff, 24%);
$flap-bot: mix($subject-lilac, #ffffff, 10%);
.vj {
position: relative;
width: min(92vw, calc(160vh - 12.8vw));
aspect-ratio: 480 / 300;
margin: 0 auto;
border-radius: $r-card;
overflow: hidden;
box-shadow: $shadow-raised;
background: mix($subject-cream, $subject-lilac, 82%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: $subject-ink;
text-align: center;
}
.cap--bad { color: $error; }
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: $subject-ink;
}
.odo.lg { font-size: 16.5cqw; }
.odo.xl { font-size: 19cqw; }
.odo.md { font-size: 6.4cqw; }
.odo.sm { font-size: 6.6cqw; }
.odo__sep {
padding: 0 .04em;
font-size: .7em;
color: $ink-soft;
align-self: flex-end;
margin-bottom: .1em;
}
.odo__sep--unit {
margin-left: .2em;
font-size: .5em;
color: $subject-ink;
}
.dg {
position: relative;
display: inline-block;
width: .64em;
height: 1.12em;
margin: 0 .03em;
overflow: hidden;
border-radius: $r-sm;
background: $win-bg;
box-shadow: inset 0 -.05em 0 rgba($subject-ink, .08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 calc(100% / 12);
display: flex;
align-items: center;
justify-content: center;
}
// 속도가 붙으면 옅은 하늘·라일락 빛이 올라온다 — 불투명도만 움직인다
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba($subject-sky, .5), rgba($subject-lilac, .5));
}
// 02 — 목표까지 남은 거리를 알리는 밑줄. 가운데에서 양쪽으로 늘어난다
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: $r-xs;
background: $subject-sky;
transform: scaleX(0);
}
.dg__bar.is-over { background: $subject-pink; }
// 05 — 시세 판. 오르면 박하색, 내리면 분홍색이 판 전체에 번쩍였다 가라앉는다
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: $r-card;
overflow: hidden;
background: rgba(255, 255, 255, .7);
box-shadow: $shadow-press;
}
.panel .dg { background: rgba(255, 255, 255, .55); }
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash { background: rgba($subject-mint, .55); }
.panel.is-down .panel__flash { background: rgba($subject-pink, .6); }
// 06 — 접히는 잎. 위 장은 아래 가장자리에서, 아래 장은 위 가장자리에서 돈다
.fl {
position: relative;
display: inline-block;
width: .66em;
height: 1.12em;
margin: 0 .03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: $r-sm $r-sm 0 0;
background: $flap-top;
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 $r-sm $r-sm;
background: $flap-bot;
}
.fl__ft { z-index: 2; transform-origin: 50% 100%; }
.fl__fb { z-index: 2; transform-origin: 50% 0; opacity: 0; }
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: $subject-ink;
}
.fl__top .fl__num,
.fl__ft .fl__num { top: 0; }
.fl__bot .fl__num,
.fl__fb .fl__num { bottom: 0; }
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 $r-sm $r-sm;
background: $subject-ink;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba($subject-ink, .2);
}
// 07 — 원통 드럼. 칸마다 JS 가 높이(scaleY=cos θ)·폭·불투명도를 계산해 준다
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: $r-card;
font-size: 8cqw;
background: linear-gradient(180deg, rgba($subject-ink, .14), rgba(255, 255, 255, .9) 38%, rgba(255, 255, 255, .9) 62%, rgba($subject-ink, .14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: $r-sm;
background: rgba($subject-lilac, .38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: $r-xs;
background: rgba($subject-ink, .5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
// 08 — 눈금자. 가운데 바늘은 고정, 눈금 줄이 옆으로 흐른다
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: $r-card;
background: rgba(255, 255, 255, .5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
// 눈금마다 그 자리의 숫자(0~9)를 아래에 적는다 — 5 의 배수 눈금은 길고 진하다
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: $ink-soft;
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: .6cqw;
height: 2.8cqw;
margin-left: -.3cqw;
border-radius: $r-xs;
background: rgba($subject-ink, .4);
}
.ruler__tick.is-major { color: $subject-ink; }
.ruler__tick.is-major::before {
height: 4cqw;
background: $subject-ink;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -.5cqw;
border-radius: $r-xs;
background: $subject-pink;
}
// 09 — 첫 화면 통계 한 줄
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: $r-card;
background: rgba(255, 255, 255, .78);
box-shadow: $shadow-press;
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: $subject-ink;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
// 바깥 .vj 가 넘침을 자르므로 포커스 테두리는 안쪽으로 들인다
.vj__pad:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: $sp-3;
top: $sp-3;
padding: $sp-1 $sp-3;
border: 2px solid $subject-ink;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: $shadow-press;
transition: transform $dur-quick $ease-spring;
}
// 눌린 상태(멈춤) — 바탕과 글자색을 뒤집어 aria-pressed 가 눈에도 보이게
.vj__pause[aria-pressed="true"] {
background: $subject-cream;
color: $subject-ink;
}
.vj__pause:hover { transform: translateY(-2px); }
.vj__pause:active { transform: translateY(1px); }
.vj__pause:focus-visible {
outline: 3px solid $subject-ink;
outline-offset: 3px;
}
// 상태 줄 — 지금 장면의 수치를 글자로. 화면 읽기 프로그램에는 넘기지 않는다(aria-hidden)
.vj__status {
position: absolute;
left: $sp-3;
top: $sp-3;
max-width: calc(100% - 120px);
margin: 0;
padding: $sp-1 $sp-3;
border-radius: $r-pill;
background: $subject-ink;
color: $subject-cream;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
// 바뀐 최종 값을 읽어 주는 숨은 알림 영역
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status { font-size: 10px; padding: 0 $sp-2; }
}
// 움직임 줄이기 — 스크립트가 굴림 없이 최종 숫자 한 장으로 멈춘다. 버튼도 떠오르지 않게
@media (prefers-reduced-motion: reduce) {
.vj__pause { transition: none !important; }
.vj__pause:hover,
.vj__pause:active { transform: none !important; }
}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);
}
.vj {
position: relative;
width: min(92vw, 160vh - 12.8vw);
aspect-ratio: 480/300;
margin: 0 auto;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
background: rgb(95.0588235294%, 90.7215686275%, 91.9607843137%);
container-type: inline-size;
}
.vj__scene {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.stack {
display: grid;
justify-items: center;
gap: 1.6cqw;
padding-top: 5cqw;
}
.cap {
font-size: max(10px, 3.1cqw);
font-weight: 700;
line-height: 1.3;
color: #17141a;
text-align: center;
}
.cap--bad {
color: #c2361a;
}
.odo {
display: inline-flex;
align-items: center;
font-weight: 800;
line-height: 1;
font-variant-numeric: tabular-nums;
color: #17141a;
}
.odo.lg {
font-size: 16.5cqw;
}
.odo.xl {
font-size: 19cqw;
}
.odo.md {
font-size: 6.4cqw;
}
.odo.sm {
font-size: 6.6cqw;
}
.odo__sep {
padding: 0 0.04em;
font-size: 0.7em;
color: rgba(23, 20, 26, 0.6);
align-self: flex-end;
margin-bottom: 0.1em;
}
.odo__sep--unit {
margin-left: 0.2em;
font-size: 0.5em;
color: #17141a;
}
.dg {
position: relative;
display: inline-block;
width: 0.64em;
height: 1.12em;
margin: 0 0.03em;
overflow: hidden;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
box-shadow: inset 0 -0.05em 0 rgba(23, 20, 26, 0.08);
}
.dg__col {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 1200%;
display: flex;
flex-direction: column;
}
.dg__cell {
flex: 0 0 8.3333333333%;
display: flex;
align-items: center;
justify-content: center;
}
.dg__glow {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
background: linear-gradient(180deg, rgba(110, 200, 255, 0.5), rgba(185, 160, 255, 0.5));
}
.dg__bar {
position: absolute;
left: 8%;
right: 8%;
bottom: 4%;
height: 5%;
border-radius: 4px;
background: #6ec8ff;
transform: scaleX(0);
}
.dg__bar.is-over {
background: #ff9ebb;
}
.panel {
position: relative;
padding: 3cqw 5cqw;
border-radius: 16px;
overflow: hidden;
background: rgba(255, 255, 255, 0.7);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.panel .dg {
background: rgba(255, 255, 255, 0.55);
}
.panel__flash {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.panel.is-up .panel__flash {
background: rgba(76, 212, 166, 0.55);
}
.panel.is-down .panel__flash {
background: rgba(255, 158, 187, 0.6);
}
.fl {
position: relative;
display: inline-block;
width: 0.66em;
height: 1.12em;
margin: 0 0.03em;
perspective: 2.4em;
}
.fl__half {
position: absolute;
left: 0;
right: 0;
height: 50%;
overflow: hidden;
backface-visibility: hidden;
}
.fl__top,
.fl__ft {
top: 0;
border-radius: 8px 8px 0 0;
background: rgb(93.4117647059%, 91.0588235294%, 100%);
}
.fl__bot,
.fl__fb {
bottom: 0;
border-radius: 0 0 8px 8px;
background: rgb(97.2549019608%, 96.2745098039%, 100%);
}
.fl__ft {
z-index: 2;
transform-origin: 50% 100%;
}
.fl__fb {
z-index: 2;
transform-origin: 50% 0;
opacity: 0;
}
.fl__num {
position: absolute;
left: 0;
right: 0;
height: 200%;
display: flex;
align-items: center;
justify-content: center;
color: #17141a;
}
.fl__top .fl__num,
.fl__ft .fl__num {
top: 0;
}
.fl__bot .fl__num,
.fl__fb .fl__num {
bottom: 0;
}
.fl__shade {
position: absolute;
left: 0;
right: 0;
bottom: 0;
height: 50%;
z-index: 1;
border-radius: 0 0 8px 8px;
background: #17141a;
opacity: 0;
pointer-events: none;
}
.fl__seam {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 2px;
z-index: 3;
background: rgba(23, 20, 26, 0.2);
}
.dr {
position: relative;
width: 28cqw;
height: 42cqw;
margin: 0 1cqw;
overflow: hidden;
border-radius: 16px;
font-size: 8cqw;
background: linear-gradient(180deg, rgba(23, 20, 26, 0.14), rgba(255, 255, 255, 0.9) 38%, rgba(255, 255, 255, 0.9) 62%, rgba(23, 20, 26, 0.14));
}
.dr__band {
position: absolute;
left: 6%;
right: 6%;
top: 50%;
height: 16%;
margin-top: -8%;
border-radius: 8px;
background: rgba(185, 160, 255, 0.38);
}
.dr__groove {
position: absolute;
left: 8%;
right: 8%;
top: 50%;
height: 2px;
margin-top: -1px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.5);
opacity: 0;
}
.dr__item {
position: absolute;
left: 0;
right: 0;
top: 50%;
height: 16%;
margin-top: -8%;
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
opacity: 0;
}
.ruler {
position: relative;
width: 90cqw;
height: 9.6cqw;
overflow: hidden;
border-radius: 16px;
background: rgba(255, 255, 255, 0.5);
}
.ruler__track {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
}
.ruler__tick {
position: absolute;
top: 0;
bottom: 0;
width: 7cqw;
margin-left: -3.5cqw;
padding-top: 4.4cqw;
box-sizing: border-box;
font-size: 3.4cqw;
font-weight: 700;
line-height: 1;
text-align: center;
color: rgba(23, 20, 26, 0.6);
}
.ruler__tick::before {
content: "";
position: absolute;
top: 0;
left: 50%;
width: 0.6cqw;
height: 2.8cqw;
margin-left: -0.3cqw;
border-radius: 4px;
background: rgba(23, 20, 26, 0.4);
}
.ruler__tick.is-major {
color: #17141a;
}
.ruler__tick.is-major::before {
height: 4cqw;
background: #17141a;
}
.ruler__needle {
position: absolute;
left: 50%;
top: 0;
bottom: 0;
width: 1cqw;
margin-left: -0.5cqw;
border-radius: 4px;
background: #ff9ebb;
}
.hero {
display: flex;
gap: 2.6cqw;
}
.hero__stat {
display: grid;
justify-items: center;
gap: 1cqw;
padding: 2.6cqw 2.8cqw;
border-radius: 16px;
background: rgba(255, 255, 255, 0.78);
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
}
.hero__label {
font-size: max(10px, 2.8cqw);
font-weight: 700;
color: #17141a;
}
.vj__pad {
position: absolute;
inset: 0;
padding: 0;
border: 0;
border-radius: inherit;
background: transparent;
cursor: pointer;
}
.vj__pad:focus-visible {
outline: 3px solid #17141a;
outline-offset: -3px;
}
.vj__pause {
position: absolute;
right: 12px;
top: 12px;
padding: 4px 12px;
border: 2px solid #17141a;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-family: inherit;
font-weight: 700;
font-size: 12px;
line-height: 1.6;
cursor: pointer;
box-shadow: 0 6px 12px rgba(23, 20, 26, 0.32);
transition: transform 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.vj__pause[aria-pressed=true] {
background: #fff7e6;
color: #17141a;
}
.vj__pause:hover {
transform: translateY(-2px);
}
.vj__pause:active {
transform: translateY(1px);
}
.vj__pause:focus-visible {
outline: 3px solid #17141a;
outline-offset: 3px;
}
.vj__status {
position: absolute;
left: 12px;
top: 12px;
max-width: calc(100% - 120px);
margin: 0;
padding: 4px 12px;
border-radius: 999px;
background: #17141a;
color: #fff7e6;
font-weight: 700;
font-size: 11px;
line-height: 1.6;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
pointer-events: none;
}
.vj__live {
position: absolute;
width: 1px;
height: 1px;
margin: -1px;
padding: 0;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
@media (max-width: 340px) {
.vj__pause,
.vj__status {
font-size: 10px;
padding: 0 8px;
}
}
@media (prefers-reduced-motion: reduce) {
.vj__pause {
transition: none !important;
}
.vj__pause:hover,
.vj__pause:active {
transform: none !important;
}
}(function () {
const root = document.getElementById("vj");
if (!root) return;
const cls = (s) => s;
function clamp(v, lo, hi) { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a, b, k) { return a + (b - a) * k; }
function mod(a, n) { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x) { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x) { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
function mk(tag, name, parent) {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) {
parent.appendChild(e);
}
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent, extra, ghosts) {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col, pos) {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s, pos, speed) {
putCol(s.cols[0], pos);
if (s.cols.length < 2) {
return;
}
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s, v) { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent, pattern, extra, ghosts) {
const row = mk('span', 'odo ' + extra, parent);
const out = [];
for (const ch of pattern) {
if (ch === 'd') {
out.push(makeStrip(row, extra, ghosts));
}
else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n, len) {
const out = [];
for (let i = 0; i < len; i += 1) {
out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10);
}
return out;
}
function fmtNum(n) {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) {
out += ',';
}
out += s.charAt(i);
}
return out;
}
function q(role) {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) {
throw new Error('missing ' + role);
}
return e;
}
const scene = q('scene');
const pad = q('pad');
const pauseBtn = q('pause');
const statusEl = q('status');
const liveEl = q('live');
const LOOP_S = 4.0;
const REST_T = 3.0;
// 첫 수치는 0.2초 뒤에 들어오고, 다음 수치는 ENTER_GAP_S 초씩 뒤에 들어온다
const START_S = 0.2;
const ENTER_GAP_S = 0.3;
// 들어올 때 떠오르는 시간 · 자리 굴림 시간 · 자리 사이 출발 간격(초)
const FADE_S = 0.35;
const ROLL_S = 1.0;
const PLACE_DELAY_S = 0.05;
// 訪問者·レビュー·評価(평점은 10 배 정수 — 48 은 4.8)
const VALUES = [[12480, 3512, 48], [12860, 3547, 49], [9640, 2118, 47]];
const PATTERNS = ['dd,ddd', 'd,ddd', 'd.d'];
const SUFFIXES = ['+', '+', '/5'];
const LABELS = ['訪問者', 'レビュー', '評価'];
let stats = [];
let target = VALUES[0];
function build() {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '画面に入った順に転がり上がる統計一行';
const line = mk('div', 'hero', stack);
stats = [];
for (let i = 0; i < 3; i += 1) {
const card = mk('div', 'hero__stat', line);
const row = mk('span', 'odo md', card);
const strips = buildRow(row, PATTERNS[i], 'md', 0);
mk('span', 'odo__sep odo__sep--unit', row).textContent = SUFFIXES[i];
mk('span', 'hero__label', card).textContent = LABELS[i];
stats.push({ card: card, strips: strips, digits: strips.length });
}
}
function reset() {
target = VALUES[cycle % VALUES.length];
}
function update(dt) {
void dt;
}
function startOf(i) { return START_S + i * ENTER_GAP_S; }
function statU(i, k) { return (sceneT - startOf(i) - FADE_S * 0.5 - k * PLACE_DELAY_S) / ROLL_S; }
function draw() {
for (let i = 0; i < 3; i += 1) {
const st = stats[i];
// 들어올 때 낮은 곳에서 떠오른다 — 처음에도 흐릿하게 보이게 바닥 밝기를 둔다
const e = easeOut3((sceneT - startOf(i)) / FADE_S);
st.card.style.opacity = (0.35 + 0.65 * e).toFixed(3);
st.card.style.transform = 'translateY(' + ((1 - e) * 14).toFixed(2) + '%)';
const d = digitsOf(target[i], st.digits);
for (let j = 0; j < st.digits; j += 1) {
const k = st.digits - 1 - j;
const u = clamp(statU(i, k), 0, 1);
const adv = 10 + d[j];
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(st.strips[j], adv * easeOut3(u), speed);
glowStrip(st.strips[j], speed / BLUR_REF * 0.8);
}
}
}
// 지금 보이는 수치(굴러 오르는 중) — 목표에 굴림 진행률을 곱한 근사
function nowValue(i) {
const u = clamp(statU(i, 0), 0, 1);
return Math.round(target[i] * easeOut3(u));
}
function isSettled() { return statU(2, stats[2].digits - 1) >= 1; }
function ratingText(v) { return (v / 10).toFixed(1); }
function statusText() {
return '訪問者 {a}・レビュー {b}・評価 {c}'.replace('{a}', fmtNum(nowValue(0))).replace('{b}', fmtNum(nowValue(1))).replace('{c}', ratingText(nowValue(2)));
}
function finalText() {
return '訪問者 {a}人、レビュー {b}件、評価 {c}点'.replace('{a}', fmtNum(target[0])).replace('{b}', fmtNum(target[1])).replace('{c}', ratingText(target[2]));
}
let shownStatus = '';
function pushStatus(s) {
if (s === shownStatus) {
return;
}
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled() {
const s = isSettled();
if (s && !wasSettled) {
liveEl.textContent = finalText();
}
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake() {
if (!demo) {
return;
}
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt) {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint() {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts) {
if (still || paused) {
rafId = 0;
return;
}
if (lastTs < 0) {
lastTs = ts;
}
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) {
advance(dt);
}
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop() {
if (rafId) {
return;
}
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop() {
if (rafId) {
window.cancelAnimationFrame(rafId);
rafId = 0;
}
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest() {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) {
sceneT += 1 / 60;
update(1 / 60);
}
paint();
}
function play() {
sceneT = 0;
reset();
paint();
if (!paused) {
startLoop();
}
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow() {
wake();
cycle += 1;
wasSettled = false;
if (still) {
showRest();
}
else {
play();
}
}
function togglePause() {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) {
stopLoop();
}
else if (!still) {
startLoop();
}
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e) {
if (e.altKey || e.ctrlKey || e.metaKey) {
return;
}
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function () {
if (document.hidden) {
stopLoop();
}
else if (!paused && !still) {
startLoop();
}
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function () {
still = reduceQuery.matches;
if (still) {
showRest();
}
else {
play();
}
}, { signal: off.signal });
build();
if (still) {
showRest();
}
else {
play();
}
return function () {
stopLoop();
off.abort();
};
})();// HeroStatTicker - 같은 항목 vanilla index.html 의 React 판. 같은 계산을 useEffect 안에서 돌린다
import { useEffect, useRef } from "react";
import styles from "./HeroStatTicker.module.scss";
const cx = (s: string): string => s.split(" ").map((n) => styles[n] ?? n).join(" ");
export default function HeroStatTicker() {
const rootRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const rootEl = rootRef.current;
if (!rootEl) return;
const root: HTMLElement = rootEl;
const cls = cx;
function clamp(v: number, lo: number, hi: number): number { return v < lo ? lo : (v > hi ? hi : v); }
function lerp(a: number, b: number, k: number): number { return a + (b - a) * k; }
function mod(a: number, n: number): number { return ((a % n) + n) % n; }
// 감속 곡선 — 처음이 가장 빠르고 끝에서 0 으로 닿는다(3제곱)
function easeOut3(x: number): number { const u = 1 - clamp(x, 0, 1); return 1 - u * u * u; }
function easeInOut(x: number): number { const c = clamp(x, 0, 1); return c * c * (3 - 2 * c); }
// 장면 시계(초) — 장면 계산이 고치고 화면 쪽이 읽는다
let sceneT = 0;
// 지금 몇 번째 값인가 — 한 바퀴(LOOP_S)가 돌 때마다 하나 올라 다음 목표 값을 고른다
let cycle = 0;
// 숫자띠 한 줄의 칸 수 — 0 부터 9 까지 열 칸에 0, 1 을 한 번 더 붙여 9 에서 0 으로 넘어가는 경계를 끊김 없이 잇는다
const CELLS = 12;
// 번짐 기준 속도(칸/초) — 이만큼 빠르면 번짐이 최대. 꼬리 한 장의 간격(칸)
const BLUR_REF = 60;
const BLUR_LAG = 0.55;
interface Strip { win: HTMLElement; cols: HTMLElement[]; glow: HTMLElement; }
function mk(tag: string, name: string, parent: HTMLElement | null): HTMLElement {
const e = document.createElement(tag);
e.className = cls(name);
if (parent) { parent.appendChild(e); }
return e;
}
// 자리 하나 — 창 하나에 0~9 띠(ghosts 장의 꼬리 띠 포함)와 속도 반짝임 층
function makeStrip(parent: HTMLElement, extra: string, ghosts: number): Strip {
const win = mk('span', 'dg ' + extra, parent);
// 속도 반짝임 층은 띠 뒤에 깐다 — 숫자 글자는 항상 그 위에서 또렷하다
const glow = mk('span', 'dg__glow', win);
const cols: HTMLElement[] = [];
for (let g = 0; g <= ghosts; g += 1) {
const col = mk('span', g === 0 ? 'dg__col' : 'dg__col dg__ghost', win);
for (let i = 0; i < CELLS; i += 1) {
const c = mk('span', 'dg__cell', col);
c.textContent = String(i % 10);
}
cols.push(col);
}
return { win: win, cols: cols, glow: glow };
}
function putCol(col: HTMLElement, pos: number): void {
col.style.transform = 'translateY(' + (-mod(pos, 10) / CELLS * 100).toFixed(3) + '%)';
}
// pos = 지금 띠가 밀려 올라온 칸 수(소수), speed = 칸/초(방향 포함). 꼬리 띠가 있으면 속도에 비례해 번진다
function moveStrip(s: Strip, pos: number, speed: number): void {
putCol(s.cols[0], pos);
if (s.cols.length < 2) { return; }
const sp = clamp(Math.abs(speed) / BLUR_REF, 0, 1);
const dir = speed < 0 ? -1 : 1;
s.cols[0].style.opacity = (1 - 0.5 * sp).toFixed(3);
for (let g = 1; g < s.cols.length; g += 1) {
putCol(s.cols[g], pos - dir * sp * BLUR_LAG * g);
s.cols[g].style.opacity = (0.5 * sp / g).toFixed(3);
}
}
function glowStrip(s: Strip, v: number): void { s.glow.style.opacity = clamp(v, 0, 1).toFixed(3); }
// 칸 패턴 — d 는 숫자띠 한 자리, 그 밖의 글자는 구분 기호(쉼표·점·콜론). 왼쪽부터 순서대로 돌려준다
function buildRow(parent: HTMLElement, pattern: string, extra: string, ghosts: number): Strip[] {
const row = mk('span', 'odo ' + extra, parent);
const out: Strip[] = [];
for (const ch of pattern) {
if (ch === 'd') { out.push(makeStrip(row, extra, ghosts)); } else {
const sep = mk('span', 'odo__sep', row);
sep.textContent = ch;
}
}
return out;
}
// 정수를 len 자리 숫자 배열로(왼쪽 자리부터)
function digitsOf(n: number, len: number): number[] {
const out: number[] = [];
for (let i = 0; i < len; i += 1) { out.push(Math.floor(n / Math.pow(10, len - 1 - i)) % 10); }
return out;
}
function fmtNum(n: number): string {
const s = String(Math.round(n));
let out = '';
for (let i = 0; i < s.length; i += 1) {
if (i > 0 && (s.length - i) % 3 === 0) { out += ','; }
out += s.charAt(i);
}
return out;
}
function q(role: string): HTMLElement {
const e = root.querySelector('[data-role="' + role + '"]');
if (!(e instanceof HTMLElement)) { throw new Error('missing ' + role); }
return e;
}
const scene: HTMLElement = q('scene');
const pad: HTMLElement = q('pad');
const pauseBtn: HTMLElement = q('pause');
const statusEl: HTMLElement = q('status');
const liveEl: HTMLElement = q('live');
const LOOP_S = 4.0;
const REST_T = 3.0;
// 첫 수치는 0.2초 뒤에 들어오고, 다음 수치는 ENTER_GAP_S 초씩 뒤에 들어온다
const START_S = 0.2;
const ENTER_GAP_S = 0.3;
// 들어올 때 떠오르는 시간 · 자리 굴림 시간 · 자리 사이 출발 간격(초)
const FADE_S = 0.35;
const ROLL_S = 1.0;
const PLACE_DELAY_S = 0.05;
// 방문자·후기·평점(평점은 10 배 정수 — 48 은 4.8)
const VALUES = [[12480, 3512, 48], [12860, 3547, 49], [9640, 2118, 47]];
const PATTERNS = ['dd,ddd', 'd,ddd', 'd.d'];
const SUFFIXES = ['+', '+', '/5'];
const LABELS = ['방문자', '후기', '평점'];
interface Stat { card: HTMLElement; strips: Strip[]; digits: number; }
let stats: Stat[] = [];
let target = VALUES[0];
function build(): void {
scene.textContent = '';
const stack = mk('div', 'stack', scene);
mk('span', 'cap', stack).textContent = '화면에 들어온 순서대로 굴러 오르는 통계 한 줄';
const line = mk('div', 'hero', stack);
stats = [];
for (let i = 0; i < 3; i += 1) {
const card = mk('div', 'hero__stat', line);
const row = mk('span', 'odo md', card);
const strips = buildRow(row, PATTERNS[i], 'md', 0);
mk('span', 'odo__sep odo__sep--unit', row).textContent = SUFFIXES[i];
mk('span', 'hero__label', card).textContent = LABELS[i];
stats.push({ card: card, strips: strips, digits: strips.length });
}
}
function reset(): void {
target = VALUES[cycle % VALUES.length];
}
function update(dt: number): void {
void dt;
}
function startOf(i: number): number { return START_S + i * ENTER_GAP_S; }
function statU(i: number, k: number): number { return (sceneT - startOf(i) - FADE_S * 0.5 - k * PLACE_DELAY_S) / ROLL_S; }
function draw(): void {
for (let i = 0; i < 3; i += 1) {
const st = stats[i];
// 들어올 때 낮은 곳에서 떠오른다 — 처음에도 흐릿하게 보이게 바닥 밝기를 둔다
const e = easeOut3((sceneT - startOf(i)) / FADE_S);
st.card.style.opacity = (0.35 + 0.65 * e).toFixed(3);
st.card.style.transform = 'translateY(' + ((1 - e) * 14).toFixed(2) + '%)';
const d = digitsOf(target[i], st.digits);
for (let j = 0; j < st.digits; j += 1) {
const k = st.digits - 1 - j;
const u = clamp(statU(i, k), 0, 1);
const adv = 10 + d[j];
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(st.strips[j], adv * easeOut3(u), speed);
glowStrip(st.strips[j], speed / BLUR_REF * 0.8);
}
}
}
// 지금 보이는 수치(굴러 오르는 중) — 목표에 굴림 진행률을 곱한 근사
function nowValue(i: number): number {
const u = clamp(statU(i, 0), 0, 1);
return Math.round(target[i] * easeOut3(u));
}
function isSettled(): boolean { return statU(2, stats[2].digits - 1) >= 1; }
function ratingText(v: number): string { return (v / 10).toFixed(1); }
function statusText(): string {
return '방문자 {a} · 후기 {b} · 평점 {c}'.replace('{a}', fmtNum(nowValue(0))).replace('{b}', fmtNum(nowValue(1))).replace('{c}', ratingText(nowValue(2)));
}
function finalText(): string {
return '방문자 {a}명, 후기 {b}건, 평점 {c}점'.replace('{a}', fmtNum(target[0])).replace('{b}', fmtNum(target[1])).replace('{c}', ratingText(target[2]));
}
let shownStatus = '';
function pushStatus(s: string): void {
if (s === shownStatus) { return; }
shownStatus = s;
statusEl.textContent = s;
}
// 바뀐 최종 값은 한 번만 알린다 — 굴러가는 중간 숫자는 읽는 쪽으로 보내지 않는다
let wasSettled = false;
function checkSettled(): void {
const s = isSettled();
if (s && !wasSettled) { liveEl.textContent = finalText(); }
wasSettled = s;
}
let paused = false;
const reduceQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
// 페이지를 연 채 움직임 줄이기 설정을 바꿔도 change 리스너가 다시 읽는다
let still = reduceQuery.matches;
// 자동 시연 표시 — 사람이 처음 손대면 떼고 알림 영역을 polite 로 켠다
let demo = root.classList.contains('is-demo');
const off = new AbortController();
function wake(): void {
if (!demo) { return; }
demo = false;
root.classList.remove('is-demo');
liveEl.setAttribute('aria-live', 'polite');
}
// 한 프레임에 넘기는 시간 상한(초) — 오래 숨었다 돌아온 첫 프레임도 50ms 까지만 계산에 넣는다
const DT_MAX = 0.05;
function advance(dt: number): void {
sceneT += dt;
if (sceneT >= LOOP_S) {
sceneT -= LOOP_S;
cycle += 1;
reset();
}
update(dt);
}
function paint(): void {
draw();
pushStatus(statusText());
checkSettled();
}
let rafId = 0;
let lastTs = -1;
function frame(ts: number): void {
if (still || paused) { rafId = 0; return; }
if (lastTs < 0) { lastTs = ts; }
const dt = Math.min((ts - lastTs) / 1000, DT_MAX);
lastTs = ts;
if (dt > 0) { advance(dt); }
paint();
rafId = window.requestAnimationFrame(frame);
}
function startLoop(): void {
if (rafId) { return; }
// 멈췄던 사이의 시간을 첫 프레임 dt 로 넘기지 않는다
lastTs = -1;
rafId = window.requestAnimationFrame(frame);
}
function stopLoop(): void {
if (rafId) { window.cancelAnimationFrame(rafId); rafId = 0; }
}
// 움직임 줄이기 — 굴림 없이 이 값의 최종 숫자 한 장
function showRest(): void {
stopLoop();
sceneT = 0;
reset();
for (let k = 0; k < Math.ceil(REST_T * 60); k += 1) { sceneT += 1 / 60; update(1 / 60); }
paint();
}
function play(): void {
sceneT = 0;
reset();
paint();
if (!paused) { startLoop(); }
}
// 다음 값 — 눌렀을 때 바로 다음 목표로 넘어가 처음부터 굴린다(움직임 줄이기에서는 즉시 최종 숫자)
function nextNow(): void {
wake();
cycle += 1;
wasSettled = false;
if (still) { showRest(); } else { play(); }
}
function togglePause(): void {
paused = !paused;
pauseBtn.setAttribute('aria-pressed', paused ? 'true' : 'false');
wake();
if (paused) { stopLoop(); } else if (!still) { startLoop(); }
}
pauseBtn.addEventListener('click', togglePause, { signal: off.signal });
// 무대 버튼 — 누르면(Enter·Space·클릭은 브라우저가 click 으로 준다) 다음 값. 오른쪽·위 화살표도 같다
pad.addEventListener('click', nextNow, { signal: off.signal });
pad.addEventListener('keydown', function (e: KeyboardEvent): void {
if (e.altKey || e.ctrlKey || e.metaKey) { return; }
if (e.key === 'ArrowRight' || e.key === 'ArrowUp') {
e.preventDefault();
nextNow();
}
}, { signal: off.signal });
// 탭이 숨으면 멈추고, 돌아오면 시계를 다시 맞춰 시작 — 숨은 동안의 몇 초가 한 프레임에 몰리지 않게
document.addEventListener('visibilitychange', function (): void {
if (document.hidden) { stopLoop(); } else if (!paused && !still) { startLoop(); }
}, { signal: off.signal });
// 설정이 바뀌면 — 켜지면 최종 숫자 한 장, 꺼지면 다시 움직인다
reduceQuery.addEventListener('change', function (): void {
still = reduceQuery.matches;
if (still) { showRest(); } else { play(); }
}, { signal: off.signal });
build();
if (still) { showRest(); } else { play(); }
return function (): void {
stopLoop();
off.abort();
};
}, []);
return (
<div className={cx("vj") + " is-demo"} ref={rootRef}>
<div className={cx("vj__scene")} data-role="scene" role="img" aria-label={'방문자·후기·평점 세 수치가 화면에 들어온 순서대로 떠오르며 자리 지연을 두고 0에서 차올라 첫 화면 한 장이 숫자가 차오르는 장면이 된다'} />
<button className={cx("vj__pad")} data-role="pad" type="button" aria-label={'숫자 장면 무대 — 누르거나 Enter, 오른쪽·위 화살표로 다음 값 굴리기'} />
<p className={cx("vj__status")} data-role="status" aria-hidden="true">{'숫자 롤링 장면'}</p>
<p className={cx("vj__live")} data-role="live" role="status" aria-live="off" />
<button className={cx("vj__pause")} data-role="pause" type="button" aria-pressed="false">{'일시정지'}</button>
</div>
);
}09 · 統計の一行が順に転がり上がるヒーロー — 計測 累積面積 21.611% · 動いたフレーム 16/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
function startOf(i) { return START_S + i * ENTER_GAP_S; }
function statU(i, k) { return (sceneT - startOf(i) - FADE_S * 0.5 - k * PLACE_DELAY_S) / ROLL_S; }
function draw() {
for (let i = 0; i < 3; i += 1) {
const st = stats[i];
// 入ってくるとき低いところから浮かぶ — 最初もぼんやり見えるよう底の明るさを置く
const e = easeOut3((sceneT - startOf(i)) / FADE_S);
st.card.style.opacity = (0.35 + 0.65 * e).toFixed(3);
st.card.style.transform = 'translateY(' + ((1 - e) * 14).toFixed(2) + '%)';
const d = digitsOf(target[i], st.digits);
for (let j = 0; j < st.digits; j += 1) {
const k = st.digits - 1 - j;
const u = clamp(statU(i, k), 0, 1);
const adv = 10 + d[j];
const speed = u > 0 && u < 1 ? adv * 3 * (1 - u) * (1 - u) / ROLL_S : 0;
moveStrip(st.strips[j], adv * easeOut3(u), speed);
glowStrip(st.strips[j], speed / BLUR_REF * 0.8);
}
}
}
カウントアップ アニメーション css は何桁まで、どれだけゆっくり転がせば読めるのでしょうか?
止まる時刻は2.16秒の内側に収めました
桁の遅れがあっても全部の桁が止まる時刻は、01が1.67秒、06が1.75秒、07が1.95秒、09が2.03秒、03が2.16秒です。一桁が転がって止まるのに01と09は1.0〜1.1秒、05は0.7秒を使い、桁のあいだの出発の間隔は80ms・50ms・40msと短くしてあります。桁を五つより増やすときは間隔を縮めないと、左の桁が遅れすぎて止まります。
値が一度出て終わる画面
ダッシュボードの累計カードや決済完了の合計のように、値がひとつ出て終わる画面には01と02が合います。ランディングの最初の画面なら09のように、入ってくる順番をカードごとに0.3秒あけて、一行をひとつの場面にします。
値が続けて変わる画面
接続者数カウンター(04)、為替・在庫の掲示板(05)、到着の時刻板(06)は、同じ桁が何度も変わります。js カウントアップ アニメーション では前の値から新しい値への向きと桁上がりの順が読めないと混乱するので、04の chainOf(n) のように、いっしょに変わった桁の数を状態の行に書いておくと確かめやすくなります。
人が値を選ぶ画面
時・分の選択ホイール(07)と数量・年齢のダイヤル(08)は、見せるのではなく選ぶ部品として使います。どちらも目盛りか溝を基準に止まる位置が決まるので、止まった桁の値を読んでフォームに入れれば足ります。
帯を値の分だけ押し、桁ごとに出発を遅らせ、ばねで止めます — 数字一つが転がって落ち着くまでの順番です
位置の微分がそのままにじみになります
01・03・05・09 は位置を a + adv·easeOut3(u) で先に求め、速さをその微分の adv·3(1−u)²/ROLL_S で出して、同じ式からにじみと光を取り出します。出だしがいちばん速く、止まるときに0になるので、別に減速の曲線を用意する必要がありません。帯を押す仕事は translateY ひとつが受け持ち、時計は requestAnimationFrame の時刻で数えます。
桁の遅れは低い桁から積みます
一の位を k = 0 として出発を k·PLACE_DELAY だけ遅らせると、右の桁が先に止まります。02 はこの遅れを0.06秒、05 は0.04秒にして、桁のあいだの間隔を場面の性格に合わせて変えています。
ばねはフレームではなく240Hzの欠片で積みます
一フレームの時間が長くなるとばねの計算が暴れるので、02 はフレームを240Hzの欠片に刻んで積み、時計のほうも一フレームの時間を DT_MAX 0.05秒で切りつめて入れます。位置と速さをフレームごとに積むのは02のばねと08の摩擦で、残りは時計だけで描きます。
数字のカウントアップが壊れるのは、桁上がりなしで一桁だけ変わって999が000に見える数字・帯の端ではねて最初へ巻き戻る数字・隠れたタブのあとで一気に転がる数字です
桁上がりが遅れて届くと値が違って見えます
04 の下の行は LATE_S 0.3秒の間隔なので、999から1000へ行くとき一・十・百・千の位が1.08・1.38・1.68・1.98秒に出発します。低い桁が0に戻っているあいだ上の桁はまだ9のままで、しばらく値が違って見え、上の行は0.07秒間隔なのでこのずれが1.29秒に終わります。
帯の端で逆向きに巻き戻る数字
01 の下の小さな行は lerp(a, b, easeOut3(u)) で数字の値を帯の位置にそのまま使います。9から2へ行くと帯が2のある場所まで逆向きになぞるので、上の行のように一周多く回して mod で余りを合わせるほうが、同じ向きにつながります。
隠れたタブのあとで一気に転がる数字
08 の下の小さな数字は、0.5秒に長い一フレームを dt + SPIKE_EXTRA 0.4秒のまま受けます。そのフレームで v が一度に掛けられて位置が大きく跳び、上のダイヤルとは違う数字に止まります。
function update(dt) {
step(good, dt);
// 悪い側は時間の上限なしで一度だけ大きく受ける
let dtBad = dt;
if (!bad.spiked && sceneT >= SPIKE_S) {
bad.spiked = true;
dtBad = dt + SPIKE_EXTRA;
}
step(bad, dtBad);
}
実際にタブが隠れると九つの場面が使うループは止まり、戻ったときは lastTs を −1 に戻して、隠れていた時間が最初の一コマに乗らないようにします。それでも長いフレームが入ったときに備えて DT_MAX をもう一度かけて切りつめます。圧縮ファイルはパスワード qyyem5sk で開き、01・04・08 のフォルダの index.html を一時停止ボタンで止めておくと、下の小さな行がずれる地点を目で確かめられます。
ばねを強くすると数字はどれだけゆれるのでしょうか — ばねの強さ・減衰・桁ごとの遅れを変えて比べます
| 名前 | 変えた値 | 感じ |
|---|---|---|
| ばねを強く | 02 SPRING_K 120 → 260 |
減衰比が0.55から0.37に下がって行き過ぎる幅が距離の12.8%から28.4%に増え(8マスなら1.0マスから2.2マス)、震える周波数が1.46Hzから2.38Hzに速くなって最初の到達が0.23秒から0.13秒に早まります |
| 桁の遅れを長く | 01 PLACE_DELAY_MS 80 → 240 |
いちばん左の桁の出発が右より320msから960ms遅れ、全部の桁が止まる時刻が1.67秒から2.31秒へ0.64秒延びます |
| 摩擦を小さく | 08 FRICTION 0.92 → 0.97 |
一フレームに残る速さが0.92から0.97に増えて1秒後に残る速さが0.7%から16.1%になり、引き込みの開始が0.68秒から1.86秒へ、すべる距離が18マスから48マスへ延びます |
値を変える前に見ておくこと
02 は SPRING_C が12のままなので、SPRING_K だけ上げると落ち着く時間は0.7〜0.9秒で変わらず、行き過ぎる幅だけが広がります。01 は桁の遅れを240msにしても2.31秒に止まって LOOP_S 3.4秒に収まりますが、桁を増やすとその余裕は減ります。08 は摩擦を0.97にすると引き込みが1.86秒に始まるので、LOOP_S 3.0秒の中で目盛りに吸いつく時間が短くなります。
動きを減らす設定では最終の数字が一枚です — 値を読み上げる文と一時停止ボタンのアクセシビリティです
動きを減らす設定と場面の説明
prefers-reduced-motion: reduce のとき、メディアクエリ を読む reduceQuery がループを止め、showRest() が REST_T 秒後の場面、つまり最終の数字を一枚だけ描きます。九つの場面の舞台は role="img" と aria-label で何が起きているかを伝え、転がっている途中の数字が読み上げに回らないよう、状態の丸い表示(.vj__status)は aria-hidden です。転がりが終わって isSettled() が最初に真になったときだけ、隠した通知の領域に finalText() の一文が書かれ、自動デモのあいだは aria-live="off"、人が最初に触れると polite に変わります。
キーボードと一時停止
舞台のボタンはクリック・Enter・Space・右矢印・上矢印で nextNow() を呼んで次の値を転がし、動きを減らす状態では同じ入力がすぐ次の最終の数字を描きます。一時停止ボタンは aria-pressed で状態を伝え、タブが隠れると visibilitychange がループを止めます。
カウントアップ アニメーション css のよくある質問 — CSSだけで転がす方法・三桁ごとのカンマ・値が頻繁に変わるときの負荷
カウントアップ アニメーション 素材を探していますが、CSSだけで同じ場面は作れますか
値がひとつに決まっていて、帯を translateY で一度押すだけなら css の transition で足ります。この記事のばねの落ち着き・速さのにじみ・桁上がりの連鎖は、フレームごとに位置と速さを読んで次の値を決めるので js で計算します。ソースにスクロールの検知(IntersectionObserver)はなく、すべて時間表だけで動きます。
三桁ごとのカンマはどう入れますか
fmtNum が文字列の長さから三桁ごとにカンマを直接差し込み、09 は dd,ddd・d,ddd・d.d のようなマスの型でカンマのマスを別に置いています。国ごとに表記を変えたいときは Intl.NumberFormat で文字列を作ってから、桁数に合わせて帯に分ければ足ります。
値が頻繁に変わっても重くなりませんか
インラインで書き換える属性は transform と opacity が中心で、07 の溝の線の top と 08 の目盛りの left だけが座標の配置です。値が一場面の転がる時間(05 は0.7秒)より頻繁に入ってくるなら、転がりを継ぎ足すより目標だけを最新の値に差し替えるほうが簡単です。









