숫자 롤링 애니메이션 9종 — 굴러서 멈추는 계수기부터 올림이 이어지는 전광판까지
숫자 롤링 애니메이션은 자리마다 0~9 가 적힌 띠를 값만큼 밀어 올려 숫자가 굴러서 멈추게 하는 움직임이다. 숫자는 왜 한 번에 바뀌기보다 굴러 멈출 때 믿음이 갈까?
숫자는 바뀌는 값이 아니라 굴러가는 띠다 — 숫자 롤링 애니메이션은 자리마다 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
자동 재생 · 눌러서 해당 항목으로 · 전부 한 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칸을 더해 30~63칸을 굴리며, 07 의 시 바퀴는 12~23칸을 돈다. 굴리는 거리가 길어야 속도가 붙고, 속도가 붙어야 번짐이나 스프링이 의미를 가진다.
이웃한 편과 다른 점
css 숫자 증가 애니메이션 편은 css 로 숫자가 올라가는 코드를 다루고, 이 편은 JS 시간표로 자리별 속도·번짐·올림을 계산한다는 점이 다르다. 값을 손으로 올리고 내리는 입력 컨트롤은 숫자 스텝퍼 UI 편에, 줄어드는 남은 시간은 css countdown timer 편에 있다. 마찰로 멈추는 08 과 같은 계산 계열은 룰렛 돌리기 편의 돌림판이다.
숫자 롤링 애니메이션 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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칸을 더해 정한다.

<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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
// 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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초라 가장 늦은 셋째 칸이 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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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 원작 · zip 에 SCSS·React 포함 · 비밀번호 보기
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);
}
}
}
숫자 롤링 애니메이션은 몇 자리까지, 얼마나 느리게 굴려야 읽힐까?
멈추는 시각은 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)은 같은 자리가 계속 바뀐다. 이런 숫자 카운트 애니메이션은 이전 값에서 새 값으로 가는 방향과 올림 순서가 읽혀야 하므로 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 가 루프를 멈춘다.
숫자 롤링 애니메이션 FAQ — 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초)보다 잦게 들어오면 굴림을 이어 붙이기보다 목표만 최신 값으로 바꾸는 편이 단순하다.









