css リスト デザイン 9選 — マーカーが計算で動く箇条書きの装飾
9つのリストマーカーのうち、1つはダイヤル角度の値が項目ごとに積み、箇条書きの点が時計のように回り、1つはカウンター値が桁ごとに分解されて数字が走行計のように転がり、1つは素数判定の値によってその番号のマーカーだけが光を放ちます。
トリガーは9項目すべてclickリスナー1つずつです — ボタンの役割だけがそれぞれ違い、01は次の段階を進め、02は一位の値を上げ、03は倍速を切り替え、04は行進を止めてまた動かし、05は開始番号を動かし、06は集計を巡回させ、07は深さを公開し、09は全体を提出し、08だけがリストの各行のマーカーボタンが状態を切り替えます。自動デモはis-demoクラスが残っていてprefers-reduced-motionが偽のときだけ回り、最初のクリックで外れますが、04はreduceの判定を自動デモの条件の外に置いているため、人が操作し始めた後もその設定を守り続けます。マーカーは9つとも::markerにできないことをするため、list-style: noneでネイティブのマーカーを消して同じ場所に立て直したもので、01・02・05は<ol>、残りの6つは<ul>の上で回ります。
01 list-style-marker-dial-rotate
02 list-style-counter-flip-odometer
03 list-style-marker-wave-phase
04 list-style-dash-march-offset
05 list-style-number-prime-glow
06 list-style-marker-size-rank
07 list-style-nested-indent-rail
08 list-style-hybrid-marker-swap
09 list-style-check-burst-stagger
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 ダイヤル回転マーカー — 項目番号に角度の値を積み、点が時計のように回る方式
- 02 走行計カウンター — カウンター値を桁に分解し、数字マーカーが転がる方式
- 03 位相波マーカー — 項目ごとに位相の値をずらし、点が波打つ方式
- 04 行進ダッシュマーカー — オフセットの値を流し、ダッシュが行進する方式
- 05 素数グローマーカー — 素数判定の値を計算し、該当番号だけが光る方式
- 06 サイズ順位マーカー — 値に比例してマーカーの面積を計算する方式
- 07 階層レールマーカー — 深さの値でインデントレールを計算する方式
- 08 ハイブリッド入れ替えマーカー — 状態の値でマーカーの種類が変わる方式
- 09 連鎖チェックマーカー — 遅延の値を積み、チェックが連鎖で弾ける方式
9つのパターンの解剖 — どの計算がマーカーを動かすか
項目ごとに画面をどれだけ塗り替えたか(累積面積)、フレーム間のピクセル差がどれだけ強かったか(強度)、24枚のキャプチャ中何枚で動いたか(動いたフレーム)を測ると、次の表になります。
| 項目 | 累積面積% | 強度平均 | 動いたフレーム |
|---|---|---|---|
| 01 | 2.81% | 70.2 | 17/23 |
| 02 | 0.19% | 115.5 | 19/23 |
| 03 | 0.93% | 142.2 | 23/23 |
| 04 | 0.42% | 124.9 | 23/23 |
| 05 | 2.42% | 27.8 | 20/23 |
| 06 | 1.28% | 98.1 | 15/23 |
| 07 | 0.93% | 47.0 | 10/23 |
| 08 | 1.21% | 109.4 | 11/23 |
| 09 | 0.37% | 91.0 | 10/23 |
累積面積1位は01(2.81%)です — クリックのたびに番号に比例して複数の針が一斉に回り、アクティブなハイライトまで重なるため、ピクセルが変わる範囲が最も広くなります。最下位は02(0.19%)で、転がる数字が狭い枠の中だけを変えるためです。07は表では03と同じ0.93%に見えますが、測定の小数では03(0.931)がわずかに大きい値です。強度が140を超える項目は03(142.2)ひとつだけで、最低の05(27.8)はグローがアルファで広がる低コントラストな変化のためフレーム差が小さくなります。動いたフレームは03・04が23/24で最多、07・09が10/24で最少ですが、これは欠点ではなく段階の間に留まる静止区間の長さが違うだけです。
計算の軸で分けると4つの系統になります。
累積した値でマーカーを押す01・02・09
01はクリック1回がturns[i]に積み上がり、針の角度はそこに番号を掛けて大きくなります。02はカウンター値を10のべき乗で割って桁ごとに分解し、09は弾ける時刻をi * DELAY_STEP_MSで累積します。3つとも今の入力ではなく、通り過ぎた回数・順番がマーカーに刻まれる系統です。
流れる時間を入れる03・04
03はsin(t * speed + i * PHASE_RAD)で共通の時間に項目番号の分だけ位相をずらし、同じ瞬間でも違う高さを作ります。04はacc += dt/1000 * MARCH_RATE_DEGでオフセットを経過時間に定速で積分します。どちらも前の状態ではなく、そのフレームの時間が値を決める点が同じです。
判定式が点けたり消したりする05・07
05は番号ごとにisPrime()の割り算判定をやり直し、通過した項目だけにis-primeクラスを付けます。07はrevealed >= depthという比較式ひとつで全レールのis-openを即座に更新します。あらかじめ決めた一覧ではなく、値が変わるたびに判定が再び回る点がこの系統の性質です。
比例式が大きさを測る06・08
06はfactor = sqrt(v)/sqrt(max)の平方根比例で面積が値に比例するように半径を決めます。08はmax(1.5, BASE_AREA/size²・3)の面積保存式で、大きくなるマーカーほど線を細く逆算します。値の大きさがマーカーの大きさ・重さに換算される系統です。
パターン別コード
01ダイヤル回転マーカー — 項目番号に角度の値を積み、点が時計のように回る方式
クリック1回が項目の通り過ぎた回数(turns[i])に積み上がり、針の角度はそこに項目番号(i+1)とDIAL_STEP_DEG(30)を掛けた値になるため、番号が大きい項目ほど同じ1クリックでも大きく回ります。自動再生でもSTEP_MS(480)ごとに1段ずつ進みます。
数値が語ること
項目01は23フレーム中17枚で動き、累積面積2.81%は9項目中で最大です — クリックのたびに番号に比例した複数の針が同時に回り、アクティブなハイライトも重なるため、変化する範囲が最も広くなります。強度平均70.2は9項目の中間に位置します。
// 計算層: 項目iの針の角度は、その項目がこれまで通り過ぎた回数(turns[i])に自分の番号(i+1)を掛けて積み上がる。
// 下の項目ほど同じ1回の前進でも大きな角度を受け取る — 単純な遅延ではなく番号に比例した累積である。
var turns = items.map(function () { return 0; });
function render() {
items.forEach(function (el, i) {
var deg = turns[i] * DIAL_STEP_DEG * (i + 1);
el.style.setProperty('--angle', (deg % 360) + 'deg');
});
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ol class="dial-list" id="list">
<li style="--i:0">設計レビュー</li>
<li style="--i:1">モックアップ作成</li>
<li style="--i:2">社内検収</li>
<li style="--i:3">クライアント確認</li>
<li style="--i:4">公開完了</li>
</ol>
<button class="advance" id="advance" type="button">次の段階へ針を回す</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 turns[i] * DIAL_STEP_DEG * (i+1) 누적식에 있다 — 여기는 다이얼 모양·판만 맡는다.
// ::marker 는 transform(rotate) 을 지원하지 않아 바늘을 돌릴 수 없다 (::marker 로 되는 건 color·content·font 뿐).
// 그래서 list-style:none 으로 네이티브 마커를 지우고 ::before(다이얼 원판)·::after(바늘) 로 대체한다.
@include stage($stage-ink);
.dial-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 220px;
}
.dial-list li {
position: relative;
padding-left: 40px;
min-height: 28px;
display: flex;
align-items: center;
color: $subject-cream;
font-size: 13px;
transition: color $dur-quick $ease-out;
}
.dial-list li::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 24px;
height: 24px;
margin-top: -12px;
border-radius: 50%;
background: $subject-blue;
box-shadow: inset 0 0 0 2px rgba($subject-cream, .25);
}
.dial-list li::after {
content: '';
position: absolute;
left: 12px;
top: 50%;
width: 9px;
height: 2px;
margin-top: -1px;
background: $subject-cream;
transform-origin: left center; // 바늘의 회전축 — 다이얼 중심에 고정된 채 끝만 돈다
transform: rotate(var(--angle, 0deg));
transition: transform $dur-quick $ease-out;
}
.dial-list li.is-active {
color: $subject-blue-light;
}
.dial-list li.is-active::before {
box-shadow: inset 0 0 0 2px rgba($subject-cream, .25), 0 0 0 4px rgba($subject-blue, .35);
}
.advance {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.dial-list { width: 180px; gap: $sp-1; }
.dial-list li { font-size: 11px; min-height: 22px; padding-left: 32px; }
.dial-list li::before { width: 20px; height: 20px; margin-top: -10px; }
.dial-list li::after { left: 10px; }
.advance { margin-top: $sp-2; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.dial-list li::after { transition: none; }
.advance { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.dial-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: 220px;
}
.dial-list li {
position: relative;
padding-left: 40px;
min-height: 28px;
display: flex;
align-items: center;
color: #fff7e6;
font-size: 13px;
transition: color 220ms ease-out;
}
.dial-list li::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 24px;
height: 24px;
margin-top: -12px;
border-radius: 50%;
background: #2f6df6;
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.25);
}
.dial-list li::after {
content: "";
position: absolute;
left: 12px;
top: 50%;
width: 9px;
height: 2px;
margin-top: -1px;
background: #fff7e6;
transform-origin: left center;
transform: rotate(var(--angle, 0deg));
transition: transform 220ms ease-out;
}
.dial-list li.is-active {
color: #5b8cf8;
}
.dial-list li.is-active::before {
box-shadow: inset 0 0 0 2px rgba(255, 247, 230, 0.25), 0 0 0 4px rgba(47, 109, 246, 0.35);
}
.advance {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.advance:hover {
transform: translateY(-1px);
}
.advance:active {
transform: scale(0.96);
}
.advance:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.dial-list {
width: 180px;
gap: 4px;
}
.dial-list li {
font-size: 11px;
min-height: 22px;
padding-left: 32px;
}
.dial-list li::before {
width: 20px;
height: 20px;
margin-top: -10px;
}
.dial-list li::after {
left: 10px;
}
.advance {
margin-top: 8px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.dial-list li::after {
transition: none;
}
.advance {
transition: none;
}
}(function () {
var DIAL_STEP_DEG = 30; // 클릭 한 번이 바늘에 더하는 각도 — 변형 01 목표: 30 → 15
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('advance');
var isDemo = true;
// 계산 겹: 항목 i 의 바늘 각도는 그 항목이 지금까지 지나온 횟수(turns[i]) 에 자기 번호(i+1) 를 곱해 쌓인다.
// 뒤 항목일수록 같은 한 번의 전진에도 더 큰 각도를 받는다 — 단순 지연이 아니라 번호에 비례한 누적이다.
var turns = items.map(function () { return 0; });
function render() {
items.forEach(function (el, i) {
var deg = turns[i] * DIAL_STEP_DEG * (i + 1);
el.style.setProperty('--angle', (deg % 360) + 'deg');
});
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
var active = 0;
function advance() {
turns[active] += 1;
items[active].classList.add('is-active');
setTimeout(function () { items[active] && items[active].classList.remove('is-active'); }, 260);
active = (active + 1) % items.length;
render();
}
button.addEventListener('click', function () { human(); advance(); });
var last = 0;
var STEP_MS = 480;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
turns = items.map(function (_, i) { return i + 1; }); // 계산은 유지, 시간 축소 대신 종료 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
advance();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
render();
})();// DialRotateMarker — vanilla 판(run/373/demo/01-list-style-marker-dial-rotate/index.html) 의 React 포트.
// ::marker 는 transform(rotate) 을 지원하지 않아 list-style:none 으로 지우고 ::before(다이얼 원판)·
// ::after(바늘) 로 마커를 다시 그린다. 바늘 각도는 turns[i] * DIAL_STEP_DEG * (i+1) 로 항목 번호에 비례해 쌓인다.
import { useEffect, useRef } from "react";
import styles from "./DialRotateMarker.module.scss";
type Box<T> = { current: T };
const DIAL_STEP_DEG = 30; // 클릭 한 번이 바늘에 더하는 각도 — 변형 01 목표: 30 → 15
const STEP_MS = 480; // 자동 루프에서 한 단계 전진하는 간격
const STEPS = ["설계 검토", "시안 제작", "내부 검수", "고객 확인", "배포 완료"];
export default function DialRotateMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
// 계산 겹: 항목 i 의 바늘 각도는 그 항목이 지금까지 지나온 횟수(turns[i]) 에 자기 번호(i+1) 를 곱해 쌓인다.
// 뒤 항목일수록 같은 한 번의 전진에도 더 큰 각도를 받는다 — 단순 지연이 아니라 번호에 비례한 누적이다.
let turns = items.map(function () { return 0; });
function render() {
items.forEach(function (el: HTMLElement, i: number) {
const deg = turns[i] * DIAL_STEP_DEG * (i + 1);
el.style.setProperty("--angle", (deg % 360) + "deg");
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
let active = 0;
function advance() {
turns[active] += 1;
items[active].classList.add("is-active");
setTimeout(function () { items[active] && items[active].classList.remove("is-active"); }, 260);
active = (active + 1) % items.length;
render();
}
function onClick() { human(); advance(); }
root!.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
turns = items.map(function (_: HTMLElement, i: number) { return i + 1; }); // 계산은 유지, 종료 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
advance();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
render();
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ol className={styles.dialList} ref={listRef}>
{STEPS.map(function (s, i) { return <li key={s} style={{ ["--i" as string]: String(i) } as Record<string, string>}>{s}</li>; })}
</ol>
<button className={styles.advance} type="button">다음 단계로 바늘 돌리기</button>
</div>
</main>
);
}01 · ダイヤル回転マーカー — 項目番号に角度の値を積み、点が時計のように回る方式 — 計測 累積面積 2.805% · 動いたフレーム 17/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
02走行計カウンター — カウンター値を桁に分解し、数字マーカーが転がる方式
カウンター値をplace = 10^(ODO_DIGITS-1-d)で割って桁ごとに分解し、0から9までのストリップをtranslateY(-digit * DIGIT_H)で引き上げます。桁ごとの転換速度は260 + d * 220msで、高い桁(左, d=0)が260msで最も速く止まり、低い桁(右)ほど遅れて追いつきます。
数値が語ること
項目02は23フレーム中19枚で動きますが、累積面積0.19%は9項目中で最小です — 転がる数字が狭いストリップの枠の中だけを変えるためです。強度平均115.5は、面積は小さくてもピクセル差そのものは大きく出ることを示します。
// 計算層: カウンター値を10のべき乗で割って桁に分解する。桁ごとの枠はそれぞれ違う転換速度を持ち
// 低い桁から先に位置が決まり(速い)、高い桁が後から続く(遅い) — 単純なopacityフェードではなく桁ごとのロールである。
function setDigits(odo, value) {
var strips = odo.querySelectorAll('.odo-strip');
for (var d = 0; d < ODO_DIGITS; d++) {
var place = Math.pow(10, ODO_DIGITS - 1 - d);
var digit = Math.floor(value / place) % 10;
strips[d].style.transform = 'translateY(' + (-digit * DIGIT_H) + 'px)';
}
}
function speedClass(odo) {
var strips = odo.querySelectorAll('.odo-strip');
strips.forEach(function (s, d) {
// d=0(最も左=最も高い桁)が最も遅く、右(低い桁)ほど速い
var ms = 260 + d * 220;
s.style.transitionDuration = ms + 'ms';
});
}
odos.forEach(speedClass);

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ol class="rank-list" id="list">
<li data-value="8">コードレビューチャレンジ</li>
<li data-value="24">週次リリースノート</li>
<li data-value="61">新規コンポーネント</li>
</ol>
<button class="bump" id="bump" type="button">1位の順位を上げる</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 자릿수 분해(value / 10^n % 10)와 자리별 전환 속도 차등에 있다 — 여기는 창·트랙만 맡는다.
// 네이티브 ::marker 는 세로로 구를(translateY) 자릿수 스트립을 담을 수 없어(자식을 못 가진다) 실제 DOM 요소로
// 마커 자리를 대신한다 — list-style:none 으로 원 마커를 지우고 aria-hidden 장식으로만 노출한다.
@include stage($stage-yellow);
.rank-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 260px;
}
.rank-list li {
display: flex;
align-items: center;
gap: $sp-3;
padding: $sp-2 $sp-3;
background: $subject-ink;
border-radius: $r-sm;
color: $subject-cream;
font-size: 12px;
}
.odo {
display: flex;
flex-shrink: 0;
}
.odo-col {
width: 14px;
height: 18px;
overflow: hidden;
position: relative;
font-variant-numeric: tabular-nums;
}
.odo-strip {
display: flex;
flex-direction: column;
transition: transform $dur-base $ease-out;
}
.odo-digit {
height: 18px;
line-height: 18px;
text-align: center;
color: $subject-blue-light;
font-weight: 700;
font-size: 13px;
}
.bump {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.rank-list { width: 210px; }
.rank-list li { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.odo-strip { transition: none; }
.bump { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.rank-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 260px;
}
.rank-list li {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
background: #17141a;
border-radius: 8px;
color: #fff7e6;
font-size: 12px;
}
.odo {
display: flex;
flex-shrink: 0;
}
.odo-col {
width: 14px;
height: 18px;
overflow: hidden;
position: relative;
font-variant-numeric: tabular-nums;
}
.odo-strip {
display: flex;
flex-direction: column;
transition: transform 300ms ease-out;
}
.odo-digit {
height: 18px;
line-height: 18px;
text-align: center;
color: #5b8cf8;
font-weight: 700;
font-size: 13px;
}
.bump {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.bump:hover {
transform: translateY(-1px);
}
.bump:active {
transform: scale(0.96);
}
.bump:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.rank-list {
width: 210px;
}
.rank-list li {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.odo-strip {
transition: none;
}
.bump {
transition: none;
}
}(function () {
var ODO_DIGITS = 2; // 자릿수 — 변형 02 목표: 2 → 3
var DIGIT_H = 18; // 숫자 스트립 한 칸 높이(px)
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('bump');
var isDemo = true;
function buildOdometer(li) {
var odo = document.createElement('span');
odo.className = 'odo';
odo.setAttribute('aria-hidden', 'true');
for (var d = 0; d < ODO_DIGITS; d++) {
var col = document.createElement('span');
col.className = 'odo-col';
var strip = document.createElement('span');
strip.className = 'odo-strip';
for (var n = 0; n < 10; n++) {
var digit = document.createElement('span');
digit.className = 'odo-digit';
digit.textContent = String(n);
strip.appendChild(digit);
}
col.appendChild(strip);
odo.appendChild(col);
}
li.insertBefore(odo, li.firstChild);
return odo;
}
var odos = items.map(buildOdometer);
// 계산 겹: 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해한다. 각 자릿수 칸은 서로 다른 전환 속도를 가져
// 낮은 자리부터 먼저 자리를 잡고(빠름) 높은 자리가 뒤따른다(느림) — 단순 opacity 페이드가 아니라 자릿수별 롤링.
function setDigits(odo, value) {
var strips = odo.querySelectorAll('.odo-strip');
for (var d = 0; d < ODO_DIGITS; d++) {
var place = Math.pow(10, ODO_DIGITS - 1 - d);
var digit = Math.floor(value / place) % 10;
strips[d].style.transform = 'translateY(' + (-digit * DIGIT_H) + 'px)';
}
}
function speedClass(odo) {
var strips = odo.querySelectorAll('.odo-strip');
strips.forEach(function (s, d) {
// d=0(가장 왼쪽=가장 높은 자리)이 가장 느리고, 오른쪽(낮은 자리)일수록 빠르다
var ms = 260 + d * 220;
s.style.transitionDuration = ms + 'ms';
});
}
odos.forEach(speedClass);
var values = items.map(function (li) { return parseInt(li.getAttribute('data-value'), 10); });
function render() {
items.forEach(function (li, i) { setDigits(odos[i], values[i] % Math.pow(10, ODO_DIGITS)); });
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function bump() {
values[0] = (values[0] + 7) % Math.pow(10, ODO_DIGITS);
render();
}
button.addEventListener('click', function () { human(); bump(); });
var last = 0;
var STEP_MS = 620;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
values = [42, 97, 5]; // 계산은 유지, 시간 축소 대신 굴러간 결과 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
bump();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
render();
})();// OdometerCounterMarker — vanilla 판(run/373/demo/02-list-style-counter-flip-odometer/index.html) 의 React 포트.
// 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해하고, 각 자릿수 칸이 서로 다른 전환 속도로 굴러간다.
// 네이티브 ::marker 는 자식을 가질 수 없어 세로 스트립을 담지 못하므로 실제 DOM 요소로 마커 자리를 대신한다.
import { useEffect, useRef } from "react";
import styles from "./OdometerCounterMarker.module.scss";
type Box<T> = { current: T };
const ODO_DIGITS = 2; // 자릿수 — 변형 02 목표: 2 → 3
const DIGIT_H = 18; // 숫자 스트립 한 칸 높이(px)
const STEP_MS = 620; // 자동 루프에서 1위 값을 올리는 간격
const ROWS = [
{ label: "코드 리뷰 챌린지", value: 8 },
{ label: "주간 배포 노트", value: 24 },
{ label: "신규 컴포넌트", value: 61 },
];
export default function OdometerCounterMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
// 계산 겹: 카운터 값을 10 의 거듭제곱으로 나눠 자릿수로 분해한다. 각 자릿수 칸은 서로 다른 전환 속도를 가져
// 낮은 자리부터 먼저 자리를 잡고(빠름) 높은 자리가 뒤따른다(느림) — 단순 opacity 페이드가 아니라 자릿수별 롤링.
function setDigits(odo: Element, value: number) {
const strips = odo.querySelectorAll("." + (styles.odoStrip || "odo-strip"));
for (let d = 0; d < ODO_DIGITS; d++) {
const place = Math.pow(10, ODO_DIGITS - 1 - d);
const digit = Math.floor(value / place) % 10;
(strips[d] as HTMLElement).style.transform = "translateY(" + (-digit * DIGIT_H) + "px)";
}
}
function speedClass() {
items.forEach(function (li: HTMLElement) {
const odo = li.querySelector("." + (styles.odo || "odo"));
if (!odo) return;
const strips = odo.querySelectorAll("." + (styles.odoStrip || "odo-strip"));
Array.prototype.forEach.call(strips, function (s: HTMLElement, d: number) {
// d=0(가장 왼쪽=가장 높은 자리)이 가장 느리고, 오른쪽(낮은 자리)일수록 빠르다
const ms = 260 + d * 220;
s.style.transitionDuration = ms + "ms";
});
});
}
let values = ROWS.map(function (r) { return r.value; });
function render() {
items.forEach(function (li: HTMLElement, i: number) {
const odo = li.querySelector("." + (styles.odo || "odo"));
if (odo) setDigits(odo, values[i] % Math.pow(10, ODO_DIGITS));
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function bump() {
values[0] = (values[0] + 7) % Math.pow(10, ODO_DIGITS);
render();
}
function onClick() { human(); bump(); }
root!.addEventListener("click", onClick);
speedClass();
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
values = [42, 97, 5]; // 계산은 유지, 시간 축소 대신 굴러간 결과 상태로 즉시 고정
render();
} else if (ts - last >= STEP_MS) {
last = ts;
bump();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
render();
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ol className={styles.rankList} ref={listRef}>
{ROWS.map(function (r) {
return (
<li key={r.label}>
<span className={styles.odo} aria-hidden="true">
{Array.from({ length: ODO_DIGITS }).map(function (_, d) {
return (
<span className={styles.odoCol} key={d}>
<span className={styles.odoStrip}>
{Array.from({ length: 10 }).map(function (_, n) {
return <span className={styles.odoDigit} key={n}>{String(n)}</span>;
})}
</span>
</span>
);
})}
</span>
{r.label}
</li>
);
})}
</ol>
<button className={styles.bump} type="button">1위 순위 올리기</button>
</div>
</main>
);
}02 · 走行計カウンター — カウンター値を桁に分解し、数字マーカーが転がる方式 — 計測 累積面積 0.189% · 動いたフレーム 19/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
03位相波マーカー — 項目ごとに位相の値をずらし、点が波打つ方式
点の縦位置は振幅AMPLITUDE_PX(6)のサイン値で決まり、位相を項目番号の分だけ(i * PHASE_RAD)ずらすため、同じ瞬間でもリスト全体が1本の波のように上下します。SPEEDS [1, 2, 0.5]のプリセットをクリックで巡回します。
数値が語ること
項目03は23フレーム中23枚で動き、04と並んで9項目中最多です。強度平均142.2は9項目の中で唯一140を超え最も高い値です — 小さな点が振幅6pxでコントラスト高く上下するため、フレーム間のピクセル差が大きくなります。
// 計算層: 各点の縦位置は、サイン関数に項目番号の分だけ位相を押し込んだ値である。
// t(共通の時間)は流れるが、位相が違えば同じ瞬間でも高さが変わる — これが「波」の原理である。
function apply(t) {
var speed = SPEEDS[speedIdx];
items.forEach(function (el, i) {
var phase = i * PHASE_RAD;
var y = AMPLITUDE_PX * Math.sin(t * speed + phase);
el.style.setProperty('--y', y.toFixed(2) + 'px');
});
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="wave-list" id="list">
<li style="--i:0">トラック 01 · 早朝の散歩</li>
<li style="--i:1">トラック 02 · 窓際の席</li>
<li style="--i:2">トラック 03 · 遅い傘</li>
<li style="--i:3">トラック 04 · 初雪</li>
<li style="--i:4">トラック 05 · 停車場</li>
</ul>
<button class="speed" id="speed" type="button">波の速度を切り替え</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 사인 함수 위상 이동(sin(t*speed + i*phase)) 에 있다 — 여기는 불릿 모양·판만 맡는다.
// ::marker 는 transform(translateY) 을 지원하지 않아 물결을 만들 수 없다 — list-style:none 으로 지우고
// ::before 로 대체한 불릿에 커스텀 프로퍼티 --y 를 실어 세로로 흔든다.
@include stage($stage-orange);
.wave-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 240px;
}
.wave-list li {
position: relative;
padding-left: 28px;
min-height: 20px;
display: flex;
align-items: center;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
.wave-list li::before {
content: '';
position: absolute;
left: 0;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: $subject-blue-deep;
transform: translateY(var(--y, 0px));
}
.speed {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.wave-list { width: 190px; gap: $sp-1; }
.wave-list li { font-size: 11px; min-height: 16px; padding-left: 20px; }
.wave-list li::before { width: 10px; height: 10px; margin-top: -5px; }
.speed { margin-top: $sp-2; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.wave-list li::before { transition: none; }
.speed { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ff4d1f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.wave-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 240px;
}
.wave-list li {
position: relative;
padding-left: 28px;
min-height: 20px;
display: flex;
align-items: center;
color: #17141a;
font-size: 12px;
font-weight: 600;
}
.wave-list li::before {
content: "";
position: absolute;
left: 0;
top: 50%;
width: 12px;
height: 12px;
margin-top: -6px;
border-radius: 50%;
background: #0a4fe8;
transform: translateY(var(--y, 0px));
}
.speed {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.speed:hover {
transform: translateY(-1px);
}
.speed:active {
transform: scale(0.96);
}
.speed:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.wave-list {
width: 190px;
gap: 4px;
}
.wave-list li {
font-size: 11px;
min-height: 16px;
padding-left: 20px;
}
.wave-list li::before {
width: 10px;
height: 10px;
margin-top: -5px;
}
.speed {
margin-top: 8px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.wave-list li::before {
transition: none;
}
.speed {
transition: none;
}
}(function () {
var WAVE_PHASE_DEG = 40; // 항목 사이 위상차 — 변형 03 목표: 40 → 80
var AMPLITUDE_PX = 6;
var SPEEDS = [1, 2, 0.5]; // 배속 프리셋
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('speed');
var isDemo = true;
var speedIdx = 0;
var PHASE_RAD = WAVE_PHASE_DEG * Math.PI / 180;
// 계산 겹: 각 불릿의 세로 위치는 사인 함수에 항목 번호만큼 위상을 밀어 넣은 값이다.
// t(공통 시간) 는 흐르되 위상이 다르면 같은 순간에도 서로 다른 높이가 된다 — 이것이 "물결"의 원리다.
function apply(t) {
var speed = SPEEDS[speedIdx];
items.forEach(function (el, i) {
var phase = i * PHASE_RAD;
var y = AMPLITUDE_PX * Math.sin(t * speed + phase);
el.style.setProperty('--y', y.toFixed(2) + 'px');
});
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
button.addEventListener('click', function () {
human();
speedIdx = (speedIdx + 1) % SPEEDS.length;
});
var start = 0;
function loop(ts) {
if (!start) start = ts;
if (isDemo) {
if (reduce) {
apply(Math.PI / 2); // 계산은 유지, 시간 축소 대신 파고 정점 위상으로 즉시 고정
} else {
apply((ts - start) / 260);
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
apply(0);
})();// PhaseWaveMarker — vanilla 판(run/373/demo/03-list-style-marker-wave-phase/index.html) 의 React 포트.
// ::marker 는 transform(translateY) 을 지원하지 않아 물결을 만들 수 없다 — list-style:none 으로 지우고
// ::before 로 대체한 불릿에 커스텀 프로퍼티 --y 를 실어 사인 위상차로 세로로 흔든다.
import { useEffect, useRef } from "react";
import styles from "./PhaseWaveMarker.module.scss";
type Box<T> = { current: T };
const WAVE_PHASE_DEG = 40; // 인접 항목 사이 위상 간격 — 변형 03 목표: 40 → 80
const AMPLITUDE_PX = 6; // 물결 진폭 상한(px)
const SPEEDS = [1, 2, 0.5]; // 속도 프리셋 — 버튼으로 순환한다 (Math.random 금지, 결정적 배열)
const TRACKS = ["트랙 01 · 새벽 산책", "트랙 02 · 창가 자리", "트랙 03 · 늦은 우산", "트랙 04 · 첫눈", "트랙 05 · 정거장"];
export default function PhaseWaveMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
const PHASE_RAD = (WAVE_PHASE_DEG * Math.PI) / 180;
let speedIdx = 0;
let isDemo = true;
// 계산 겹: 항목 i 의 불릿 위치는 sin(t * speed + i * phase) 로 계산한다 — 같은 시각에 항목마다 위상이 달라
// 줄 전체가 하나의 파동으로 보인다. CSS transition 이 아니라 프레임마다 계산되는 값이다.
function apply(t: number) {
items.forEach(function (el: HTMLElement, i: number) {
const y = AMPLITUDE_PX * Math.sin(t * SPEEDS[speedIdx] + i * PHASE_RAD);
el.style.setProperty("--y", y.toFixed(2) + "px");
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() {
human();
speedIdx = (speedIdx + 1) % SPEEDS.length;
}
root!.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce) apply((ts - last) / 1000);
else if (reduce) apply(1.57); // 계산은 유지, 시간 축소 대신 파도 꼭대기 위상(π/2) 으로 즉시 고정
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.waveList} ref={listRef}>
{TRACKS.map(function (t) { return <li key={t}>{t}</li>; })}
</ul>
<button className={styles.speed} type="button">물결 속도 전환</button>
</div>
</main>
);
}03 · 位相波マーカー — 項目ごとに位相の値をずらし、点が波打つ方式 — 計測 累積面積 0.931% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
04行進ダッシュマーカー — オフセットの値を流し、ダッシュが行進する方式
点の枠のダッシュのオフセット角を、毎フレームacc += dt/1000 * MARCH_RATE_DEG_S(140)で経過時間に定速で積分するため、点線の枠がそのまま流れ続けます。止めてまた動かしても値が引き継がれ、流れが途切れません。
数値が語ること
項目04は23フレーム中23枚で動き、03と並んで最多です。強度平均124.9は9項目の中でも上位で、累積面積は0.42%です — 太さの細い点線の枠だけが動くため、面積そのものは小さくまとまります。
var last = 0;
var acc = 0;
function loop(ts) {
if (!last) last = ts;
var dt = ts - last;
last = ts;
if (reduce) {
apply(90); // 計算は維持、時間短縮の代わりに4分の1回転の地点へ即座に固定(自動ループ・実使用共通)
} else if (running) {
acc += dt / 1000 * MARCH_RATE_DEG_S;
apply(acc);
}
requestAnimationFrame(loop);
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="queue-list" id="list">
<li>動画エンコード待ち</li>
<li>サムネイル生成待ち</li>
<li>字幕整列待ち</li>
</ul>
<button class="toggle" id="toggle" type="button" aria-pressed="false">行進を止める</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 시간 적분(acc += dt/1000 * MARCH_RATE_DEG_S) 에 있다 — 여기는 대시 링·판만 맡는다.
// ::marker 는 border-style:dashed 를 표현할 수 없어(마커 콘텐츠 상자일 뿐) list-style:none 으로 지우고
// 실제 span(.ring) 을 마커 자리에 넣어 점선 테두리를 회전시킨다.
@include stage($stage-ink);
.queue-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 250px;
}
.queue-list li {
display: flex;
align-items: center;
gap: $sp-3;
color: $subject-cream;
font-size: 12px;
}
.ring {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px dashed $subject-blue-light;
transform: rotate(var(--offset, 0deg));
}
.toggle {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.queue-list { width: 190px; }
.queue-list li { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.toggle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.queue-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 250px;
}
.queue-list li {
display: flex;
align-items: center;
gap: 12px;
color: #fff7e6;
font-size: 12px;
}
.ring {
flex-shrink: 0;
width: 20px;
height: 20px;
border-radius: 50%;
border: 2px dashed #5b8cf8;
transform: rotate(var(--offset, 0deg));
}
.toggle {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.toggle:hover {
transform: translateY(-1px);
}
.toggle:active {
transform: scale(0.96);
}
.toggle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.queue-list {
width: 190px;
}
.queue-list li {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.toggle {
transition: none;
}
}(function () {
var MARCH_RATE_DEG_S = 140; // 초당 회전 각도 — 대시가 흐르는 속도
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var markers = Array.prototype.slice.call(list.querySelectorAll('li')).map(function (li) {
var m = document.createElement('span');
m.className = 'ring';
m.setAttribute('aria-hidden', 'true');
li.insertBefore(m, li.firstChild);
return m;
});
var button = document.getElementById('toggle');
var isDemo = true;
var running = true;
// 계산 겹: 오프셋 각도는 시간에 정속(MARCH_RATE_DEG_S)으로 누적된다. 대시 테두리를 이 각도만큼 계속
// 회전시키면 점선이 원을 따라 "흐르는" 것처럼 보인다 — 단일 keyframe 반복이 아니라 시간 적분값이다.
function apply(offsetDeg) {
markers.forEach(function (m) { m.style.setProperty('--offset', (offsetDeg % 360) + 'deg'); });
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
button.addEventListener('click', function () {
human();
running = !running;
button.setAttribute('aria-pressed', String(!running));
button.textContent = running ? '行進を止める' : '行進を再開';
});
var last = 0;
var acc = 0;
function loop(ts) {
if (!last) last = ts;
var dt = ts - last;
last = ts;
if (reduce) {
apply(90); // 계산은 유지, 시간 축소 대신 4분의1 회전 지점으로 즉시 고정 (자동 루프·실사용 공통)
} else if (running) {
acc += dt / 1000 * MARCH_RATE_DEG_S;
apply(acc);
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
apply(0);
})();// DashMarchMarker — vanilla 판(run/373/demo/04-list-style-dash-march-offset/index.html) 의 React 포트.
// ::marker 는 transform(rotate)·테두리를 지원하지 않아 링 마커를 그릴 수 없다 — 실제 span 링으로 마커를 대신하고
// 오프셋 각도를 시간 적분해 대시가 원을 따라 흐르게 만든다.
import { useEffect, useRef } from "react";
import styles from "./DashMarchMarker.module.scss";
type Box<T> = { current: T };
const MARCH_RATE_DEG_S = 140; // 초당 회전 각도 — 대시가 흐르는 속도
const QUEUES = ["영상 인코딩 대기", "썸네일 생성 대기", "자막 정렬 대기"];
export default function DashMarchMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const buttonRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
const button = buttonRef.current;
if (!root || !list || !button) return;
const markers = Array.prototype.slice.call(list.querySelectorAll("li")).map(function (li: HTMLElement) {
const m = document.createElement("span");
m.className = styles.ring;
m.setAttribute("aria-hidden", "true");
li.insertBefore(m, li.firstChild);
return m;
});
let isDemo = true;
let running = true;
// 계산 겹: 오프셋 각도는 시간에 정속(MARCH_RATE_DEG_S)으로 누적된다. 대시 테두리를 이 각도만큼 계속
// 회전시키면 점선이 원을 따라 "흐르는" 것처럼 보인다 — 단일 keyframe 반복이 아니라 시간 적분값이다.
function apply(offsetDeg: number) {
markers.forEach(function (m: HTMLElement) { m.style.setProperty("--offset", (offsetDeg % 360) + "deg"); });
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() {
human();
running = !running;
button!.setAttribute("aria-pressed", String(!running));
button!.textContent = running ? "행진 멈추기" : "행진 다시 시작";
}
button.addEventListener("click", onClick);
let last = 0;
let acc = 0;
function loop(ts: number) {
if (!last) last = ts;
const dt = ts - last;
last = ts;
if (reduce) {
apply(90); // 계산은 유지, 시간 축소 대신 4분의1 회전 지점으로 즉시 고정 (자동 루프·실사용 공통)
} else if (running) {
acc += dt / 1000 * MARCH_RATE_DEG_S;
apply(acc);
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
apply(0);
return () => {
button.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.queueList} ref={listRef}>
{QUEUES.map(function (q) { return <li key={q}>{q}</li>; })}
</ul>
<button className={styles.toggle} ref={buttonRef} type="button" aria-pressed="false">행진 멈추기</button>
</div>
</main>
);
}04 · 行進ダッシュマーカー — オフセットの値を流し、ダッシュが行進する方式 — 計測 累積面積 0.421% · 動いたフレーム 23/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
05素数グローマーカー — 素数判定の値を計算し、該当番号だけが光る方式
項目番号ごとにisPrime()の割り算判定を直接やり直し、通過した番号のマーカーだけがグローを放ちます。開始番号を動かすたびに判定は最初から回り、番号はlist-style: noneで消してからcounter-reset・counter-incrementとcontent: counter(item)の値で::beforeに描き直します。
数値が語ること
項目05は23フレーム中20枚で動き、累積面積2.42%は9項目中で01に次いで2番目に大きい値です。強度平均27.8は9項目の中で最も低く、アルファで広がるグローの低コントラストな変化がフレーム間の差を小さくしています。
// 計算層: 各項目番号に素数判定を直接計算する — あらかじめ決めた一覧ではなく、実行のたびに再び回る。
function isPrime(n) {
if (n < 2) return false;
for (var d = 2; d * d <= n; d++) {
if (n % d === 0) return false;
}
return true;
}
function recompute() {
list.setAttribute('start', String(start));
items.forEach(function (li, i) {
var n = start + i;
li.classList.toggle('is-prime', isPrime(n));
});
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ol class="prime-list" id="list" start="1">
<li>変数と型</li>
<li>条件と繰り返し</li>
<li>関数とスコープ</li>
<li>配列とオブジェクト</li>
<li>非同期処理</li>
<li>モジュールシステム</li>
<li>テスト作成</li>
</ol>
<button class="shift" id="shift" type="button">開始番号を1つ動かす</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 isPrime() 시행 나눗셈 판정에 있다 — 여기는 번호 상자·글로우 애니메이션만 맡는다.
// ::marker 는 box-shadow(글로우)·animation 을 지원하지 않는다(color·content·font 뿐) — list-style:none 으로
// 네이티브 번호를 지우고 counter() 값을 ::before 콘텐츠로 다시 그려 그 위에 글로우를 건다.
@include stage($stage-paper);
.prime-list {
list-style: none;
counter-reset: item;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-2;
width: 230px;
}
.prime-list li {
counter-increment: item;
position: relative;
padding-left: 32px;
min-height: 22px;
display: flex;
align-items: center;
color: $subject-ink;
font-size: 12px;
}
.prime-list li::before {
content: counter(item);
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
margin-top: -11px;
border-radius: 50%;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 0 rgba($subject-blue-deep, 0);
}
.prime-list li.is-prime::before {
animation: primePulse $dur-loop $ease-out infinite;
}
@keyframes primePulse {
0%, 100% { box-shadow: 0 0 0 0 rgba($subject-blue-deep, .55); }
50% { box-shadow: 0 0 0 7px rgba($subject-blue-deep, 0); }
}
.shift {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-blue-deep;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.prime-list {
width: 260px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: $sp-1;
}
.prime-list li { font-size: 10px; min-height: 20px; padding-left: 24px; }
.prime-list li::before { width: 18px; height: 18px; margin-top: -9px; font-size: 10px; }
.shift { margin-top: $sp-2; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.prime-list li.is-prime::before { animation: none; box-shadow: 0 0 0 4px rgba($subject-blue-deep, .35); }
.shift { transition: none; }
}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);
}
.prime-list {
list-style: none;
counter-reset: item;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
width: 230px;
}
.prime-list li {
counter-increment: item;
position: relative;
padding-left: 32px;
min-height: 22px;
display: flex;
align-items: center;
color: #17141a;
font-size: 12px;
}
.prime-list li::before {
content: counter(item);
position: absolute;
left: 0;
top: 50%;
width: 22px;
height: 22px;
margin-top: -11px;
border-radius: 50%;
background: #0a4fe8;
color: #fff7e6;
font-size: 11px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 0 0 0 rgba(10, 79, 232, 0);
}
.prime-list li.is-prime::before {
animation: primePulse 2s ease-out infinite;
}
@keyframes primePulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(10, 79, 232, 0.55);
}
50% {
box-shadow: 0 0 0 7px rgba(10, 79, 232, 0);
}
}
.shift {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #0a4fe8;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.shift:hover {
transform: translateY(-1px);
}
.shift:active {
transform: scale(0.96);
}
.shift:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.prime-list {
width: 260px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.prime-list li {
font-size: 10px;
min-height: 20px;
padding-left: 24px;
}
.prime-list li::before {
width: 18px;
height: 18px;
margin-top: -9px;
font-size: 10px;
}
.shift {
margin-top: 8px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.prime-list li.is-prime::before {
animation: none;
box-shadow: 0 0 0 4px rgba(10, 79, 232, 0.35);
}
.shift {
transition: none;
}
}(function () {
var GLOW_PULSE_MS = 1100;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('shift');
var isDemo = true;
var start = 1;
// 계산 겹: 각 항목 번호에 소수 판정을 직접 계산한다 — 미리 정해둔 목록이 아니라 실행마다 다시 돈다.
function isPrime(n) {
if (n < 2) return false;
for (var d = 2; d * d <= n; d++) {
if (n % d === 0) return false;
}
return true;
}
function recompute() {
list.setAttribute('start', String(start));
items.forEach(function (li, i) {
var n = start + i;
li.classList.toggle('is-prime', isPrime(n));
});
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
button.addEventListener('click', function () {
human();
start += 1;
recompute();
});
var last = 0;
var STEP_MS = 900;
function loop(ts) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
start += 1;
recompute();
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
recompute();
})();// PrimeGlowMarker — vanilla 판(run/373/demo/05-list-style-number-prime-glow/index.html) 의 React 포트.
// ::marker 는 box-shadow(글로우)·animation 을 지원하지 않는다(color·content·font 뿐) — counter() 값을
// ::before 콘텐츠로 다시 그려 그 위에 글로우를 건다. 소수 판정은 매번 직접 계산한다.
import { useEffect, useRef } from "react";
import styles from "./PrimeGlowMarker.module.scss";
type Box<T> = { current: T };
const STEP_MS = 900; // 자동 루프에서 시작 번호를 한 칸 옮기는 간격
const LESSONS = ["변수와 타입", "조건과 반복", "함수와 스코프", "배열과 객체", "비동기 처리", "모듈 시스템", "테스트 작성"];
export default function PrimeGlowMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLOListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
let start = 1;
// 계산 겹: 각 항목 번호에 소수 판정을 직접 계산한다 — 미리 정해둔 목록이 아니라 실행마다 다시 돈다.
function isPrime(n: number) {
if (n < 2) return false;
for (let d = 2; d * d <= n; d++) {
if (n % d === 0) return false;
}
return true;
}
function recompute() {
list!.setAttribute("start", String(start));
items.forEach(function (li: HTMLElement, i: number) {
const n = start + i;
li.classList.toggle("is-prime", isPrime(n));
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function onClick() {
human();
start += 1;
recompute();
}
root!.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo && !reduce && ts - last >= STEP_MS) {
last = ts;
start += 1;
recompute();
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
recompute();
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ol className={styles.primeList} ref={listRef} start={1}>
{LESSONS.map(function (l) { return <li key={l}>{l}</li>; })}
</ol>
<button className={styles.shift} type="button">시작 번호 한 칸 이동</button>
</div>
</main>
);
}05 · 素数グローマーカー — 素数判定の値を計算し、該当番号だけが光る方式 — 計測 累積面積 2.419% · 動いたフレーム 20/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
06サイズ順位マーカー — 値に比例してマーカーの面積を計算する方式
マーカーの半径をfactor = Math.sqrt(v) / Math.sqrt(max)の平方根比例で決めるため、面積が値に比例して見えます — 線形比例だと大きな値のマーカーが誇張されて見えてしまいます。集計値はMath.randomなしの決定論的なプリセットをSTEP_MS(550)ごとに巡回します。
数値が語ること
項目06は23フレーム中15枚で動き、累積面積1.28%、強度平均98.1です。マーカーの大きさが変わる局所的な変化のため、面積・強度とも9項目の中間に位置します。
// 計算層: マーカー半径は値の平方根に比例する(面積が値に比例するように) — 線形比例は大きな値が誇張されて見える。
function apply(set) {
var max = Math.max.apply(null, Object.keys(set).map(function (k) { return set[k]; }));
items.forEach(function (li, i) {
var key = li.getAttribute('data-key');
var v = set[key];
var factor = Math.sqrt(v) / Math.sqrt(max);
markers[i].style.setProperty('--scale', factor.toFixed(3));
});
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="stat-list" id="list">
<li data-key="a">朝のルーティンガイド</li>
<li data-key="b">30日間ホームトレーニング</li>
<li data-key="c">食事記録メソッド</li>
<li data-key="d">睡眠改善ノート</li>
</ul>
<button class="cycle" id="cycle" type="button">次の集計値を見る</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 제곱근 비례(sqrt(v)/sqrt(max)) 에 있다 — 여기는 칩 기본 크기·판만 맡는다.
// ::marker 는 transform(scale) 을 지원하지 않아 면적을 키울 수 없다 — list-style:none 으로 지우고
// 실제 span(.chip) 을 마커 자리에 넣어 항상 최대 지름을 기준으로 scale() 만 줄인다(가장자리 좌표가 안 밀린다).
@include stage($stage-yellow);
.stat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 240px;
}
.stat-list li {
display: flex;
align-items: center;
gap: $sp-3;
color: $subject-ink;
font-size: 12px;
font-weight: 600;
}
.chip {
flex-shrink: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.chip::before {
content: '';
width: 30px;
height: 30px;
border-radius: 50%;
background: $subject-blue;
transform: scale(var(--scale, 1));
transition: transform $dur-base $ease-out;
}
.cycle {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-ink;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.stat-list { width: 190px; gap: $sp-1; }
.stat-list li { font-size: 11px; }
.chip, .chip::before { width: 22px; height: 22px; }
.cycle { margin-top: $sp-2; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.chip::before { transition: none; }
.cycle { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #ffd23f;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.stat-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 240px;
}
.stat-list li {
display: flex;
align-items: center;
gap: 12px;
color: #17141a;
font-size: 12px;
font-weight: 600;
}
.chip {
flex-shrink: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
}
.chip::before {
content: "";
width: 30px;
height: 30px;
border-radius: 50%;
background: #2f6df6;
transform: scale(var(--scale, 1));
transition: transform 300ms ease-out;
}
.cycle {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #17141a;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.cycle:hover {
transform: translateY(-1px);
}
.cycle:active {
transform: scale(0.96);
}
.cycle:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.stat-list {
width: 190px;
gap: 4px;
}
.stat-list li {
font-size: 11px;
}
.chip, .chip::before {
width: 22px;
height: 22px;
}
.cycle {
margin-top: 8px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.chip::before {
transition: none;
}
.cycle {
transition: none;
}
}(function () {
var R_MAX = 15; // 최대 값을 가진 마커의 반경(px) — scale() 기준 반경
// 결정적 집계 프리셋 3세트를 순환한다(Math.random 미사용)
var SETS = [
{ a: 82, b: 64, c: 47, d: 20 },
{ a: 30, b: 90, c: 55, d: 65 },
{ a: 58, b: 22, c: 88, d: 41 }
];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var markers = items.map(function (li) {
var m = document.createElement('span');
m.className = 'chip';
m.setAttribute('aria-hidden', 'true');
li.insertBefore(m, li.firstChild);
return m;
});
var button = document.getElementById('cycle');
var isDemo = true;
var setIdx = 0;
// 계산 겹: 마커 반경은 값의 제곱근에 비례한다(면적이 값에 비례하도록) — 선형 비례는 큰 값이 과장돼 보인다.
function apply(set) {
var max = Math.max.apply(null, Object.keys(set).map(function (k) { return set[k]; }));
items.forEach(function (li, i) {
var key = li.getAttribute('data-key');
var v = set[key];
var factor = Math.sqrt(v) / Math.sqrt(max);
markers[i].style.setProperty('--scale', factor.toFixed(3));
});
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function next() {
setIdx = (setIdx + 1) % SETS.length;
apply(SETS[setIdx]);
}
button.addEventListener('click', function () { human(); next(); });
var last = 0;
var STEP_MS = 550;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
apply(SETS[1]); // 계산은 유지, 시간 축소 대신 두 번째 집계 상태로 즉시 고정
} else if (ts - last >= STEP_MS) {
last = ts;
next();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
apply(SETS[0]);
})();// SizeRankMarker — vanilla 판(run/373/demo/06-list-style-marker-size-rank/index.html) 의 React 포트.
// ::marker 는 transform(scale) 을 지원하지 않아 면적을 키울 수 없다 — list-style:none 으로 지우고
// 실제 span(.chip) 을 마커 자리에 넣어 항상 최대 지름을 기준으로 scale() 만 줄인다.
import { useEffect, useRef } from "react";
import styles from "./SizeRankMarker.module.scss";
type Box<T> = { current: T };
// 결정적 집계 프리셋 3세트를 순환한다(Math.random 미사용)
const SETS: Array<{ a: number; b: number; c: number; d: number }> = [
{ a: 82, b: 64, c: 47, d: 20 },
{ a: 30, b: 90, c: 55, d: 65 },
{ a: 58, b: 22, c: 88, d: 41 },
];
const STEP_MS = 550; // 자동 루프에서 다음 집계로 넘어가는 간격
const NOTES: Array<{ key: string; label: string }> = [
{ key: "a", label: "아침 루틴 가이드" },
{ key: "b", label: "홈트레이닝 30일" },
{ key: "c", label: "식단 기록법" },
{ key: "d", label: "수면 개선 노트" },
];
export default function SizeRankMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
const markers = items.map(function (li: HTMLElement) {
const m = document.createElement("span");
m.className = styles.chip;
m.setAttribute("aria-hidden", "true");
li.insertBefore(m, li.firstChild);
return m;
});
let isDemo = true;
let setIdx = 0;
// 계산 겹: 마커 반경은 값의 제곱근에 비례한다(면적이 값에 비례하도록) — 선형 비례는 큰 값이 과장돼 보인다.
function apply(set: { a: number; b: number; c: number; d: number }) {
const max = Math.max.apply(null, Object.keys(set).map(function (k) { return (set as Record<string, number>)[k]; }));
items.forEach(function (li: HTMLElement, i: number) {
const key = li.getAttribute("data-key") || "a";
const v = (set as Record<string, number>)[key];
const factor = Math.sqrt(v) / Math.sqrt(max);
markers[i].style.setProperty("--scale", factor.toFixed(3));
});
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function next() {
setIdx = (setIdx + 1) % SETS.length;
apply(SETS[setIdx]);
}
function onClick() { human(); next(); }
root!.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
apply(SETS[1]); // 계산은 유지, 시간 축소 대신 두 번째 집계 상태로 즉시 고정
} else if (ts - last >= STEP_MS) {
last = ts;
next();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
apply(SETS[0]);
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.statList} ref={listRef}>
{NOTES.map(function (n) { return <li key={n.key} data-key={n.key}>{n.label}</li>; })}
</ul>
<button className={styles.cycle} type="button">다음 집계 값 보기</button>
</div>
</main>
);
}06 · サイズ順位マーカー — 値に比例してマーカーの面積を計算する方式 — 計測 累積面積 1.283% · 動いたフレーム 15/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
07階層レールマーカー — 深さの値でインデントレールを計算する方式
ネストした<ul>それぞれにdata-depthを持たせ、revealed >= depthという比較式1つで全レールの表示可否を即座に更新します — レールごとの個別タイマーではありません。レール線は::beforeをscaleY(0)からscaleY(1)へ伸ばします。
数値が語ること
項目07は23フレーム中10枚しか動かず、09と並んで9項目中最少です。累積面積は0.93%で表では03と同じ値に見えますが、測定の小数では03(0.931)がわずかに大きく、07は0.93という値です。強度平均47.0は9項目の中でも低めです。
// 計算層: レールそれぞれの表示可否は「今公開されている深さ ≥ このレールの深さ」という比較式の結果である。
// 値ひとつ(revealed)が変わると、それより浅いレールは全部即座に更新される — レールごとの個別タイマーではない。
function apply() {
tree.style.setProperty('--revealed', String(revealed));
rails.forEach(function (rail) {
var d = parseInt(rail.getAttribute('data-depth'), 10);
rail.classList.toggle('is-open', revealed >= d);
});
button.setAttribute('aria-expanded', String(revealed >= MAX_DEPTH));
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="tree" id="tree">
<li>サイトマップ
<ul class="rail" data-depth="1">
<li>製品
<ul class="rail" data-depth="2">
<li>価格案内</li>
<li>導入事例</li>
</ul>
</li>
<li>カスタマーサポート</li>
</ul>
</li>
<li>会社紹介</li>
</ul>
<button class="reveal" id="reveal" type="button">深さをもう1段表示</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 비교식(revealed >= depth) 에 있다 — 여기는 레일 선·판만 맡는다.
// ::marker 는 transform(scaleY) 을 지원하지 않아 레일을 늘일 수 없다 — 중첩 <ul> 자체에 ::before 레일을
// 붙이고, 각 <li> 의 네이티브 마커는 list-style:none 으로 지운 뒤 깊이별 색 점으로 대체한다.
@include stage($stage-ink);
.tree, .rail {
list-style: none;
margin: 0;
padding: 0;
}
.tree { width: 220px; }
.tree > li {
color: $subject-cream;
font-size: 13px;
font-weight: 600;
margin-bottom: $sp-2;
}
.rail {
position: relative;
margin-top: $sp-2;
padding-left: $sp-4;
}
.rail::before {
content: '';
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 2px;
background: $subject-blue-light;
transform-origin: top;
transform: scaleY(0);
transition: transform $dur-base $ease-out;
}
.rail.is-open::before {
transform: scaleY(1);
}
.rail li {
position: relative;
padding-left: $sp-3;
color: $subject-cream;
font-size: 12px;
font-weight: 400;
opacity: .72; // 접힌 상태 — BD25 흐린 글자 알파 하한(0.72) 에 맞춘다
transition: opacity $dur-base $ease-out;
}
.rail.is-open > li {
opacity: 1;
}
.rail li::before {
content: '';
position: absolute;
left: -3px;
top: 9px;
width: 5px;
height: 5px;
border-radius: 50%;
background: $subject-blue-light;
}
.reveal {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.tree { width: 170px; }
.tree > li { font-size: 12px; margin-bottom: $sp-1; }
.rail { margin-top: $sp-1; padding-left: $sp-3; }
.rail li { font-size: 10px; padding-left: $sp-2; }
.reveal { margin-top: $sp-2; padding: $sp-1 $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.rail::before { transition: none; }
.rail li { transition: none; }
.reveal { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.tree, .rail {
list-style: none;
margin: 0;
padding: 0;
}
.tree {
width: 220px;
}
.tree > li {
color: #fff7e6;
font-size: 13px;
font-weight: 600;
margin-bottom: 8px;
}
.rail {
position: relative;
margin-top: 8px;
padding-left: 16px;
}
.rail::before {
content: "";
position: absolute;
left: 3px;
top: 0;
bottom: 0;
width: 2px;
background: #5b8cf8;
transform-origin: top;
transform: scaleY(0);
transition: transform 300ms ease-out;
}
.rail.is-open::before {
transform: scaleY(1);
}
.rail li {
position: relative;
padding-left: 12px;
color: #fff7e6;
font-size: 12px;
font-weight: 400;
opacity: 0.72;
transition: opacity 300ms ease-out;
}
.rail.is-open > li {
opacity: 1;
}
.rail li::before {
content: "";
position: absolute;
left: -3px;
top: 9px;
width: 5px;
height: 5px;
border-radius: 50%;
background: #5b8cf8;
}
.reveal {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.reveal:hover {
transform: translateY(-1px);
}
.reveal:active {
transform: scale(0.96);
}
.reveal:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.tree {
width: 170px;
}
.tree > li {
font-size: 12px;
margin-bottom: 4px;
}
.rail {
margin-top: 4px;
padding-left: 12px;
}
.rail li {
font-size: 10px;
padding-left: 8px;
}
.reveal {
margin-top: 8px;
padding: 4px 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.rail::before {
transition: none;
}
.rail li {
transition: none;
}
.reveal {
transition: none;
}
}(function () {
var MAX_DEPTH = 2;
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var tree = document.getElementById('tree');
var rails = Array.prototype.slice.call(tree.querySelectorAll('.rail'));
var button = document.getElementById('reveal');
var isDemo = true;
var revealed = 0;
// 계산 겹: 레일 각각의 표시 여부는 "지금 공개된 깊이 ≥ 이 레일의 깊이" 라는 비교식의 결과다.
// 값 하나(revealed)가 바뀌면 그보다 얕은 레일은 전부 즉시 갱신된다 — 레일마다 별도 타이머가 아니다.
function apply() {
tree.style.setProperty('--revealed', String(revealed));
rails.forEach(function (rail) {
var d = parseInt(rail.getAttribute('data-depth'), 10);
rail.classList.toggle('is-open', revealed >= d);
});
button.setAttribute('aria-expanded', String(revealed >= MAX_DEPTH));
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function step() {
revealed = (revealed + 1) % (MAX_DEPTH + 1);
apply();
}
button.addEventListener('click', function () { human(); step(); });
var last = 0;
var STEP_MS = 750;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
revealed = MAX_DEPTH; // 계산은 유지, 시간 축소 대신 완전히 펼쳐진 상태로 즉시 고정
apply();
} else if (ts - last >= STEP_MS) {
last = ts;
step();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
apply();
})();// NestedIndentRailMarker — vanilla 판(run/373/demo/07-list-style-nested-indent-rail/index.html) 의 React 포트.
// ::marker 는 transform(scaleY) 을 지원하지 않아 레일을 늘일 수 없다 — 중첩 <ul> 자체에 ::before 레일을 붙이고
// 깊이 값과 공개 깊이의 비교식으로 레일 전체를 한 번에 갱신한다.
import { useEffect, useRef } from "react";
import styles from "./NestedIndentRailMarker.module.scss";
type Box<T> = { current: T };
const STEP_MS = 750; // 자동 루프에서 깊이 한 단계를 더 보이는 간격
export default function NestedIndentRailMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const treeRef = useRef(null) as unknown as Box<HTMLUListElement>;
const buttonRef = useRef(null) as unknown as Box<HTMLButtonElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const MAX_DEPTH = 2;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const tree = treeRef.current;
const button = buttonRef.current;
if (!root || !tree || !button) return;
const rails = Array.prototype.slice.call(tree.querySelectorAll("." + (styles.rail || "rail")));
let isDemo = true;
let revealed = 0;
// 계산 겹: 레일 각각의 표시 여부는 "지금 공개된 깊이 ≥ 이 레일의 깊이" 라는 비교식의 결과다.
// 값 하나(revealed)가 바뀌면 그보다 얕은 레일은 전부 즉시 갱신된다 — 레일마다 별도 타이머가 아니다.
function apply() {
tree!.style.setProperty("--revealed", String(revealed));
rails.forEach(function (rail: HTMLElement) {
const d = parseInt(rail.getAttribute("data-depth") || "1", 10);
rail.classList.toggle("is-open", revealed >= d);
});
button!.setAttribute("aria-expanded", String(revealed >= MAX_DEPTH));
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function step() {
revealed = (revealed + 1) % (MAX_DEPTH + 1);
apply();
}
function onClick() { human(); step(); }
button.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
revealed = MAX_DEPTH; // 계산은 유지, 시간 축소 대신 완전히 펼쳐진 상태로 즉시 고정
apply();
} else if (ts - last >= STEP_MS) {
last = ts;
step();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
apply();
return () => {
button.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.tree} ref={treeRef}>
<li>사이트맵
<ul className={styles.rail} data-depth="1">
<li>제품
<ul className={styles.rail} data-depth="2">
<li>가격 안내</li>
<li>도입 사례</li>
</ul>
</li>
<li>고객지원</li>
</ul>
</li>
<li>회사 소개</li>
</ul>
<button className={styles.reveal} ref={buttonRef} type="button" aria-expanded="false">깊이 한 단계 더 보기</button>
</div>
</main>
);
}07 · 階層レールマーカー — 深さの値でインデントレールを計算する方式 — 計測 累積面積 0.93% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
08ハイブリッド入れ替えマーカー — 状態の値でマーカーの種類が変わる方式
状態ごとの直径SIZE_BY_STATE [14, 20, 24]に対してthicknessFor(size) = Math.max(1.5, BASE_AREA / (size * size) * 3)の面積保存式で太さを逆算するため、マーカーが大きくなるほど線が細くなり、状態が変わっても見た目の重さが噛み合います。マーカーの種類は状態ごとに点・ダイヤ・チェックへ切り替わります。
数値が語ること
項目08は23フレーム中11枚で動き、09に次いで少ない部類です。累積面積1.21%、強度平均109.4で、マーカーの大きさと形が同時に変わる局所的な変化がこの値を作ります。
// 計算層: 太さは「面積保存」式(BASE_AREA / size)で逆算する — 大きくなるマーカーほど線が細くなり
// 視覚的な重さが互いに噛み合う。任意の固定太さ3種を並べたものではない。
function thicknessFor(size) {
return Math.max(1.5, BASE_AREA / (size * size) * 3);
}
function apply(li) {
var state = parseInt(li.getAttribute('data-state'), 10);
var btn = li.querySelector('.marker');
var size = SIZE_BY_STATE[state];
var thick = thicknessFor(size);
btn.style.setProperty('--size', size + 'px');
btn.style.setProperty('--thick', thick.toFixed(2) + 'px');
btn.className = 'marker state-' + state;
btn.setAttribute('aria-label', '状態切替: ' + STATES[state]);
btn.textContent = STATES[state];
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="task-list" id="list">
<li data-state="0"><button class="marker" type="button" aria-label="状態切替: 待機中">待機中</button><span class="label">デザイン案の承認</span></li>
<li data-state="1"><button class="marker" type="button" aria-label="状態切替: 進行中">進行中</button><span class="label">バナーアニメーション制作</span></li>
<li data-state="2"><button class="marker" type="button" aria-label="状態切替: 完了">完了</button><span class="label">コピーライティング校正</span></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 면적 보존 역산(BASE_AREA / size^2 * 3) 에 있다 — 여기는 세 형태(점·다이아·체크)만 맡는다.
// ::marker 는 border·transform(rotate)·다중 셀렉터(::after) 를 함께 쓸 수 없어 상태에 따라 모양 자체가
// 바뀌는 마커를 표현하지 못한다 — list-style:none 으로 지우고 실제 <button> 을 마커 자리에 둔다(클릭 가능해야 하므로
// 애초에 대화형 요소가 필요하다 — 장식 pseudo 로는 aria 상태를 못 준다).
@include stage($stage-paper);
.task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 250px;
}
.task-list li {
display: flex;
align-items: center;
gap: $sp-3;
}
.marker {
position: relative;
width: 28px;
height: 28px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
padding: 0;
margin: 0;
cursor: pointer;
color: transparent;
font-size: 0; // 접근성 이름은 aria-label·버튼 텍스트가 맡고, 화면엔 모양(pseudo)만 보인다
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 3px; }
}
.marker::before,
.marker::after {
content: '';
position: absolute;
transition: width $dur-base $ease-out, height $dur-base $ease-out,
border-width $dur-base $ease-out, transform $dur-base $ease-out, opacity $dur-quick $ease-out;
}
// state-0: 점 — ::before 원, ::after 는 숨김
.marker.state-0::before {
left: 50%; top: 50%;
width: var(--size); height: var(--size);
margin-left: calc(var(--size) / -2);
margin-top: calc(var(--size) / -2);
border-radius: 50%;
background: $subject-blue-light;
}
.marker.state-0::after { opacity: 0; width: 0; height: 0; }
// state-1: 다이아몬드 — ::before 회전한 정사각 테두리(두께는 면적 보존 값)
.marker.state-1::before {
left: 50%; top: 50%;
width: var(--size); height: var(--size);
margin-left: calc(var(--size) / -2);
margin-top: calc(var(--size) / -2);
border-radius: $r-xs;
border: var(--thick) solid $subject-blue;
background: transparent;
transform: rotate(45deg);
}
.marker.state-1::after { opacity: 0; width: 0; height: 0; }
// state-2: 체크 — ::before(짧은 막대)·::after(긴 막대) 가 같은 꼭짓점에서 출발한다 (BD16)
.marker.state-2::before,
.marker.state-2::after {
left: 30%;
top: 55%;
height: var(--thick);
background: $subject-mint;
border-radius: $r-pill;
transform-origin: left center;
}
.marker.state-2::before {
width: calc(var(--size) * .45);
transform: rotate(225deg);
}
.marker.state-2::after {
width: calc(var(--size) * .8);
transform: rotate(-52deg);
}
.label {
color: $subject-ink;
font-size: 12px;
}
@media (max-width: 340px) {
.task-list { width: 200px; }
.label { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.marker::before,
.marker::after { transition: none; }
}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);
}
.task-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 250px;
}
.task-list li {
display: flex;
align-items: center;
gap: 12px;
}
.marker {
position: relative;
width: 28px;
height: 28px;
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
padding: 0;
margin: 0;
cursor: pointer;
color: transparent;
font-size: 0;
}
.marker:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 3px;
}
.marker::before,
.marker::after {
content: "";
position: absolute;
transition: width 300ms ease-out, height 300ms ease-out, border-width 300ms ease-out, transform 300ms ease-out, opacity 220ms ease-out;
}
.marker.state-0::before {
left: 50%;
top: 50%;
width: var(--size);
height: var(--size);
margin-left: calc(var(--size) / -2);
margin-top: calc(var(--size) / -2);
border-radius: 50%;
background: #5b8cf8;
}
.marker.state-0::after {
opacity: 0;
width: 0;
height: 0;
}
.marker.state-1::before {
left: 50%;
top: 50%;
width: var(--size);
height: var(--size);
margin-left: calc(var(--size) / -2);
margin-top: calc(var(--size) / -2);
border-radius: 4px;
border: var(--thick) solid #2f6df6;
background: transparent;
transform: rotate(45deg);
}
.marker.state-1::after {
opacity: 0;
width: 0;
height: 0;
}
.marker.state-2::before,
.marker.state-2::after {
left: 30%;
top: 55%;
height: var(--thick);
background: #4cd4a6;
border-radius: 999px;
transform-origin: left center;
}
.marker.state-2::before {
width: calc(var(--size) * 0.45);
transform: rotate(225deg);
}
.marker.state-2::after {
width: calc(var(--size) * 0.8);
transform: rotate(-52deg);
}
.label {
color: #17141a;
font-size: 12px;
}
@media (max-width: 340px) {
.task-list {
width: 200px;
}
.label {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.marker::before,
.marker::after {
transition: none;
}
}(function () {
var BASE_AREA = 400; // 마커 기준 면적(px^2) — 두께는 면적/지름 으로 역산해 커질수록 얇아진다
var STATES = ['待機中', '進行中', '完了'];
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var rows = Array.prototype.slice.call(list.querySelectorAll('li'));
var isDemo = true;
var SIZE_BY_STATE = [14, 20, 24]; // 대기(점) < 진행(다이아) < 完了(체크) 지름
// 계산 겹: 두께는 "면적 보존" 식(BASE_AREA / size)으로 역산한다 — 커지는 마커일수록 선이 얇아져
// 시각적 무게가 서로 비슷하게 맞물린다. 임의의 고정 두께 3종을 나열한 것이 아니다.
function thicknessFor(size) {
return Math.max(1.5, BASE_AREA / (size * size) * 3);
}
function apply(li) {
var state = parseInt(li.getAttribute('data-state'), 10);
var btn = li.querySelector('.marker');
var size = SIZE_BY_STATE[state];
var thick = thicknessFor(size);
btn.style.setProperty('--size', size + 'px');
btn.style.setProperty('--thick', thick.toFixed(2) + 'px');
btn.className = 'marker state-' + state;
btn.setAttribute('aria-label', '状態切替: ' + STATES[state]);
btn.textContent = STATES[state];
}
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function advance(li) {
var state = (parseInt(li.getAttribute('data-state'), 10) + 1) % 3;
li.setAttribute('data-state', String(state));
apply(li);
}
rows.forEach(function (li) {
li.querySelector('.marker').addEventListener('click', function () {
human();
advance(li);
});
apply(li);
});
var last = 0;
var STEP_MS = 700;
var cursor = 0;
function loop(ts) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
rows.forEach(function (li, i) { li.setAttribute('data-state', String(i % 3)); apply(li); }); // 계산은 유지, 시간 축소 대신 서로 다른 상태로 즉시 고정
} else if (ts - last >= STEP_MS) {
last = ts;
advance(rows[cursor]);
cursor = (cursor + 1) % rows.length;
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// HybridSwapMarker — vanilla 판(run/373/demo/08-list-style-hybrid-marker-swap/index.html) 의 React 포트.
// ::marker 는 자식·box-shadow 를 가질 수 없어 점·다이아·체크 모양 마커를 그릴 수 없다 — 실제 <button class="marker">
// 로 마커 자리를 대신하고, 상태마다 크기와 두께를 면적 보존 식으로 함께 바꾼다.
import { useEffect, useRef } from "react";
import styles from "./HybridSwapMarker.module.scss";
type Box<T> = { current: T };
const BASE_AREA = 400; // 마커 기준 면적(px^2) — 두께는 면적/지름 으로 역산해 커질수록 얇아진다
const SIZE_BY_STATE = [14, 20, 24]; // 대기(점) < 진행(다이아) < 완료(체크) 지름
const STATES = ["대기 중", "진행 중", "완료"];
const STEP_MS = 700; // 자동 루프에서 다음 행의 상태를 넘기는 간격
const TASKS = [
{ label: "디자인 시안 승인", state: 0 },
{ label: "배너 애니메이션 제작", state: 1 },
{ label: "문구 교정", state: 2 },
];
export default function HybridSwapMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const rows = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
// 계산 겹: 두께는 "면적 보존" 식(BASE_AREA / size)으로 역산한다 — 커지는 마커일수록 선이 얇아져
// 시각적 무게가 서로 비슷하게 맞물린다. 임의의 고정 두께 3종을 나열한 것이 아니다.
function thicknessFor(size: number) {
return Math.max(1.5, BASE_AREA / (size * size) * 3);
}
function apply(li: HTMLElement) {
const state = parseInt(li.getAttribute("data-state") || "0", 10);
const btn = li.querySelector("." + (styles.marker || "marker")) as HTMLElement | null;
if (!btn) return;
const size = SIZE_BY_STATE[state];
const thick = thicknessFor(size);
btn.style.setProperty("--size", size + "px");
btn.style.setProperty("--thick", thick.toFixed(2) + "px");
btn.className = (styles.marker || "marker") + " state-" + state;
btn.setAttribute("aria-label", "상태 전환: " + STATES[state]);
btn.textContent = STATES[state];
}
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function advance(li: HTMLElement) {
const state = (parseInt(li.getAttribute("data-state") || "0", 10) + 1) % 3;
li.setAttribute("data-state", String(state));
apply(li);
}
rows.forEach(function (li: HTMLElement) {
const btn = li.querySelector("." + (styles.marker || "marker"));
const onClick = function () {
human();
advance(li);
};
btn!.addEventListener("click", onClick);
apply(li);
});
let last = 0;
let cursor = 0;
function loop(ts: number) {
if (!last) last = ts;
if (isDemo) {
if (reduce) {
rows.forEach(function (li: HTMLElement, i: number) { li.setAttribute("data-state", String(i % 3)); apply(li); }); // 계산은 유지, 시간 축소 대신 서로 다른 상태로 즉시 고정
} else if (ts - last >= STEP_MS) {
last = ts;
advance(rows[cursor]);
cursor = (cursor + 1) % rows.length;
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.taskList} ref={listRef}>
{TASKS.map(function (t) {
return (
<li key={t.label} data-state={t.state}>
<button className={styles.marker} type="button" aria-label={"상태 전환: " + STATES[t.state]}>{STATES[t.state]}</button>
<span className={styles.label}>{t.label}</span>
</li>
);
})}
</ul>
</div>
</main>
);
}08 · ハイブリッド入れ替えマーカー — 状態の値でマーカーの種類が変わる方式 — 計測 累積面積 1.21% · 動いたフレーム 11/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
09連鎖チェックマーカー — 遅延の値を積み、チェックが連鎖で弾ける方式
項目iが弾ける時刻をi * DELAY_STEP_MS(110)の累積で決め、CSS変数--delayに載せてanimation-delayにそのまま渡します。JavaScriptはクラスのトグル1回だけを行い、連鎖のタイミングはCSSが計算します。
数値が語ること
項目09は23フレーム中10枚で動き、07と並んで9項目中最少です。累積面積0.37%、強度平均91.0で、チェックが刻まれる瞬間だけがピクセルを動かすため、面積は9項目の中でも下から2番目に小さい値です。
// 計算層: 項目iが弾ける時刻は、遅延の値をi回累積した値(i * DELAY_STEP_MS)である — CSS変数に載せて
// animation-delayにそのまま渡す。JavaScriptはトリガー1回(クラスのトグル)だけを行い、連鎖はCSSが計算する。
items.forEach(function (li, i) {
li.style.setProperty('--delay', (i * DELAY_STEP_MS) + 'ms');
});
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function burst() {
list.classList.remove('is-bursting');
// reflowを強制して、同じクラスを再び付けてもアニメーションが再スタートするようにする
void list.offsetWidth;
list.classList.add('is-bursting');
}

<main class="stage">
<div class="wrap is-demo" id="wrap">
<ul class="done-list" id="list">
<li style="--i:0">契約書の確認</li>
<li style="--i:1">支払い情報の登録</li>
<li style="--i:2">配送先の入力</li>
<li style="--i:3">最終提出</li>
</ul>
<button class="submit" id="submit" type="button">全体を提出</button>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
// 계산 겹은 index.html 의 지연 누적(i * DELAY_STEP_MS) 에 있다 — CSS 변수 --delay 로 넘어와
// animation-delay 에 그대로 꽂힌다(자바스크립트는 트리거만, 연쇄 타이밍은 CSS 가 계산).
// ::marker 는 두 개의 막대(::before·::after)를 동시에 가질 수 없어 체크 모양을 못 만든다 — list-style:none 으로
// 지우고 li 자체에 두 pseudo 로 체크를 그린다(BD16: 같은 꼭짓점에서 출발).
@include stage($stage-ink);
.done-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: $sp-3;
width: 230px;
}
.done-list li {
position: relative;
padding-left: 32px;
min-height: 22px;
display: flex;
align-items: center;
color: $subject-cream;
font-size: 12px;
}
.done-list li::before,
.done-list li::after {
content: '';
position: absolute;
left: 4px;
top: 11px;
height: 3px;
border-radius: $r-pill;
background: $subject-mint;
transform-origin: left center;
opacity: 0;
}
.done-list li::before {
width: 7px;
transform: scale(0) rotate(225deg);
}
.done-list li::after {
width: 13px;
transform: scale(0) rotate(-52deg);
}
.done-list.is-bursting li::before {
animation: burstBefore $dur-base $ease-pop both;
animation-delay: var(--delay, 0ms);
}
.done-list.is-bursting li::after {
animation: burstAfter $dur-base $ease-pop both;
animation-delay: var(--delay, 0ms);
}
@keyframes burstBefore {
0% { opacity: 0; transform: scale(0) rotate(225deg); }
55% { opacity: 1; transform: scale(1.3) rotate(225deg); }
100% { opacity: 1; transform: scale(1) rotate(225deg); }
}
@keyframes burstAfter {
0% { opacity: 0; transform: scale(0) rotate(-52deg); }
55% { opacity: 1; transform: scale(1.3) rotate(-52deg); }
100% { opacity: 1; transform: scale(1) rotate(-52deg); }
}
.submit {
appearance: none;
border: none;
margin-top: $sp-4;
padding: $sp-2 $sp-4;
border-radius: $r-control;
background: $subject-blue;
color: $subject-cream;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform $dur-instant $ease-out;
&:hover { transform: translateY(-1px); }
&:active { transform: scale(.96); }
&:focus-visible { outline: 2px solid $subject-blue-light; outline-offset: 2px; }
}
@media (max-width: 340px) {
.done-list { width: 190px; }
.done-list li { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.done-list.is-bursting li::before,
.done-list.is-bursting li::after { animation: none; opacity: 1; }
.done-list.is-bursting li::before { transform: scale(1) rotate(225deg); }
.done-list.is-bursting li::after { transform: scale(1) rotate(-52deg); }
.submit { transition: none; }
}html, body {
margin: 0;
overflow: hidden;
}
.stage {
box-sizing: border-box;
min-height: 100vh;
display: grid;
place-items: center;
margin: 0;
background: #17141a;
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
padding: clamp(8px, 4vw, 24px);
}
.done-list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 12px;
width: 230px;
}
.done-list li {
position: relative;
padding-left: 32px;
min-height: 22px;
display: flex;
align-items: center;
color: #fff7e6;
font-size: 12px;
}
.done-list li::before,
.done-list li::after {
content: "";
position: absolute;
left: 4px;
top: 11px;
height: 3px;
border-radius: 999px;
background: #4cd4a6;
transform-origin: left center;
opacity: 0;
}
.done-list li::before {
width: 7px;
transform: scale(0) rotate(225deg);
}
.done-list li::after {
width: 13px;
transform: scale(0) rotate(-52deg);
}
.done-list.is-bursting li::before {
animation: burstBefore 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
animation-delay: var(--delay, 0ms);
}
.done-list.is-bursting li::after {
animation: burstAfter 300ms cubic-bezier(0.2, 1.4, 0.4, 1) both;
animation-delay: var(--delay, 0ms);
}
@keyframes burstBefore {
0% {
opacity: 0;
transform: scale(0) rotate(225deg);
}
55% {
opacity: 1;
transform: scale(1.3) rotate(225deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(225deg);
}
}
@keyframes burstAfter {
0% {
opacity: 0;
transform: scale(0) rotate(-52deg);
}
55% {
opacity: 1;
transform: scale(1.3) rotate(-52deg);
}
100% {
opacity: 1;
transform: scale(1) rotate(-52deg);
}
}
.submit {
appearance: none;
border: none;
margin-top: 16px;
padding: 8px 16px;
border-radius: 12px;
background: #2f6df6;
color: #fff7e6;
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: transform 140ms ease-out;
}
.submit:hover {
transform: translateY(-1px);
}
.submit:active {
transform: scale(0.96);
}
.submit:focus-visible {
outline: 2px solid #5b8cf8;
outline-offset: 2px;
}
@media (max-width: 340px) {
.done-list {
width: 190px;
}
.done-list li {
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.done-list.is-bursting li::before,
.done-list.is-bursting li::after {
animation: none;
opacity: 1;
}
.done-list.is-bursting li::before {
transform: scale(1) rotate(225deg);
}
.done-list.is-bursting li::after {
transform: scale(1) rotate(-52deg);
}
.submit {
transition: none;
}
}(function () {
var DELAY_STEP_MS = 110; // 항목 사이 누적 지연 — 값이 커질수록 파동이 느리게 번진다
var reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
var wrap = document.getElementById('wrap');
var list = document.getElementById('list');
var items = Array.prototype.slice.call(list.querySelectorAll('li'));
var button = document.getElementById('submit');
var isDemo = true;
// 계산 겹: 항목 i 가 터지는 시각은 지연 값을 i 번 누적한 값(i * DELAY_STEP_MS)이다 — CSS 변수로 실어
// animation-delay 에 그대로 쓴다. 자바스크립트는 트리거 한 번(클래스 토글)만 하고 연쇄는 CSS 가 계산한다.
items.forEach(function (li, i) {
li.style.setProperty('--delay', (i * DELAY_STEP_MS) + 'ms');
});
function human() { if (isDemo) { wrap.classList.remove('is-demo'); isDemo = false; } }
function burst() {
list.classList.remove('is-bursting');
// reflow 를 강제해 같은 클래스를 다시 걸어도 애니메이션이 재시작되게 한다
void list.offsetWidth;
list.classList.add('is-bursting');
}
button.addEventListener('click', function () { human(); burst(); });
var last = 0;
var CYCLE_MS = 950;
function loop(ts) {
if (!last) { last = ts; if (isDemo && !reduce) burst(); }
if (isDemo) {
if (reduce) {
list.classList.add('is-bursting'); // 계산은 유지, 시간 축소 대신 完了 상태로 즉시 고정
} else if (ts - last >= CYCLE_MS) {
last = ts;
burst();
}
}
requestAnimationFrame(loop);
}
requestAnimationFrame(loop);
})();// CheckBurstMarker — vanilla 판(run/373/demo/09-list-style-check-burst-stagger/index.html) 의 React 포트.
// ::marker 는 animation·자식 막대를 지원하지 않아 체크가 터지는 마커를 그릴 수 없다 — list-style:none 으로 지우고
// ::before·::after 막대 체크를 얹되, 항목마다 지연 값(i * DELAY_STEP_MS)을 누적해 연쇄 터진다.
import { useEffect, useRef } from "react";
import styles from "./CheckBurstMarker.module.scss";
type Box<T> = { current: T };
const DELAY_STEP_MS = 110; // 항목 사이 누적 지연 — 값이 커질수록 파동이 느리게 번진다
const CYCLE_MS = 950; // 자동 루프에서 연쇄를 다시 터뜨리는 주기
const STEPS = ["계약서 확인", "결제 정보 등록", "배송지 입력", "최종 제출"];
export default function CheckBurstMarker() {
const rootRef = useRef(null) as unknown as Box<HTMLDivElement>;
const listRef = useRef(null) as unknown as Box<HTMLUListElement>;
const rafRef = useRef(0) as unknown as Box<number>;
useEffect(() => {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const root = rootRef.current;
const list = listRef.current;
if (!root || !list) return;
const items = Array.prototype.slice.call(list.querySelectorAll("li"));
let isDemo = true;
// 계산 겹: 항목 i 가 터지는 시각은 지연 값을 i 번 누적한 값(i * DELAY_STEP_MS)이다 — CSS 변수로 실어
// animation-delay 에 그대로 쓴다. 자바스크립트는 트리거 한 번(클래스 토글)만 하고 연쇄는 CSS 가 계산한다.
items.forEach(function (li: HTMLElement, i: number) {
li.style.setProperty("--delay", (i * DELAY_STEP_MS) + "ms");
});
function human() { if (isDemo) { root!.classList.remove(styles.isDemo); isDemo = false; } }
function burst() {
list!.classList.remove("is-bursting");
// reflow 를 강제해 같은 클래스를 다시 걸어도 애니메이션이 재시작되게 한다
void list!.offsetWidth;
list!.classList.add("is-bursting");
}
function onClick() { human(); burst(); }
root!.addEventListener("click", onClick);
let last = 0;
function loop(ts: number) {
if (!last) { last = ts; if (isDemo && !reduce) burst(); }
if (isDemo) {
if (reduce) {
list!.classList.add("is-bursting"); // 계산은 유지, 시간 축소 대신 완료 상태로 즉시 고정
} else if (ts - last >= CYCLE_MS) {
last = ts;
burst();
}
}
rafRef.current = requestAnimationFrame(loop);
}
rafRef.current = requestAnimationFrame(loop);
return () => {
root!.removeEventListener("click", onClick);
cancelAnimationFrame(rafRef.current);
};
}, []);
return (
<main className={styles.stage}>
<div className={styles.wrap + " " + styles.isDemo} ref={rootRef}>
<ul className={styles.doneList} ref={listRef}>
{STEPS.map(function (s, i) { return <li key={s} style={{ ["--i" as string]: String(i) } as Record<string, string>}>{s}</li>; })}
</ul>
<button className={styles.submit} type="button">전체 제출</button>
</div>
</main>
);
}09 · 連鎖チェックマーカー — 遅延の値を積み、チェックが連鎖で弾ける方式 — 計測 累積面積 0.367% · 動いたフレーム 10/23GODRICH オリジナル · SCSS と React は zip に同梱 · パスワードを見る
値がマーカーを変える基準 — 角度・桁・位相・判定
一歩を決める定数たち
各項目の表情は名前付きの定数が決めます — 01のDIAL_STEP_DEG(30)・STEP_MS(480)、02のODO_DIGITS(2)・DIGIT_H(18)、03のWAVE_PHASE_DEG(40)・AMPLITUDE_PX(6)、04のMARCH_RATE_DEG_S(140)、05のSTEP_MS(900)、06のR_MAX(15)、07のMAX_DEPTH(2)、08のBASE_AREA(400)、09のDELAY_STEP_MS(110)・CYCLE_MS(950)です。css リスト マーカー の 種類と言うと普通はlist-style-typeで不吉マークを入れ替える話を指しますが、この記事で値を触る場所は形の一覧ではなくこれらの定数です。なお05のグロー脈動の周期はJavaScriptの定数ではなく、scssの$dur-loop(2s)トークンが決めており、GLOW_PULSE_MS(1100)は宣言だけが残る未使用の変数です。
隣接する枝 — リスト軸の他の記事
項目を開閉する操作はアコーディオンリスト UI 9選 — チェックリストまでが扱っており、この記事は項目の前に立つマーカーの計算だけを扱います。項目を押し出して消したりページを送る操作はスワイプ削除リストUI 9選 — ページネーションまでで、数字を積み上げる動きはCSS 数字 カウントアップ アニメーション9選 — 一行でコピペOKで扱っています — この記事の02の走行計は値を桁に分解して転がす点で、単純な数字の積み上げとは分かれます。リストの文法そのものはlist-style文書とCSS counters文書に従います。
どこで壊れるか — list-styleの対応範囲、フォールバック、reduced-motion
::markerが直接受け取れるプロパティはcolor・content・fontの3つだけで、回転・移動・拡大・グロー・アニメーション・子要素はすべて不可能です。そのため9項目すべてがlist-style: noneでネイティブのマーカーを消し、同じ場所に立て直しています — 01・03・05・09はliの::before・::afterで立て直し、02・04・06・08は転がる数字ストリップやクリック可能なボタンを収める必要があるため実際のDOM要素を置き、07はネストした<ul>そのものに::beforeのレールを付けます。番号を描き直すのは05だけで、counter-reset・counter-incrementとcontent: counter(item)の値を使います。css リスト デザイン おしゃれを探してこのページに来た人にとって、その最初の一手はすでに9項目全部に入っています。
9項目のhtml・scss・js全体は6tdgyv9rの圧縮パスワードで受け取り、定数を直接書き換えながら開いて試せます。
reduced-motionが残すもの
reduce設定が入ると、01はturnsをi+1の終了状態へ、02は値を[42, 97, 5]の結果へ、03は位相をπ/2の山の頂点へ、04はオフセットを90度へ即座に固定します。05はパルスを止めて4pxの静的なリングを残し、06は2つ目のプリセットを固定し、07は完全に開き切り、08は行ごとに違う状態を、09はチェックが全部埋まった完了状態を見せます。CSS側にも項目ごとにreduced-motionのブロックがあり、遷移をすべて断ちます。
1ページに9つを詰め込むとrequestAnimationFrameのループが同時に9つ回ることになり、低スペック機ではフレームが詰まることがあります — 実サービスに載せるときは必要なマーカーをひとつだけ残すのが安全です。
3つのバリエーションとアクセシビリティ
| バリエーション | 値 | 感触 |
|---|---|---|
| ダイヤル角度を細かくする | 01 DIAL_STEP_DEG 30 → 15 | DIAL_STEP_DEG を15に下げると1回の前進で針が受ける角度が小さくなり(turns[i] * DIAL_STEP_DEG * (i+1))、次の目盛りまでにより多くのクリックが要る — ダイヤルが細かい歩みで上がっていく |
| 走行計の桁を増やす | 02 ODO_DIGITS 2 → 3 | ODO_DIGITS を3に上げると桁の分解が百の位まで広がり(Math.floor(value / place) % 10, place = 10^(ODO_DIGITS-1-d))、000から999までの3桁の走行計として転がる — 範囲は2倍ではなく10倍に広がる |
| 波の位相を広げる | 03 WAVE_PHASE_DEG 40 → 80 | WAVE_PHASE_DEG を80に上げると隣接項目間の位相間隔が広がり(i * WAVE_PHASE_DEG * π/180)、同じ瞬間にリスト全体へより多くの波の山が入る — 緩やかに広がる波ではなく密な重なり波として見える |
装飾マーカーはスクリーンリーダーが読まないよう、02の走行計・04のリング・06のチップがaria-hiddenで隠されており、番号を描き直す05の::beforeは内容がそのまま番号なので露出したままです。08のマーカーは状態をaria-labelで知らせる実際のbuttonで、04は行進トグルにaria-pressedを、07は深さ公開ボタンにaria-expandedを状態に合わせて変えます。操作がすべてbuttonなのでEnter・Spaceでそのまま操作でき、reduce環境では前節で見た通り項目ごとにそれぞれの最終形がアニメーションなしで残ります。
FAQ
css リスト 数字 デザイン のように並びを横に変えても、これらのマーカーはそのまま使えますか?
liの::before・::afterでマーカーを立てた01・03・05・09は、背景となるリストを横に流しても項目に合わせてマーカーがついていき、実際の要素を入れた02・04・06・08もインラインなので一緒に流れます。07のレールだけが縦方向の伸びを前提に描かれているため、横方向のレイアウトではレール軸を描き直す必要があります。
::markerに直接この効果をかけることはできませんか?
できません — color・content・font以外のプロパティは::markerが受け取れないため、回転・転がり・グローは最初から不可能です。9項目すべてがlist-style: noneでマーカーを消し同じ場所に立て直す構造になっているのもそのためで、マーカーがリストの文法の中にありながら動くようにするには、この置き換えが出発点になります。
9つの中でどの項目が画面に最も大きな影響を与えますか?
毎フレーム値を計算し直す忙しさでは03・04が23/24で最も先を行き、広がりでは累積面積2.81%の01が、ピクセル差の強さでは強度142.2の03が最大です。計算量そのものは9項目とも軽く — 05の割り算判定もこのデモの番号の範囲では回数が少なく負担になりません。
Sources: MDN — list-style · MDN — CSS counters · MDN — @counter-style · MDN — Web Animations API









