07階層レールマーカー — 深さの値でインデントレールを計算する方式
ネストの深さの値ごとにレール線が計算されて伸び、子項目が増えるたびにレールが一段内側へ掘り下がります。
使いどころサイトマップリスト、規約文書の目次
scaleYrevealed$subject-blue-light

<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 に同梱 · css リスト デザイン 9選 — マーカーが計算で動く箇条書きの装飾
このデモが属する記事
css リスト デザイン 9選 — マーカーが計算で動く箇条書きの装飾9つのリストマーカーのうち、1つはダイヤル角度の値が項目ごとに積み、箇条書きの点が時計のように回り、1つはカウンター値が桁ごとに分解されて数字が走行計のように転がり、1つは素数判定の値によってその番号のマーカーだけが光を放ちます。この9つは この記事のデモをすべて見る


