9 File Tree CSS Views — Folders That Really Open
A file tree css view is the nested folder list in a sidebar: folders that open, files with icons, a highlight on the row you picked.
01 filetree-expand-collapse
03 filetree-active-highlight
04 filetree-drag-into-folder
05 filetree-multi-select
06 filetree-search-filter
07 filetree-context-menu
08 filetree-inline-rename
09 filetree-size-bars
Auto-plays · click a tile to jump to its section · all nine in one zip
- 01 Caret-expand base tree
- 02 Type icons with indent guides
- 03 Active highlight with breadcrumb
- 04 Drag a file into a folder
- 05 Shift and Ctrl multi-select
- 06 Search-filtered tree
- 07 Right-click context menu
- 08 Inline rename in place
- 09 Tree with size bars
The order follows what a tree has to learn, one skill at a time. First it opens — the caret pattern (01) is the skeleton the rest are built on. Once rows exist, they need identity, so type icons and indent guides come next (02), then a way to show which row is active, with a sliding bar and a breadcrumb (03). From there the rows turn into objects you act on: drag one into a folder (04), gather several with Shift and Ctrl (05), narrow hundreds down by typing (06), act on one through a right-click menu (07), rename one where it sits (08). The last one changes what a row can carry — a size bar on every row (09), because tidying up starts with seeing where the bytes went. Each of the nine ships as plain file tree html css — two files, index.html and style.scss, with no library, no web font, and no CDN; the icons are Phosphor Duotone paths pasted in as inline SVG. Every autoplay loop below was rendered and measured — the changed share of the frame, and how many of the 24 frames moved: The screen for when the tree is empty maps to Empty State UI Design Cards, and the case where files give way to rows of numbers is Data Table UI Patterns.
| # | View | Changed area | Moving frames |
|---|---|---|---|
| 01 | Caret-expand base tree | 12.287% | 9 / 24 |
| 02 | Type icons with indent guides | 5.168% | 11 / 24 |
| 03 | Active highlight with breadcrumb | 13.612% | 9 / 24 |
| 04 | Drag a file into a folder | 13.852% | 14 / 24 |
| 05 | Shift and Ctrl multi-select | 16.023% | 8 / 24 |
| 06 | Search-filtered tree | 2.089% | 6 / 24 |
| 07 | Right-click context menu | 21.478% | 15 / 24 |
| 08 | Inline rename in place | 2.036% | 11 / 24 |
| 09 | Tree with size bars | 1.785% | 12 / 24 |
01Caret-expand base tree
What this folder tree view shows
Clicking a folder's caret slides the whole block of child rows down, and a second click pulls it back up, so nesting reads as real depth. Lay this skeleton first when a sidebar shows nested documents, projects, or categories.

<main class="stage">
<div class="ft is-demo" id="ft">
<span class="ft__label" id="ft-label">my-app</span>
<ul class="ft__tree" id="ft-tree" role="tree" aria-labelledby="ft-label" tabindex="0" aria-activedescendant="ft-n1">
<li class="ft__item is-branch is-loop" id="ft-n1" role="treeitem" aria-level="1" aria-expanded="false">
<div class="ft__row" data-hit>
<span class="ft__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></span>
<span class="ft__ico" aria-hidden="true"><span class="ft__ic-f"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="ft__ic-o"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2"/><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/></g></svg></span></span>
<span class="ft__name">components</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul role="group" aria-label="components">
<li class="ft__item is-leaf" id="ft-n2" role="treeitem" aria-level="2">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="ft__name">Button.tsx</span></div>
</li>
<li class="ft__item is-leaf" id="ft-n3" role="treeitem" aria-level="2">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="ft__name">Modal.tsx</span></div>
</li>
</ul>
</div></div>
</li>
<li class="ft__item is-branch" id="ft-n4" role="treeitem" aria-level="1" aria-expanded="false">
<div class="ft__row" data-hit>
<span class="ft__caret" aria-hidden="true"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="m176 128l-80 80V48Z" opacity=".2"/><path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z"/></g></svg></span>
<span class="ft__ico" aria-hidden="true"><span class="ft__ic-f"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="ft__ic-o"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2"/><path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z"/></g></svg></span></span>
<span class="ft__name">assets</span>
</div>
<div class="ft__kids"><div class="ft__kwrap"><ul role="group" aria-label="assets"></ul></div></div>
</li>
<li class="ft__item is-leaf" id="ft-n5" role="treeitem" aria-level="1">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ft__name">main.css</span></div>
</li>
<li class="ft__item is-leaf" id="ft-n6" role="treeitem" aria-level="1">
<div class="ft__row" data-hit><span class="ft__caret ft__caret--blank" aria-hidden="true"></span><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span><span class="ft__name">notes.md</span></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-ink);
.ft {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ft__label {
display: block;
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
margin: 0 0 $sp-1;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item.is-at > .ft__row { outline: 2px solid $color; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2 0 $sp-1;
border-radius: $r-sm;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover { background: rgba(255, 255, 255, .07); }
.ft__item.is-at > .ft__row { background: rgba(47, 109, 246, .22); }
.ft__caret {
display: inline-flex; width: 11px; flex: 0 0 auto;
color: rgba(255, 255, 255, .55);
transition: transform $duration $easing;
}
.ft__caret--blank { width: 11px; }
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $color; }
.ft__ico > span { display: inline-flex; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 열림/닫힘 — 0fr→1fr 격자 트랙. 자손 막대기 애니메이션이 아니라 트랙 자체가 커진다
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__kids ul { padding-left: $sp-6; }
.ft__item.is-open > .ft__kids { grid-template-rows: 1fr; }
.ft__item.is-open > .ft__row .ft__caret { transform: rotate(90deg); }
// 폴더 아이콘 교체는 컷 — 반투명로 겹치지 않는다
.ft__ic-o { display: none; }
.ft__item.is-open > .ft__row .ft__ic-f { display: none; }
.ft__item.is-open > .ft__row .ft__ic-o { display: inline-flex; }
// ── 자동 루프: 펼침 → 유지 → 접힘. 끝 프레임이 첫 프레임과 같다 ──
.ft.is-demo .ft__item.is-loop > .ft__kids {
animation-name: ft-kids;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ft-kids {
0%, 100% { grid-template-rows: 0fr; }
26%, 76% { grid-template-rows: 1fr; }
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret {
animation-name: ft-caret;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes ft-caret {
0%, 100% { transform: rotate(0deg); }
26%, 76% { transform: rotate(90deg); }
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f {
animation-name: ft-icf;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-icf { 0% { opacity: 1; } 26% { opacity: 0; } 76% { opacity: 1; } 100% { opacity: 1; } }
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
display: inline-flex;
animation-name: ft-ico;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-ico { 0% { opacity: 0; } 26% { opacity: 1; } 76% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ft__row { height: 22px; font-size: 11px; }
.ft__kids ul { padding-left: $sp-4; }
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__item.is-loop > .ft__kids,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o { animation: none !important; }
.ft__kids, .ft__caret { 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);
}
.ft {
box-sizing: border-box;
width: min(264px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ft__label {
display: block;
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
margin: 0 0 4px;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item.is-at > .ft__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px 0 4px;
border-radius: 8px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover {
background: rgba(255, 255, 255, 0.07);
}
.ft__item.is-at > .ft__row {
background: rgba(47, 109, 246, 0.22);
}
.ft__caret {
display: inline-flex;
width: 11px;
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.55);
transition: transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ft__caret--blank {
width: 11px;
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #2f6df6;
}
.ft__ico > span {
display: inline-flex;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ft__kwrap {
overflow: hidden;
min-height: 0;
}
.ft__kids ul {
padding-left: 24px;
}
.ft__item.is-open > .ft__kids {
grid-template-rows: 1fr;
}
.ft__item.is-open > .ft__row .ft__caret {
transform: rotate(90deg);
}
.ft__ic-o {
display: none;
}
.ft__item.is-open > .ft__row .ft__ic-f {
display: none;
}
.ft__item.is-open > .ft__row .ft__ic-o {
display: inline-flex;
}
.ft.is-demo .ft__item.is-loop > .ft__kids {
animation-name: ft-kids;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes ft-kids {
0%, 100% {
grid-template-rows: 0fr;
}
26%, 76% {
grid-template-rows: 1fr;
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret {
animation-name: ft-caret;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes ft-caret {
0%, 100% {
transform: rotate(0deg);
}
26%, 76% {
transform: rotate(90deg);
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f {
animation-name: ft-icf;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-icf {
0% {
opacity: 1;
}
26% {
opacity: 0;
}
76% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
display: inline-flex;
animation-name: ft-ico;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ft-ico {
0% {
opacity: 0;
}
26% {
opacity: 1;
}
76% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ft__row {
height: 22px;
font-size: 11px;
}
.ft__kids ul {
padding-left: 16px;
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__item.is-loop > .ft__kids,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__caret,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-f,
.ft.is-demo .ft__item.is-loop > .ft__row .ft__ic-o {
animation: none !important;
}
.ft__kids, .ft__caret {
transition: none;
}
}// 실제 동작: 클릭·Enter 로 폴더를 열고 닫고, 화살표·Home·End 로 돌아다닌다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var items = [].slice.call(tree.querySelectorAll('li'));
var at = 0;
function visible(li) {
var p = li.parentElement;
while (p && p !== tree) {
if (p.getAttribute('role') === 'group') {
var host = p.closest('li');
if (!host.classList.contains('is-open') && !host.classList.contains('is-loop')) return false;
}
p = p.parentElement;
}
return true;
}
function list() { return items.filter(visible); }
function paint() {
items.forEach(function (li, i) {
li.classList.toggle('is-at', i === at);
if (i === at) tree.setAttribute('aria-activedescendant', li.id);
});
}
function toggle(li, on) {
root.classList.remove('is-demo');
li.classList.remove('is-loop');
li.classList.toggle('is-open', on);
li.setAttribute('aria-expanded', String(!!on && li.classList.contains('is-branch')));
}
items.forEach(function (li, i) {
var hit = li.querySelector('[data-hit]');
hit.addEventListener('click', function () { at = i; paint(); if (li.classList.contains('is-branch')) toggle(li, !li.classList.contains('is-open')); });
});
tree.addEventListener('keydown', function (e) {
var vis = list(), k = vis.indexOf(items[at]);
if (e.key === 'ArrowDown') { at = items.indexOf(vis[Math.min(k + 1, vis.length - 1)]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp') { at = items.indexOf(vis[Math.max(k - 1, 0)]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowRight') {
if (items[at].classList.contains('is-branch') && !items[at].classList.contains('is-open')) toggle(items[at], true);
else if (k < vis.length - 1) { at = items.indexOf(vis[k + 1]); paint(); }
e.preventDefault();
} else if (e.key === 'ArrowLeft') {
if (items[at].classList.contains('is-branch') && items[at].classList.contains('is-open')) toggle(items[at], false);
else { var host = items[at].closest('ul[role="group"]'); if (host) { at = items.indexOf(host.closest('li')); paint(); } }
e.preventDefault();
} else if (e.key === 'Home') { at = items.indexOf(vis[0]); paint(); e.preventDefault(); }
else if (e.key === 'End') { at = items.indexOf(vis[vis.length - 1]); paint(); e.preventDefault(); }
else if (e.key === 'Enter' || e.key === ' ') { if (items[at].classList.contains('is-branch')) toggle(items[at], !items[at].classList.contains('is-open')); e.preventDefault(); }
});
paint();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ExpandCollapseTree.module.scss";
export type TreeNode = { name: string; kids?: string[] };
export type ExpandCollapseTreeProps = {
label?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { stopPropagation: () => void };
const DEFAULT_NODES: TreeNode[] = [
{ name: "components", kids: ["Button.tsx", "Modal.tsx"] },
{ name: "assets", kids: [] },
{ name: "main.css" },
{ name: "notes.md" },
];
type Row = { id: string; branch: boolean; owner: number; level: number };
// 아이콘은 vanilla 데모(01-filetree-expand-collapse/index.html)의 인라인 SVG 그대로다.
function Caret() {
return (
<svg viewBox="0 0 256 256" width="11" height="11">
<g fill="currentColor">
<path d="m176 128l-80 80V48Z" opacity=".2" />
<path d="m181.66 122.34l-80-80A8 8 0 0 0 88 48v160a8 8 0 0 0 13.66 5.66l80-80a8 8 0 0 0 0-11.32M104 188.69V67.31L164.69 128Z" />
</g>
</svg>
);
}
function FolderShut() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function FolderOpen() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M208 88v24H69.77a8 8 0 0 0-7.59 5.47L32 208V64a8 8 0 0 1 8-8h53.33a8 8 0 0 1 4.8 1.6L128 80h72a8 8 0 0 1 8 8" opacity=".2" />
<path d="M245 110.64a16 16 0 0 0-13-6.64h-16V88a16 16 0 0 0-16-16h-69.33l-27.73-20.8a16.14 16.14 0 0 0-9.6-3.2H40a16 16 0 0 0-16 16v144a8 8 0 0 0 8 8h179.1a8 8 0 0 0 7.59-5.47l28.49-85.47a16.05 16.05 0 0 0-2.18-14.42M93.34 64l29.86 22.4A8 8 0 0 0 128 88h72v16H69.77a16 16 0 0 0-15.18 10.94L40 158.7V64Zm112 136H43.1l26.67-80H232Z" />
</g>
</svg>
);
}
function FileTs() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function FileCss() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
function FileMd() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152" />
</g>
</svg>
);
}
function fileIcon(name: string) {
const ext = name.slice(name.lastIndexOf(".") + 1).toLowerCase();
if (ext === "css" || ext === "scss") return <FileCss />;
if (ext === "md") return <FileMd />;
return <FileTs />;
}
export default function ExpandCollapseTree({
label = "my-app",
nodes = DEFAULT_NODES,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: ExpandCollapseTreeProps) {
const [open, setOpen] = useState<boolean[]>(() => nodes.map(() => false));
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const id = (i: number, j?: number) => (j === undefined ? `ect-n${i}` : `ect-n${i}-${j}`);
const isOpen = (i: number) => open[i] === true;
// 보이는 줄만 화살표로 돈다 — 닫힌 폴더의 자식은 건너뛴다
const rows: Row[] = [];
nodes.forEach((n, i) => {
rows.push({ id: id(i), branch: !!n.kids, owner: i, level: 1 });
const kids = n.kids;
if (kids && isOpen(i)) {
kids.forEach((_k, j) => rows.push({ id: id(i, j), branch: false, owner: i, level: 2 }));
}
});
const cur = at < rows.length ? at : rows.length - 1;
const active = rows[cur];
function setFold(i: number, on: boolean) {
setOpen((o) => o.map((v, k) => (k === i ? on : v)));
}
function hit(i: number, branch: boolean) {
const k = rows.findIndex((r) => r.id === id(i));
if (k >= 0) setAt(k);
if (branch) setFold(i, !isOpen(i));
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function hitKid(i: number, j: number) {
const k = rows.findIndex((r) => r.id === id(i, j));
if (k >= 0) setAt(k);
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
const r = active;
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, rows.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "ArrowRight") {
if (r && r.branch && !isOpen(r.owner)) setFold(r.owner, true);
else setAt(Math.min(cur + 1, rows.length - 1));
e.preventDefault();
} else if (e.key === "ArrowLeft") {
if (r && r.branch && isOpen(r.owner)) setFold(r.owner, false);
else if (r && r.level === 2) setAt(rows.findIndex((x) => x.id === id(r.owner)));
e.preventDefault();
} else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(rows.length - 1); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") {
if (r && r.branch) setFold(r.owner, !isOpen(r.owner));
e.preventDefault();
}
}
return (
<div className={styles.ft} style={vars}>
<span className={styles.label} id="ect-label">{label}</span>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-labelledby="ect-label"
tabIndex={0}
aria-activedescendant={active ? active.id : undefined}
onKeyDown={onKeyDown}
>
{nodes.map((n, i) => {
const kids = n.kids;
const opened = isOpen(i);
const cls = [
styles.item,
kids ? styles.branch : styles.leaf,
opened ? styles.isOpen : "",
active && active.id === id(i) ? styles.isAt : "",
].join(" ");
return (
<li key={n.name} id={id(i)} className={cls} role="treeitem" aria-level={1} aria-expanded={kids ? opened : undefined}>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); hit(i, !!kids); }}>
<span className={kids ? styles.caret : styles.caretBlank} aria-hidden="true">{kids ? <Caret /> : null}</span>
<span className={styles.ico} aria-hidden="true">{kids ? (opened ? <FolderOpen /> : <FolderShut />) : fileIcon(n.name)}</span>
<span className={styles.name}>{n.name}</span>
</div>
{kids ? (
<div className={styles.kids}>
<div className={styles.kwrap}>
<ul className={styles.group} role="group" aria-label={n.name}>
{kids.map((k, j) => (
<li
key={k}
id={id(i, j)}
className={`${styles.item} ${styles.leaf} ${active && active.id === id(i, j) ? styles.isAt : ""}`}
role="treeitem"
aria-level={2}
>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); hitKid(i, j); }}>
<span className={styles.caretBlank} aria-hidden="true" />
<span className={styles.ico} aria-hidden="true">{fileIcon(k)}</span>
<span className={styles.name}>{k}</span>
</div>
</li>
))}
</ul>
</div>
</div>
) : null}
</li>
);
})}
</ul>
</div>
);
}01 · Caret-expand base tree — Measured area 12.287% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The track itself grows — grid-template-rows animates from 0fr to 1fr — rather than anything inside the children moving.
// open/close — a 0fr→1fr grid track. the track itself grows; the children are not animated
.ft__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__kids ul { padding-left: $sp-6; }
.ft__item.is-open > .ft__kids { grid-template-rows: 1fr; }
.ft__item.is-open > .ft__row .ft__caret { transform: rotate(90deg); }
// the folder icon swap is a cut — the two never overlap half-transparent
.ft__ic-o { display: none; }
.ft__item.is-open > .ft__row .ft__ic-f { display: none; }
.ft__item.is-open > .ft__row .ft__ic-o { display: inline-flex; }
02Type icons with indent guides
What happens on screen
Every file gets an icon for its type, and each level of nesting draws its own faint vertical guide, so folders three deep still read as a hierarchy when names repeat.
<main class="stage">
<div class="ft is-demo" id="ft">
<ul class="ft__tree" id="ft-tree" role="tree" aria-label="files" tabindex="0" aria-activedescendant="ft-n1">
<li class="ft__item is-branch" id="ft-n1" role="treeitem" aria-level="1" aria-expanded="true">
<div class="ft__row" style="--i: 0" data-hit>
<span class="ft__ico ft__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="ft__name">portfolio</span>
<span class="ft__kind" aria-hidden="true">folder</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul class="ft__lv2" role="group" aria-label="portfolio">
<li class="ft__item is-leaf" id="ft-n2" role="treeitem" aria-level="2">
<div class="ft__row" style="--i: 1" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="ft__name">resume.pdf</span>
<span class="ft__kind">PDF</span>
</div>
</li>
<li class="ft__item is-branch" id="ft-n3" role="treeitem" aria-level="2" aria-expanded="true">
<div class="ft__row" style="--i: 2" data-hit>
<span class="ft__ico ft__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="ft__name">site</span>
<span class="ft__kind" aria-hidden="true">folder</span>
</div>
<div class="ft__kids"><div class="ft__kwrap">
<ul class="ft__lv3" role="group" aria-label="site">
<li class="ft__item is-leaf" id="ft-n4" role="treeitem" aria-level="3">
<div class="ft__row" style="--i: 3" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="ft__name">cover.jpg</span>
<span class="ft__kind">JPG</span>
</div>
</li>
</ul>
</div></div>
</li>
</ul>
</div></div>
</li>
<li class="ft__item is-leaf" id="ft-n5" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 4" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span>
<span class="ft__name">main.js</span>
<span class="ft__kind">JS</span>
</div>
</li>
<li class="ft__item is-leaf" id="ft-n6" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 5" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span>
<span class="ft__name">style.css</span>
<span class="ft__kind">CSS</span>
</div>
</li>
<li class="ft__item is-leaf" id="ft-n7" role="treeitem" aria-level="1">
<div class="ft__row" style="--i: 6" data-hit>
<span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="ft__name">bundle.zip</span>
<span class="ft__kind">ZIP</span>
</div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $stage-ink;
@include stage($stage-yellow);
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $color;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item.is-at > .ft__row { outline: 2px solid $subject-blue; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover { background: rgba(23, 20, 26, .07); }
.ft__item.is-at > .ft__row { background: rgba(23, 20, 26, .12); }
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $color; }
.ft__ico--folder { color: $subject-blue; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ft__kind {
margin-left: auto;
flex: 0 0 auto;
font-size: 9px; line-height: 12px; font-weight: 700;
letter-spacing: .06em;
color: rgba(23, 20, 26, .72);
}
// 들여쓰기 가이드라인 — 줄마다 실제 세로 줄이 그어진다
.ft__kids {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows $duration $easing;
}
.ft__kwrap { overflow: hidden; min-height: 0; }
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, .22);
transform-origin: top center; // 규칙8: 가이드선 위에서 그어짐
}
.ft__lv3 { border-left-color: rgba(23, 20, 26, .14); }
.ft__item.is-branch:not(.is-open) > .ft__kids { grid-template-rows: 0fr; }
// ── 자동 루프: 가이드 줄이 그어지고 줄이 차례로 떠오른다 ──
.ft.is-demo .ft__lv2 {
animation-name: ft-guide;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes ft-guide {
0% { transform: scaleY(0); }
14%, 84% { transform: scaleY(1); }
96%, 100% { transform: scaleY(0); }
}
.ft.is-demo .ft__lv3 {
animation-name: ft-guide;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
}
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% { opacity: 0; transform: translateX(-6px); }
9% { opacity: 1; transform: translateX(0); }
84% { opacity: 1; transform: translateX(0); }
95%, 100% { opacity: 0; transform: translateX(-6px); }
}
@media (max-width: 340px) {
.ft__row { height: 20px; font-size: 11px; }
.ft__kind { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__row,
.ft.is-demo .ft__lv2,
.ft.is-demo .ft__lv3 { animation: none !important; }
.ft__kids { 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);
}
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ft__tree, .ft__tree ul {
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item.is-at > .ft__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.ft__row:hover {
background: rgba(23, 20, 26, 0.07);
}
.ft__item.is-at > .ft__row {
background: rgba(23, 20, 26, 0.12);
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.ft__ico--folder {
color: #2f6df6;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__kind {
margin-left: auto;
flex: 0 0 auto;
font-size: 9px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.06em;
color: rgba(23, 20, 26, 0.72);
}
.ft__kids {
display: grid;
grid-template-rows: 1fr;
transition: grid-template-rows 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ft__kwrap {
overflow: hidden;
min-height: 0;
}
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, 0.22);
transform-origin: top center;
}
.ft__lv3 {
border-left-color: rgba(23, 20, 26, 0.14);
}
.ft__item.is-branch:not(.is-open) > .ft__kids {
grid-template-rows: 0fr;
}
.ft.is-demo .ft__lv2 {
animation-name: ft-guide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes ft-guide {
0% {
transform: scaleY(0);
}
14%, 84% {
transform: scaleY(1);
}
96%, 100% {
transform: scaleY(0);
}
}
.ft.is-demo .ft__lv3 {
animation-name: ft-guide;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: 120ms;
}
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% {
opacity: 0;
transform: translateX(-6px);
}
9% {
opacity: 1;
transform: translateX(0);
}
84% {
opacity: 1;
transform: translateX(0);
}
95%, 100% {
opacity: 0;
transform: translateX(-6px);
}
}
@media (max-width: 340px) {
.ft__row {
height: 20px;
font-size: 11px;
}
.ft__kind {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__row,
.ft.is-demo .ft__lv2,
.ft.is-demo .ft__lv3 {
animation: none !important;
}
.ft__kids {
transition: none;
}
}// 실제 동작: 폴더를 누르면 접히고, 화살표로 줄을 돌아다닌다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var items = [].slice.call(tree.querySelectorAll('li'));
var at = 0;
function visible(li) {
var p = li.parentElement;
while (p && p !== tree) {
if (p.getAttribute('role') === 'group') {
if (!p.closest('li').classList.contains('is-open')) return false;
}
p = p.parentElement;
}
return true;
}
function paint() {
items.forEach(function (li, i) {
li.classList.toggle('is-at', i === at);
if (i === at) tree.setAttribute('aria-activedescendant', li.id);
});
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function () {
at = i; paint();
if (!li.classList.contains('is-branch')) return;
root.classList.remove('is-demo');
var on = !li.classList.contains('is-open');
li.classList.toggle('is-open', on);
li.setAttribute('aria-expanded', String(on));
});
});
tree.addEventListener('keydown', function (e) {
var vis = items.filter(visible), k = vis.indexOf(items[at]);
if (e.key === 'ArrowDown' && k < vis.length - 1) { at = items.indexOf(vis[k + 1]); paint(); e.preventDefault(); }
else if (e.key === 'ArrowUp' && k > 0) { at = items.indexOf(vis[k - 1]); paint(); e.preventDefault(); }
else if (e.key === 'Home') { at = items.indexOf(vis[0]); paint(); e.preventDefault(); }
else if (e.key === 'End') { at = items.indexOf(vis[vis.length - 1]); paint(); e.preventDefault(); }
});
paint();import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./TypeIconTree.module.scss";
export type TypeNode = { name: string; kind?: string; kids?: TypeNode[] };
export type TypeIconTreeProps = {
treeLabel?: string;
nodes?: TypeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { stopPropagation: () => void };
const DEFAULT_NODES: TypeNode[] = [
{
name: "portfolio",
kind: "폴더",
kids: [
{ name: "resume.pdf", kind: "PDF" },
{ name: "site", kind: "폴더", kids: [{ name: "cover.jpg", kind: "JPG" }] },
],
},
{ name: "main.js", kind: "JS" },
{ name: "style.css", kind: "CSS" },
{ name: "bundle.zip", kind: "ZIP" },
];
// 아이콘은 vanilla 데모(02-filetree-icon-by-type/index.html)의 인라인 SVG 그대로다.
function IcoFolder() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function IcoPdf() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z" />
</g>
</svg>
);
}
function IcoImg() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
function IcoJs() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0" />
</g>
</svg>
);
}
function IcoCss() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
function IcoZip() {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z" />
</g>
</svg>
);
}
function extOf(name: string) {
return name.slice(name.lastIndexOf(".") + 1).toLowerCase();
}
function iconFor(name: string) {
const ext = extOf(name);
if (ext === "pdf") return <IcoPdf />;
if (ext === "jpg" || ext === "jpeg" || ext === "png" || ext === "webp" || ext === "gif") return <IcoImg />;
if (ext === "css" || ext === "scss") return <IcoCss />;
if (ext === "zip" || ext === "tar" || ext === "gz") return <IcoZip />;
return <IcoJs />;
}
type Flat = { id: string; folder: boolean };
export default function TypeIconTree({
treeLabel = "files",
nodes = DEFAULT_NODES,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: TypeIconTreeProps) {
// 기본은 펼침 — 닫은 줄만 shut 에 기록한다 (vanilla 의 aria-expanded="true" 시작과 같다)
const [shut, setShut] = useState<Record<string, boolean>>({});
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const opened = (id: string) => shut[id] !== true;
const flat: Flat[] = [];
function walk(list: TypeNode[], path: string) {
list.forEach((n, i) => {
const id = `${path}-${i}`;
const kids = n.kids;
flat.push({ id, folder: !!kids });
if (kids && opened(id)) walk(kids, id);
});
}
walk(nodes, "tit");
const cur = at < flat.length ? at : flat.length - 1;
const active = flat[cur];
function pick(id: string, folder: boolean) {
const k = flat.findIndex((f) => f.id === id);
if (k >= 0) setAt(k);
if (folder) setShut((s) => ({ ...s, [id]: opened(id) }));
if (treeRef.current) treeRef.current.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, flat.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(flat.length - 1); e.preventDefault(); }
}
// 반환형을 적어둔다 — 재귀 함수라 tsc 가 추론을 순환으로 본다(TS7023)
function branch(list: TypeNode[], path: string, level: number): any[] {
return list.map((n, i) => {
const id = `${path}-${i}`;
const kids = n.kids;
const on = opened(id);
const kind = n.kind || extOf(n.name).toUpperCase();
const cls = [
styles.item,
kids ? styles.isBranch : styles.isLeaf,
kids && !on ? styles.isShut : "",
active && active.id === id ? styles.isAt : "",
].join(" ");
return (
<li key={id} id={id} className={cls} role="treeitem" aria-level={level} aria-expanded={kids ? on : undefined}>
<div className={styles.row} onClick={(e: ClickEvt) => { e.stopPropagation(); pick(id, !!kids); }}>
<span className={kids ? `${styles.ico} ${styles.icoFolder}` : styles.ico} aria-hidden="true">
{kids ? <IcoFolder /> : iconFor(n.name)}
</span>
<span className={styles.name}>{n.name}</span>
<span className={styles.kind} aria-hidden={kids ? true : undefined}>{kind}</span>
</div>
{kids ? (
<div className={styles.kids}>
<div className={styles.kwrap}>
<ul className={level === 1 ? styles.lv2 : styles.lv3} role="group" aria-label={n.name}>
{branch(kids, id, level + 1)}
</ul>
</div>
</div>
) : null}
</li>
);
});
}
return (
<div className={styles.ft} style={vars}>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={treeLabel}
tabIndex={0}
aria-activedescendant={active ? active.id : undefined}
onKeyDown={onKeyDown}
>
{branch(nodes, "tit", 1)}
</ul>
</div>
);
}02 · Type icons with indent guides — Measured area 5.169% · moving frames 11/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The rows arrive one after another: each carries an inline --i from 0 to 6, and the delay is calc(60ms + var(--i) * 80ms).
.ft__lv2, .ft__lv3 {
margin-left: 4px;
padding-left: 8px;
border-left: 1px solid rgba(23, 20, 26, .22);
transform-origin: top center;
}
.ft__lv3 { border-left-color: rgba(23, 20, 26, .14); }
.ft.is-demo .ft__row {
animation-name: ft-rise;
animation-duration: $dur-loop;
animation-timing-function: $ease-spring;
animation-iteration-count: infinite;
animation-delay: calc(60ms + var(--i) * 80ms);
}
@keyframes ft-rise {
0% { opacity: 0; transform: translateX(-6px); }
9% { opacity: 1; transform: translateX(0); }
84% { opacity: 1; transform: translateX(0); }
95%, 100% { opacity: 0; transform: translateX(-6px); }
}
03Active highlight with breadcrumb
What moves
Instead of repainting a background on whichever row is picked, one absolutely positioned bar slides between rows while the line above assembles the file's full path. It earns its place in editor and file-manager sidebars, where a long tree makes it easy to lose track of which folder the open file lives in.

<main class="stage">
<div class="ft is-demo" id="ft">
<div class="ft__crumbs" aria-hidden="true">
<span class="ft__crumb" id="ft-real" hidden>portfolio</span>
<span class="ft__cstack">
<span class="ft__cs ft__cs--1">portfolio</span>
<span class="ft__cs ft__cs--2">portfolio / site</span>
<span class="ft__cs ft__cs--3">portfolio / site / <b>cover.jpg</b></span>
</span>
</div>
<ul class="ft__tree" id="ft-tree" role="tree" aria-label="portfolio" tabindex="0" aria-activedescendant="ft-n1" aria-describedby="ft-real">
<li class="ft__item" id="ft-n1" role="treeitem" aria-level="1" data-path="portfolio">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="ft__name">portfolio</span></div>
</li>
<li class="ft__item" id="ft-n2" role="treeitem" aria-level="2" data-path="portfolio / site">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="ft__name">site</span></div>
</li>
<li class="ft__item" id="ft-n3" role="treeitem" aria-level="3" data-path="portfolio / site / cover.jpg">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="ft__name">cover.jpg</span></div>
</li>
<li class="ft__item" id="ft-n4" role="treeitem" aria-level="3" data-path="portfolio / site / main.js">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="ft__name">main.js</span></div>
</li>
<li class="ft__item" id="ft-n5" role="treeitem" aria-level="3" data-path="portfolio / site / style.css">
<div class="ft__row" data-hit><span class="ft__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ft__name">style.css</span></div>
</li>
<li class="ft__bar" aria-hidden="true"></li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-orange);
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink; // 주황 무대 위 흰 글자는 3.32:1 — 잉크 5.50:1
}
.ft__crumbs {
position: relative;
box-sizing: border-box;
height: 22px;
margin: 0 0 $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
background: rgba(23, 20, 26, .26);
display: flex; align-items: center;
overflow: hidden;
}
.ft__crumb, .ft__cs {
font-size: 11px; line-height: 22px; font-weight: 700;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ft__cstack { position: relative; display: block; width: 100%; height: 22px; }
.ft__cs {
position: absolute; inset: 0;
opacity: 0;
}
.ft__cs b { color: $stage-yellow; font-weight: 800; }
.ft__tree {
position: relative;
list-style: none;
margin: 0; padding: 0;
}
.ft__tree:focus-visible { outline: none; }
.ft__tree:focus-visible .ft__item[aria-selected="true"] .ft__row { outline: 2px solid $subject-ink; outline-offset: -2px; }
.ft__row {
box-sizing: border-box;
height: 24px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
}
.ft__ico { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.ft__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 하이라이트 막대 — 줄마다 배경을 칠하는 대신 하나가 미끄러진다
.ft__bar {
position: absolute;
left: 0; right: 0; top: 0;
height: 24px;
border-left: 3px solid $color;
border-radius: $r-xs;
background: rgba(255, 255, 255, .2);
z-index: 0;
transition: transform $duration $easing;
}
// ── 자동 루프: 막대가 내려가고 경로가 컷으로 바뀐다 ──
.ft.is-demo .ft__bar {
animation-name: hl-bar;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes hl-bar {
0%, 20% { transform: translateY(0); }
28%, 48% { transform: translateY(24px); }
56%, 84% { transform: translateY(48px); }
96%, 100% { transform: translateY(0); }
}
.ft.is-demo .ft__cs--1 {
animation-name: cs-1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-1 { 0% { opacity: 1; } 28% { opacity: 0; } 90% { opacity: 1; } 100% { opacity: 1; } }
.ft.is-demo .ft__cs--2 {
animation-name: cs-2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-2 { 0% { opacity: 0; } 28% { opacity: 1; } 56% { opacity: 0; } 100% { opacity: 0; } }
.ft.is-demo .ft__cs--3 {
animation-name: cs-3;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-3 { 0% { opacity: 0; } 56% { opacity: 1; } 90% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ft__row { height: 22px; font-size: 11px; }
.ft__bar { height: 22px; }
@keyframes hl-bar {
0%, 20% { transform: translateY(0); }
28%, 48% { transform: translateY(22px); }
56%, 84% { transform: translateY(44px); }
96%, 100% { transform: translateY(0); }
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__bar, .ft.is-demo .ft__cs { animation: none !important; }
.ft.is-demo .ft__cs--1 { opacity: 1; }
.ft__bar { 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);
}
.ft {
box-sizing: border-box;
width: min(272px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.ft__crumbs {
position: relative;
box-sizing: border-box;
height: 22px;
margin: 0 0 8px;
padding: 0 8px;
border-radius: 4px;
background: rgba(23, 20, 26, 0.26);
display: flex;
align-items: center;
overflow: hidden;
}
.ft__crumb, .ft__cs {
font-size: 11px;
line-height: 22px;
font-weight: 700;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__cstack {
position: relative;
display: block;
width: 100%;
height: 22px;
}
.ft__cs {
position: absolute;
inset: 0;
opacity: 0;
}
.ft__cs b {
color: #ffd23f;
font-weight: 800;
}
.ft__tree {
position: relative;
list-style: none;
margin: 0;
padding: 0;
}
.ft__tree:focus-visible {
outline: none;
}
.ft__tree:focus-visible .ft__item[aria-selected=true] .ft__row {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.ft__row {
box-sizing: border-box;
height: 24px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
position: relative;
z-index: 1;
}
.ft__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.ft__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ft__bar {
position: absolute;
left: 0;
right: 0;
top: 0;
height: 24px;
border-left: 3px solid #2f6df6;
border-radius: 4px;
background: rgba(255, 255, 255, 0.2);
z-index: 0;
transition: transform 300ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ft.is-demo .ft__bar {
animation-name: hl-bar;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 0.8, 0.2, 1);
animation-iteration-count: infinite;
}
@keyframes hl-bar {
0%, 20% {
transform: translateY(0);
}
28%, 48% {
transform: translateY(24px);
}
56%, 84% {
transform: translateY(48px);
}
96%, 100% {
transform: translateY(0);
}
}
.ft.is-demo .ft__cs--1 {
animation-name: cs-1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-1 {
0% {
opacity: 1;
}
28% {
opacity: 0;
}
90% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ft.is-demo .ft__cs--2 {
animation-name: cs-2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-2 {
0% {
opacity: 0;
}
28% {
opacity: 1;
}
56% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ft.is-demo .ft__cs--3 {
animation-name: cs-3;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cs-3 {
0% {
opacity: 0;
}
56% {
opacity: 1;
}
90% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ft__row {
height: 22px;
font-size: 11px;
}
.ft__bar {
height: 22px;
}
@keyframes hl-bar {
0%, 20% {
transform: translateY(0);
}
28%, 48% {
transform: translateY(22px);
}
56%, 84% {
transform: translateY(44px);
}
96%, 100% {
transform: translateY(0);
}
}
}
@media (prefers-reduced-motion: reduce) {
.ft.is-demo .ft__bar, .ft.is-demo .ft__cs {
animation: none !important;
}
.ft.is-demo .ft__cs--1 {
opacity: 1;
}
.ft__bar {
transition: none;
}
}// 실제 동작: 줄을 고르면 막대가 그 행으로 미끄러지고 경로가 다시 짜인다.
var root = document.querySelector('.ft');
var tree = document.getElementById('ft-tree');
var bar = document.querySelector('.ft__bar');
var real = document.getElementById('ft-real');
var stack = document.querySelector('.ft__cstack');
var items = [].slice.call(tree.querySelectorAll('.ft__item'));
var at = 0;
function rowH() { return items[0].querySelector('.ft__row').offsetHeight || 24; }
function pick(i) {
root.classList.remove('is-demo');
stack.hidden = true;
real.hidden = false;
at = i;
bar.style.transform = 'translateY(' + (at * rowH()) + 'px)';
real.textContent = items[at].dataset.path;
items.forEach(function (li, k) { li.setAttribute('aria-selected', String(k === at)); });
tree.setAttribute('aria-activedescendant', items[at].id);
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function () { pick(i); });
});
tree.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { pick(Math.min(at + 1, items.length - 1)); e.preventDefault(); }
else if (e.key === 'ArrowUp') { pick(Math.max(at - 1, 0)); e.preventDefault(); }
else if (e.key === 'Home') { pick(0); e.preventDefault(); }
else if (e.key === 'End') { pick(items.length - 1); e.preventDefault(); }
});
items[0].setAttribute('aria-selected', 'true');import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ActiveHighlightTree.module.scss";
export type TreeKind = "folder" | "image" | "js" | "css";
export type TreeNode = { name: string; path: string; level: number; kind: TreeKind };
export type ActiveHighlightTreeProps = {
label?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & {
"--duration"?: string;
"--easing"?: string;
"--color"?: string;
"--at"?: string;
};
type KeyEvt = { key: string; preventDefault: () => void };
function Icon({ kind }: { kind: TreeKind }) {
if (kind === "folder") {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
if (kind === "image") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
if (kind === "js") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0" />
</g>
</svg>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z" />
</g>
</svg>
);
}
const DEFAULT_NODES: TreeNode[] = [
{ name: "portfolio", path: "portfolio", level: 1, kind: "folder" },
{ name: "site", path: "portfolio / site", level: 2, kind: "folder" },
{ name: "cover.jpg", path: "portfolio / site / cover.jpg", level: 3, kind: "image" },
{ name: "main.js", path: "portfolio / site / main.js", level: 3, kind: "js" },
{ name: "style.css", path: "portfolio / site / style.css", level: 3, kind: "css" },
];
export default function ActiveHighlightTree({
label = "portfolio",
nodes = DEFAULT_NODES,
duration = 300,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: ActiveHighlightTreeProps) {
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const last = nodes.length - 1;
const cur = Math.max(0, Math.min(at, last));
const vars: Vars = {
"--duration": `${duration}ms`,
"--easing": easing,
"--color": color,
"--at": String(cur),
};
const segs = (nodes[cur] ? nodes[cur].path : "").split(" / ");
const head = segs.slice(0, -1).join(" / ");
const tail = segs[segs.length - 1];
function pick(i: number) {
setAt(Math.max(0, Math.min(i, last)));
treeRef.current?.focus({ preventScroll: true });
}
return (
<div className={styles.root} style={vars}>
<div className={styles.crumbs}>
<span className={styles.crumb} id="aht-crumb">
{head ? `${head} / ` : ""}
<b>{tail}</b>
</span>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`aht-n${cur}`}
aria-describedby="aht-crumb"
onKeyDown={(e: KeyEvt) => {
if (e.key === "ArrowDown") { pick(cur + 1); e.preventDefault(); }
else if (e.key === "ArrowUp") { pick(cur - 1); e.preventDefault(); }
else if (e.key === "Home") { pick(0); e.preventDefault(); }
else if (e.key === "End") { pick(last); e.preventDefault(); }
}}
>
{nodes.map((n, i) => (
<li
key={n.path}
id={`aht-n${i}`}
className={styles.item}
role="treeitem"
aria-level={n.level}
aria-selected={i === cur}
onClick={() => pick(i)}
>
<div className={styles.row}>
<span className={styles.ico} aria-hidden="true"><Icon kind={n.kind} /></span>
<span className={styles.name}>{n.name}</span>
</div>
</li>
))}
<li className={styles.bar} aria-hidden="true" />
</ul>
</div>
);
}03 · Active highlight with breadcrumb — Measured area 13.687% · moving frames 9/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
The jump distance comes from offsetHeight measured at runtime, so the bar stays on its row at any width.
function rowH() { return items[0].querySelector('.ft__row').offsetHeight || 24; }
function pick(i) {
root.classList.remove('is-demo');
stack.hidden = true;
real.hidden = false;
at = i;
bar.style.transform = 'translateY(' + (at * rowH()) + 'px)';
real.textContent = items[at].dataset.path;
items.forEach(function (li, k) { li.setAttribute('aria-selected', String(k === at)); });
tree.setAttribute('aria-activedescendant', items[at].id);
}
.ft__bar {
position: absolute;
left: 0; right: 0; top: 0;
height: 24px;
border-left: 3px solid $color;
border-radius: $r-xs;
background: rgba(255, 255, 255, .2);
z-index: 0;
transition: transform $duration $easing;
}
04Drag a file into a folder
What you see
Hovering a dragged row over a folder lights the target with a ring, and releasing really does move the row inside that folder — which then opens by itself, because :has() on the folder flips its child track to 1fr the moment a child appears. This is the pattern for tidying asset libraries, upload inboxes, and image galleries by hand.

<main class="stage">
<div class="dg is-demo" id="dg">
<ul class="dg__list" id="dg-list" role="tree" aria-label="files" tabindex="0" aria-activedescendant="dg-f1">
<li class="dg__f" id="dg-f1" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">uploads</span>
<span class="dg__n" aria-label="0">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="uploads"></ul></div></div>
</li>
<li class="dg__f" id="dg-f2" role="treeitem" aria-level="1" aria-expanded="false">
<div class="dg__row dg__row--f" data-hit>
<span class="dg__ico dg__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">archive</span>
<span class="dg__n" aria-label="0">0</span>
</div>
<div class="dg__kids"><div class="dg__kwrap"><ul role="group" aria-label="archive"></ul></div></div>
</li>
<li class="dg__i" id="dg-i1" role="treeitem" aria-level="1" aria-label="drag photo-01.jpg into a folder">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div>
</li>
<li class="dg__i" id="dg-i2" role="treeitem" aria-level="1" aria-label="drag report.pdf into a folder">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div>
</li>
<li class="dg__i" id="dg-i3" role="treeitem" aria-level="1" aria-label="drag data.zip into a folder">
<div class="dg__row" data-hit tabindex="-1">
<span class="dg__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="dg__name">data.zip</span>
</div>
</li>
</ul>
<div class="dg__after" aria-hidden="true">
<ul>
<li><div class="dg__row dg__row--f dg__row--flash">
<span class="dg__ico dg__ico--folder"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">uploads</span>
<span class="dg__n dg__n--on">1</span>
</div>
<ul class="dg__inkids"><li><div class="dg__row dg__row--in">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">photo-01.jpg</span>
</div></li></ul></li>
<li><div class="dg__row dg__row--f">
<span class="dg__ico dg__ico--folder"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="dg__name">archive</span>
<span class="dg__n">0</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span>
<span class="dg__name">report.pdf</span>
</div></li>
<li><div class="dg__row">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="dg__name">data.zip</span>
</div></li>
</ul>
</div>
<div class="dg__ghost" aria-hidden="true">
<span class="dg__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span>
photo-01.jpg
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-paper);
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0; padding: 0;
}
.dg__list:focus-visible { outline: none; }
.dg__list li[aria-selected="true"] > .dg__row { outline: 2px solid $color; outline-offset: -2px; }
.dg__list:focus-visible .dg__row { outline: none; }
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border: 1px solid rgba(23, 20, 26, .1);
border-radius: $r-xs;
background: #fff;
font-size: 12px; font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color $duration $easing, background $duration $easing, box-shadow $duration $easing;
}
.dg__row--f { cursor: pointer; }
.dg__row:hover { border-color: rgba(23, 20, 26, .24); }
.dg__ico { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.dg__ico--folder { color: $color; }
.dg__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px; height: 16px;
display: inline-flex; align-items: center; justify-content: center;
padding: 0 $sp-1;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
font-size: 10px; line-height: 16px; font-weight: 800;
color: rgba(23, 20, 26, .72);
}
.dg__n--on { background: $color; color: #fff; }
// 폴더가 드롭 대상으로 반응하는 순간
.dg__row.is-hot {
border-color: $color;
background: rgba(47, 109, 246, .08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, .25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows $duration $easing;
}
.dg__kwrap { overflow: hidden; min-height: 0; }
.dg__f:has(.dg__kids li) > .dg__kids { grid-template-rows: 1fr; }
.dg__kids .dg__row { margin-left: $sp-4; }
.dg__row.is-in { animation-name: dg-in; animation-duration: 320ms; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
@keyframes dg-in { 0% { transform: scale(.94); background: rgba(47, 109, 246, .16); } 100% { transform: scale(1); background: #fff; } }
// 루프용 결과 상태 — 실제 목록 위에 겹쳐 놓는다
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost { display: none; }
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids { padding-left: $sp-4; }
.dg__row--in { margin-bottom: $sp-1; }
.dg__row--flash { border-color: $color; background: rgba(47, 109, 246, .08); }
// 끌리는 파일 조각
.dg__ghost {
position: absolute;
left: 24px; top: 65px;
display: inline-flex; align-items: center; gap: $sp-1;
height: 24px;
padding: 0 $sp-2;
border-radius: $r-xs;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
box-shadow: $shadow-raised;
font-size: 11px; font-weight: 700;
color: $stage-ink;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live { position: fixed; opacity: 1; }
// ── 자동 루프: 조각이 떠올라 폴더로 들어가고 목록이 컷으로 바뀐다 ──
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% { opacity: 0; }
4% { opacity: 1; }
39.9% { opacity: 1; }
40% { opacity: 0; }
100% { opacity: 0; }
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% { opacity: 0; }
39.9% { opacity: 0; }
40% { opacity: 1; }
82% { opacity: 1; }
94%, 100% { opacity: 0; }
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -52px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -52px) scale(.96); }
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
30%, 39.9% { border-color: $color; box-shadow: 0 0 0 2px rgba(47, 109, 246, .25); }
40%, 100% { border-color: rgba(23, 20, 26, .1); box-shadow: none; }
}
@media (max-width: 340px) {
.dg__row { height: 22px; font-size: 11px; }
@keyframes dg-ghost {
0%, 4% { opacity: 0; transform: translate(0, 0); }
5% { opacity: 1; }
6% { transform: translate(0, 0) scale(.96); }
34% { transform: translate(10px, -46px) scale(.96); }
39.9% { opacity: 1; }
40%, 100% { opacity: 0; transform: translate(10px, -46px) scale(.96); }
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row { animation: none !important; }
.dg__row, .dg__kids { transition: none; }
.dg:not(.is-demo) .dg__after { display: 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);
}
.dg {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.dg__list, .dg__list ul, .dg__after ul {
list-style: none;
margin: 0;
padding: 0;
}
.dg__list:focus-visible {
outline: none;
}
.dg__list li[aria-selected=true] > .dg__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.dg__list:focus-visible .dg__row {
outline: none;
}
.dg__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border: 1px solid rgba(23, 20, 26, 0.1);
border-radius: 4px;
background: #fff;
font-size: 12px;
font-weight: 600;
cursor: grab;
user-select: none;
transition: border-color 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms cubic-bezier(0.2, 0.8, 0.2, 1), box-shadow 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__row--f {
cursor: pointer;
}
.dg__row:hover {
border-color: rgba(23, 20, 26, 0.24);
}
.dg__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.dg__ico--folder {
color: #2f6df6;
}
.dg__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.dg__n {
margin-left: auto;
flex: 0 0 auto;
min-width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
padding: 0 4px;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
font-size: 10px;
line-height: 16px;
font-weight: 800;
color: rgba(23, 20, 26, 0.72);
}
.dg__n--on {
background: #2f6df6;
color: #fff;
}
.dg__row.is-hot {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
.dg__kids {
display: grid;
grid-template-rows: 0fr;
transition: grid-template-rows 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.dg__kwrap {
overflow: hidden;
min-height: 0;
}
.dg__f:has(.dg__kids li) > .dg__kids {
grid-template-rows: 1fr;
}
.dg__kids .dg__row {
margin-left: 16px;
}
.dg__row.is-in {
animation-name: dg-in;
animation-duration: 320ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes dg-in {
0% {
transform: scale(0.94);
background: rgba(47, 109, 246, 0.16);
}
100% {
transform: scale(1);
background: #fff;
}
}
.dg:not(.is-demo) .dg__after, .dg:not(.is-demo) .dg__ghost {
display: none;
}
.dg__after {
position: absolute;
inset: 0;
opacity: 0;
pointer-events: none;
}
.dg__inkids {
padding-left: 16px;
}
.dg__row--in {
margin-bottom: 4px;
}
.dg__row--flash {
border-color: #2f6df6;
background: rgba(47, 109, 246, 0.08);
}
.dg__ghost {
position: absolute;
left: 24px;
top: 65px;
display: inline-flex;
align-items: center;
gap: 4px;
height: 24px;
padding: 0 8px;
border-radius: 4px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
font-size: 11px;
font-weight: 700;
color: #17141a;
opacity: 0;
pointer-events: none;
z-index: 2;
}
.dg__ghost.is-live {
position: fixed;
opacity: 1;
}
.dg.is-demo .dg__list {
animation-name: dg-list;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-list {
0% {
opacity: 0;
}
4% {
opacity: 1;
}
39.9% {
opacity: 1;
}
40% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.dg.is-demo .dg__after {
animation-name: dg-after;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-after {
0% {
opacity: 0;
}
39.9% {
opacity: 0;
}
40% {
opacity: 1;
}
82% {
opacity: 1;
}
94%, 100% {
opacity: 0;
}
}
.dg.is-demo .dg__ghost {
animation-name: dg-ghost;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -52px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -52px) scale(0.96);
}
}
.dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation-name: dg-hot;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes dg-hot {
0%, 29.9% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
30%, 39.9% {
border-color: #2f6df6;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.25);
}
40%, 100% {
border-color: rgba(23, 20, 26, 0.1);
box-shadow: none;
}
}
@media (max-width: 340px) {
.dg__row {
height: 22px;
font-size: 11px;
}
@keyframes dg-ghost {
0%, 4% {
opacity: 0;
transform: translate(0, 0);
}
5% {
opacity: 1;
}
6% {
transform: translate(0, 0) scale(0.96);
}
34% {
transform: translate(10px, -46px) scale(0.96);
}
39.9% {
opacity: 1;
}
40%, 100% {
opacity: 0;
transform: translate(10px, -46px) scale(0.96);
}
}
}
@media (prefers-reduced-motion: reduce) {
.dg.is-demo .dg__list, .dg.is-demo .dg__after, .dg.is-demo .dg__ghost, .dg.is-demo .dg__list .dg__f:first-child .dg__row {
animation: none !important;
}
.dg__row, .dg__kids {
transition: none;
}
.dg:not(.is-demo) .dg__after {
display: none;
}
}// 실제 동작: 포인터로 끌어 폴더에 놓는다(마우스·터치). 키보드는 Enter 로 이동 모드.
var root = document.querySelector('.dg');
var list = document.getElementById('dg-list');
var files = [].slice.call(list.querySelectorAll('.dg__i .dg__row'));
var folders = [].slice.call(list.querySelectorAll('.dg__f'));
function clearHot() { folders.forEach(function (f) { f.querySelector('.dg__row').classList.remove('is-hot'); }); }
function bump(f) {
var n = f.querySelector('.dg__n'), k = f.querySelectorAll('.dg__kids li').length;
n.textContent = k; n.classList.toggle('dg__n--on', k > 0);
f.setAttribute('aria-expanded', String(k > 0));
}
function drop(file, folder) {
folder.querySelector('.dg__kids ul').appendChild(file.closest('li'));
file.classList.add('is-in');
setTimeout(function () { file.classList.remove('is-in'); }, 320);
bump(folder);
}
files.forEach(function (row) {
row.addEventListener('pointerdown', function (e) {
if (e.button && e.button !== 0) return;
root.classList.remove('is-demo');
var gh = document.querySelector('.dg__ghost').cloneNode(true);
gh.classList.add('is-live'); gh.removeAttribute('aria-hidden');
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
document.body.appendChild(gh);
row.setPointerCapture(e.pointerId);
function mv(ev) {
gh.style.transform = 'translate(' + (ev.clientX - e.clientX) + 'px,' + (ev.clientY - e.clientY) + 'px) scale(.96)';
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
clearHot();
var f = hit && hit.closest && hit.closest('.dg__f');
if (f) f.querySelector('.dg__row').classList.add('is-hot');
}
function up(ev) {
row.removeEventListener('pointermove', mv);
row.removeEventListener('pointerup', up);
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
var f = hit && hit.closest && hit.closest('.dg__f');
clearHot();
gh.remove();
if (f) drop(row, f);
}
row.addEventListener('pointermove', mv);
row.addEventListener('pointerup', up);
e.preventDefault();
});
});
var moving = null, moveRow = null;
var rows = [].slice.call(list.querySelectorAll('li[role="treeitem"]'));
rows.forEach(function (li, i) {
li.querySelector('.dg__row').addEventListener('click', function () { list.setAttribute('aria-activedescendant', li.id); });
});
list.addEventListener('keydown', function (e) {
if (!moving && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) {
var cur = rows.indexOf(document.getElementById(list.getAttribute('aria-activedescendant')));
var nxt = Math.max(0, Math.min(cur + (e.key === 'ArrowDown' ? 1 : -1), rows.length - 1));
list.setAttribute('aria-activedescendant', rows[nxt].id);
e.preventDefault();
return;
}
if (moving) {
var k = folders.indexOf(moving);
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
clearHot();
moving = folders[(k + (e.key === 'ArrowDown' ? 1 : folders.length - 1)) % folders.length];
moving.querySelector('.dg__row').classList.add('is-hot');
list.setAttribute('aria-activedescendant', moving.id);
e.preventDefault();
} else if (e.key === 'Enter') {
var f = moving, row = moveRow;
moving = null; moveRow = null; clearHot();
if (row) drop(row, f);
e.preventDefault();
} else if (e.key === 'Escape') {
moving = null; moveRow = null; clearHot();
e.preventDefault();
}
return;
}
var at = list.getAttribute('aria-activedescendant');
var li = at && document.getElementById(at);
if (e.key === 'Enter' && li && li.classList.contains('dg__i')) {
root.classList.remove('is-demo');
moving = folders[0]; moveRow = li.querySelector('.dg__row');
clearHot();
moving.querySelector('.dg__row').classList.add('is-hot');
e.preventDefault();
}
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./DragIntoFolderTree.module.scss";
export type FileKind = "image" | "pdf" | "zip";
export type FolderItem = { id: string; name: string };
export type FileItem = { id: string; name: string; kind: FileKind };
export type DragIntoFolderTreeProps = {
label?: string;
folders?: FolderItem[];
files?: FileItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type PointerEvt = {
button: number;
clientX: number;
clientY: number;
pointerId: number;
currentTarget: { setPointerCapture: (id: number) => void };
preventDefault: () => void;
};
type PtrMove = { clientX: number; clientY: number };
type KeyDown = { key: string };
function FolderIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function FileIcon({ kind }: { kind: FileKind }) {
if (kind === "image") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2" />
<path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z" />
</g>
</svg>
);
}
if (kind === "pdf") {
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z" />
</g>
</svg>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z" />
</g>
</svg>
);
}
const DEFAULT_FOLDERS: FolderItem[] = [
{ id: "uploads", name: "uploads" },
{ id: "archive", name: "archive" },
];
const DEFAULT_FILES: FileItem[] = [
{ id: "photo", name: "photo-01.jpg", kind: "image" },
{ id: "report", name: "report.pdf", kind: "pdf" },
{ id: "data", name: "data.zip", kind: "zip" },
];
export default function DragIntoFolderTree({
label = "files",
folders = DEFAULT_FOLDERS,
files = DEFAULT_FILES,
duration = 220,
easing = "cubic-bezier(.2,.8,.2,1)",
color = "#2f6df6",
}: DragIntoFolderTreeProps) {
const [where, setWhere] = useState<{ [k: string]: string | null }>({});
const [drag, setDrag] = useState<{ id: string; x: number; y: number } | null>(null);
const [hot, setHot] = useState<string | null>(null);
const [justIn, setJustIn] = useState<string | null>(null);
const [moving, setMoving] = useState<{ fileId: string; folder: number } | null>(null);
const [at, setAt] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const kidsOf = (fid: string) => files.filter((f) => where[f.id] === fid);
const loose = files.filter((f) => !where[f.id]);
const rows: { key: string; file: FileItem | null }[] = [];
folders.forEach((fo) => {
rows.push({ key: fo.id, file: null });
kidsOf(fo.id).forEach((f) => rows.push({ key: f.id, file: f }));
});
loose.forEach((f) => rows.push({ key: f.id, file: f }));
const cur = Math.max(0, Math.min(at, rows.length - 1));
const active = rows[cur];
function drop(fileId: string, folderId: string) {
setWhere((w) => {
const next: { [k: string]: string | null } = {};
for (const k in w) next[k] = w[k];
next[fileId] = folderId;
return next;
});
setJustIn(fileId);
window.setTimeout(() => setJustIn(null), 320);
}
// 포인터로 끌어 폴더에 놓는다 (마우스·터치). Escape 로 취소.
useEffect(() => {
if (!drag) return;
const id = drag.id;
function folderAt(x: number, y: number): string | null {
const el = document.elementFromPoint(x, y);
const f = el ? el.closest("[data-folder]") : null;
return f ? f.getAttribute("data-folder") : null;
}
function onMove(ev: PtrMove) {
const x = ev.clientX;
const y = ev.clientY;
setDrag((d) => (d ? { id: d.id, x: x, y: y } : d));
setHot(folderAt(x, y));
}
function onUp(ev: PtrMove) {
const fid = folderAt(ev.clientX, ev.clientY);
setDrag(null);
setHot(null);
if (fid) drop(id, fid);
}
function onKey(ev: KeyDown) {
if (ev.key === "Escape") {
setDrag(null);
setHot(null);
}
}
document.addEventListener("pointermove", onMove);
document.addEventListener("pointerup", onUp);
document.addEventListener("pointercancel", onUp);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("pointermove", onMove);
document.removeEventListener("pointerup", onUp);
document.removeEventListener("pointercancel", onUp);
document.removeEventListener("keydown", onKey);
};
}, [drag ? drag.id : null]);
function grab(e: PointerEvt, fileId: string) {
if (e.button && e.button !== 0) return;
e.currentTarget.setPointerCapture(e.pointerId);
setDrag({ id: fileId, x: e.clientX, y: e.clientY });
setMoving(null);
listRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
function onKeyDown(e: KeyEvt) {
if (moving) {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
const step = e.key === "ArrowDown" ? 1 : folders.length - 1;
const next = (moving.folder + step) % folders.length;
setMoving({ fileId: moving.fileId, folder: next });
setHot(folders[next].id);
e.preventDefault();
} else if (e.key === "Enter" || e.key === " ") {
drop(moving.fileId, folders[moving.folder].id);
setMoving(null);
setHot(null);
e.preventDefault();
} else if (e.key === "Escape") {
setMoving(null);
setHot(null);
e.preventDefault();
}
return;
}
if (e.key === "ArrowDown") { setAt(Math.min(cur + 1, rows.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt(Math.max(cur - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(rows.length - 1); e.preventDefault(); }
else if ((e.key === "Enter" || e.key === " ") && active && active.file) {
setMoving({ fileId: active.file.id, folder: 0 });
setHot(folders[0].id);
e.preventDefault();
}
}
function fileRow(f: FileItem, inside: boolean) {
const rowCls = [styles.row, inside ? styles.rowIn : "", justIn === f.id ? styles.isIn : ""]
.filter(Boolean)
.join(" ");
return (
<li
key={f.id}
id={`dg-${f.id}`}
className={styles.item}
role="treeitem"
aria-level={inside ? 2 : 1}
aria-selected={active ? active.key === f.id : false}
aria-grabbed={drag ? drag.id === f.id : moving ? moving.fileId === f.id : false}
>
<div
className={rowCls}
onPointerDown={(e: PointerEvt) => grab(e, f.id)}
onClick={() => setAt(rows.findIndex((r) => r.key === f.id))}
>
<span className={styles.ico} aria-hidden="true"><FileIcon kind={f.kind} /></span>
<span className={styles.name}>{f.name}</span>
</div>
</li>
);
}
const ghostFile = drag ? files.filter((f) => f.id === drag.id)[0] : null;
return (
<div className={styles.root} style={vars}>
<ul
ref={listRef}
className={styles.list}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={active ? `dg-${active.key}` : undefined}
onKeyDown={onKeyDown}
>
{folders.map((fo) => {
const kids = kidsOf(fo.id);
return (
<li
key={fo.id}
id={`dg-${fo.id}`}
className={styles.item}
role="treeitem"
aria-level={1}
aria-expanded={kids.length > 0}
aria-selected={active ? active.key === fo.id : false}
data-folder={fo.id}
>
<div
className={`${styles.row} ${styles.rowFolder} ${hot === fo.id ? styles.isHot : ""}`}
onClick={() => setAt(rows.findIndex((r) => r.key === fo.id))}
>
<span className={`${styles.ico} ${styles.icoFolder}`} aria-hidden="true"><FolderIcon /></span>
<span className={styles.name}>{fo.name}</span>
<span className={`${styles.count} ${kids.length > 0 ? styles.countOn : ""}`}>{kids.length}</span>
</div>
<div className={`${styles.kids} ${kids.length > 0 ? styles.kidsOpen : ""}`}>
<div className={styles.kwrap}>
<ul className={styles.group} role="group" aria-label={fo.name}>
{kids.map((f) => fileRow(f, true))}
</ul>
</div>
</div>
</li>
);
})}
{loose.map((f) => fileRow(f, false))}
</ul>
{drag && ghostFile ? (
<div
className={styles.ghost}
style={{ left: `${drag.x}px`, top: `${drag.y}px` }}
aria-hidden="true"
>
<span className={styles.ico}><FileIcon kind={ghostFile.kind} /></span>
{ghostFile.name}
</div>
) : null}
</div>
);
}04 · Drag a file into a folder — Measured area 13.852% · moving frames 14/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The ghost copy follows the pointer through setPointerCapture, and document.elementFromPoint() decides what is underneath.
function drop(file, folder) {
folder.querySelector('.dg__kids ul').appendChild(file.closest('li'));
file.classList.add('is-in');
setTimeout(function () { file.classList.remove('is-in'); }, 320);
bump(folder);
}
function mv(ev) {
gh.style.transform = 'translate(' + (ev.clientX - e.clientX) + 'px,' + (ev.clientY - e.clientY) + 'px) scale(.96)';
gh.style.left = e.clientX + 'px'; gh.style.top = e.clientY + 'px';
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
clearHot();
var f = hit && hit.closest && hit.closest('.dg__f');
if (f) f.querySelector('.dg__row').classList.add('is-hot');
}
function up(ev) {
row.removeEventListener('pointermove', mv);
row.removeEventListener('pointerup', up);
var hit = document.elementFromPoint(ev.clientX, ev.clientY);
var f = hit && hit.closest && hit.closest('.dg__f');
clearHot();
gh.remove();
if (f) drop(row, f);
}
05Shift and Ctrl multi-select
What happens on screen
A plain click selects one row, Shift extends the range from the anchor, Ctrl or Cmd toggles rows one by one, and a badge above the list counts what you have picked up. It is the selection behavior for lists where several files must be exported, moved, or deleted together.

<main class="stage">
<div class="ms is-demo" id="ms">
<div class="ms__head">
<span class="ms__title" id="ms-title">6 assets</span>
<span class="ms__badge" aria-live="polite">
<span class="ms__b ms__b--0">0 selected</span>
<span class="ms__b ms__b--1">1 selected</span>
<span class="ms__b ms__b--4">4 selected</span>
<span class="ms__breal" id="ms-badge" hidden>0 selected</span>
</span>
</div>
<ul class="ms__list" id="ms-list" role="tree" aria-label="assets" aria-multiselectable="true" tabindex="0" aria-activedescendant="ms-1">
<li class="ms__item" id="ms-1" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="ms__name">hero.jpg</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
<li class="ms__item is-a" id="ms-2" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z"/></g></svg></span><span class="ms__name">resume.pdf</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
<li class="ms__item is-b" style="--i: 0" id="ms-3" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="ms__name">bundle.zip</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
<li class="ms__item is-b" style="--i: 1" id="ms-4" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="ms__name">main.js</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
<li class="ms__item is-b" style="--i: 2" id="ms-5" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z"/></g></svg></span><span class="ms__name">style.css</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
<li class="ms__item" id="ms-6" role="treeitem" aria-level="1" aria-selected="false">
<div class="ms__row" data-hit><span class="ms__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span><span class="ms__name">notes.md</span><span class="ms__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-spring;
$color: $subject-blue;
@include stage($stage-ink);
.ms {
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ms__head {
display: flex; align-items: center; justify-content: space-between;
margin: 0 0 $sp-2;
}
.ms__title {
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(255, 255, 255, .6);
}
.ms__badge {
position: relative;
box-sizing: border-box;
height: 20px;
padding: 0 $sp-2;
border-radius: $r-pill;
background: rgba(255, 255, 255, .1);
display: inline-flex; align-items: center; justify-content: flex-end;
overflow: hidden;
}
.ms__b, .ms__breal {
position: absolute; right: $sp-2;
font-size: 10px; line-height: 20px; font-weight: 800;
white-space: nowrap;
color: rgba(255, 255, 255, .75);
}
.ms__b--1, .ms__b--4 { color: $stage-yellow; }
.ms__b { opacity: 0; }
.ms__b--0 { opacity: 1; }
.ms__list { list-style: none; margin: 0; padding: 0; }
.ms__list:focus-visible { outline: none; }
.ms__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
background: rgba(255, 255, 255, .05);
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
transition: background $duration $easing;
}
.ms__row:hover { background: rgba(255, 255, 255, .1); }
.ms__item.is-sel .ms__row { background: rgba(47, 109, 246, .3); }
.ms__ico { display: inline-flex; flex: 0 0 auto; color: rgba(255, 255, 255, .8); }
.ms__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ms__ok {
margin-left: auto;
display: inline-flex; flex: 0 0 auto;
color: $stage-yellow;
opacity: 0;
transform: scale(.6);
transition: opacity $duration $easing, transform $duration $ease-pop;
}
.ms__item.is-sel .ms__ok { opacity: 1; transform: scale(1); }
// ── 자동 루프: 한 줄 고르고 → Shift 범위로 네 줄 → 풀린다 ──
.ms.is-demo .ms__item.is-a .ms__row {
animation-name: ms-sela;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-sela {
0%, 9.9% { background: rgba(255, 255, 255, .05); }
10%, 82% { background: rgba(47, 109, 246, .3); }
82.01%, 100% { background: rgba(255, 255, 255, .05); }
}
.ms.is-demo .ms__item.is-a .ms__ok {
animation-name: ms-oka;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-oka { 0%, 9.9% { opacity: 0; } 10%, 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.ms.is-demo .ms__item.is-b .ms__row {
animation-name: ms-selb;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-selb {
0%, 21.9% { background: rgba(255, 255, 255, .05); }
22%, 82% { background: rgba(47, 109, 246, .3); }
82.01%, 100% { background: rgba(255, 255, 255, .05); }
}
.ms.is-demo .ms__item.is-b .ms__ok {
animation-name: ms-okb;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-okb { 0%, 21.9% { opacity: 0; } 22%, 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.ms.is-demo .ms__b--0 {
animation-name: ms-b0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b0 { 0% { opacity: 1; } 13% { opacity: 0; } 89% { opacity: 1; } 100% { opacity: 1; } }
.ms.is-demo .ms__b--1 {
animation-name: ms-b1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b1 { 0% { opacity: 0; } 13% { opacity: 1; } 37% { opacity: 0; } 100% { opacity: 0; } }
.ms.is-demo .ms__b--4 {
animation-name: ms-b4;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b4 { 0% { opacity: 0; } 37% { opacity: 1; } 89% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.ms__row { height: 20px; font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row, .ms.is-demo .ms__ok, .ms.is-demo .ms__b { animation: none !important; }
.ms__row, .ms__ok { 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);
}
.ms {
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.ms__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.ms__title {
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.6);
}
.ms__badge {
position: relative;
box-sizing: border-box;
height: 20px;
padding: 0 8px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.1);
display: inline-flex;
align-items: center;
justify-content: flex-end;
overflow: hidden;
}
.ms__b, .ms__breal {
position: absolute;
right: 8px;
font-size: 10px;
line-height: 20px;
font-weight: 800;
white-space: nowrap;
color: rgba(255, 255, 255, 0.75);
}
.ms__b--1, .ms__b--4 {
color: #ffd23f;
}
.ms__b {
opacity: 0;
}
.ms__b--0 {
opacity: 1;
}
.ms__list {
list-style: none;
margin: 0;
padding: 0;
}
.ms__list:focus-visible {
outline: none;
}
.ms__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
background: rgba(255, 255, 255, 0.05);
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
transition: background 220ms cubic-bezier(0.2, 0.8, 0.2, 1);
}
.ms__row:hover {
background: rgba(255, 255, 255, 0.1);
}
.ms__item.is-sel .ms__row {
background: rgba(47, 109, 246, 0.3);
}
.ms__ico {
display: inline-flex;
flex: 0 0 auto;
color: rgba(255, 255, 255, 0.8);
}
.ms__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.ms__ok {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
color: #ffd23f;
opacity: 0;
transform: scale(0.6);
transition: opacity 220ms cubic-bezier(0.2, 0.8, 0.2, 1), transform 220ms cubic-bezier(0.2, 1.4, 0.4, 1);
}
.ms__item.is-sel .ms__ok {
opacity: 1;
transform: scale(1);
}
.ms.is-demo .ms__item.is-a .ms__row {
animation-name: ms-sela;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-sela {
0%, 9.9% {
background: rgba(255, 255, 255, 0.05);
}
10%, 82% {
background: rgba(47, 109, 246, 0.3);
}
82.01%, 100% {
background: rgba(255, 255, 255, 0.05);
}
}
.ms.is-demo .ms__item.is-a .ms__ok {
animation-name: ms-oka;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-oka {
0%, 9.9% {
opacity: 0;
}
10%, 82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.ms.is-demo .ms__item.is-b .ms__row {
animation-name: ms-selb;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-selb {
0%, 21.9% {
background: rgba(255, 255, 255, 0.05);
}
22%, 82% {
background: rgba(47, 109, 246, 0.3);
}
82.01%, 100% {
background: rgba(255, 255, 255, 0.05);
}
}
.ms.is-demo .ms__item.is-b .ms__ok {
animation-name: ms-okb;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
animation-delay: calc(var(--i) * 90ms);
}
@keyframes ms-okb {
0%, 21.9% {
opacity: 0;
}
22%, 82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.ms.is-demo .ms__b--0 {
animation-name: ms-b0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b0 {
0% {
opacity: 1;
}
13% {
opacity: 0;
}
89% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.ms.is-demo .ms__b--1 {
animation-name: ms-b1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b1 {
0% {
opacity: 0;
}
13% {
opacity: 1;
}
37% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.ms.is-demo .ms__b--4 {
animation-name: ms-b4;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes ms-b4 {
0% {
opacity: 0;
}
37% {
opacity: 1;
}
89% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.ms__row {
height: 20px;
font-size: 11px;
}
}
@media (prefers-reduced-motion: reduce) {
.ms.is-demo .ms__row, .ms.is-demo .ms__ok, .ms.is-demo .ms__b {
animation: none !important;
}
.ms__row, .ms__ok {
transition: none;
}
}// 실제 동작: 클릭 한 개, Ctrl+클릭 토글, Shift+클릭 범위.
var root = document.querySelector('.ms');
var list = document.getElementById('ms-list');
var badge = document.getElementById('ms-badge');
var items = [].slice.call(list.querySelectorAll('.ms__item'));
var anchor = 0;
function sync() {
var n = 0;
items.forEach(function (li, i) {
var on = li.classList.contains('is-sel');
li.setAttribute('aria-selected', String(on));
if (on) { n++; list.setAttribute('aria-activedescendant', li.id); }
});
badge.textContent = n + ' selected';
}
items.forEach(function (li, i) {
li.querySelector('[data-hit]').addEventListener('click', function (e) {
root.classList.remove('is-demo');
document.querySelector('.ms__badge').querySelectorAll('.ms__b').forEach(function (b) { b.hidden = true; });
badge.hidden = false;
if (e.shiftKey) {
var a = Math.min(anchor, i), z = Math.max(anchor, i);
items.forEach(function (l, k) { l.classList.toggle('is-sel', k >= a && k <= z); });
} else if (e.ctrlKey || e.metaKey) {
li.classList.toggle('is-sel');
anchor = i;
} else {
items.forEach(function (l) { l.classList.remove('is-sel'); });
li.classList.add('is-sel');
anchor = i;
}
sync();
});
});
list.addEventListener('keydown', function (e) {
var at = items.indexOf(document.getElementById(list.getAttribute('aria-activedescendant')));
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
var li = items[Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), items.length - 1))];
list.setAttribute('aria-activedescendant', li.id);
e.preventDefault();
} else if (e.key === ' ' && at >= 0) {
root.classList.remove('is-demo');
items[at].classList.toggle('is-sel');
anchor = at;
sync();
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./MultiSelectTree.module.scss";
export type FileKind = "img" | "pdf" | "zip" | "js" | "css" | "md";
export type FileItem = { name: string; kind: FileKind };
export type MultiSelectTreeProps = {
label?: string;
items?: FileItem[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ClickEvt = { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean };
type Glyph = { dim: string; solid: string };
const PAGE = "M208 88h-56V32Z";
const GLYPH: Record<FileKind, Glyph> = {
img: {
dim: "m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z",
solid: "M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z",
},
pdf: {
dim: PAGE,
solid: "M52 144H36a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m169.53-4.91a8 8 0 0 1 .25 11.31A30.06 30.06 0 0 1 200 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30.06 30.06 0 0 1 21.78 9.6a8 8 0 0 1-11.56 11.06A14.24 14.24 0 0 0 200 160c-8.82 0-16 9-16 20s7.18 20 16 20a14.18 14.18 0 0 0 10.22-4.66a8 8 0 0 1 11.31-.25M128 144c-17.64 0-32 16.15-32 36s14.36 36 32 36s32-16.15 32-36s-14.36-36-32-36m0 56c-8.82 0-16-9-16-20s7.18-20 16-20s16 9 16 20s-7.18 20-16 20m-80-80a8 8 0 0 0 8-8V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 8 8m112-68.69L188.69 80H160Z",
},
zip: {
dim: PAGE,
solid: "M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z",
},
js: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0",
},
css: {
dim: PAGE,
solid: "M48 180c0 11 7.18 20 16 20a14.2 14.2 0 0 0 10.22-4.66a8 8 0 1 1 11.55 11.06A30 30 0 0 1 64 216c-17.65 0-32-16.15-32-36s14.35-36 32-36a30 30 0 0 1 21.77 9.6a8 8 0 1 1-11.55 11.06A14.24 14.24 0 0 0 64 160c-8.82 0-16 9-16 20m79.6-8.69c-4-1.16-8.14-2.35-10.45-3.84c-1.26-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.35-1.73 19.83-.56a8 8 0 0 0 4.07-15.48c-2.12-.55-21-5.22-32.83 2.76a20.55 20.55 0 0 0-9 14.95c-2 15.88 13.64 20.41 23 23.11c12.07 3.49 13.13 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.14 3.93c-4.6 3.06-15.17 1.56-19.55.36a8 8 0 0 0-4.3 15.41a61.2 61.2 0 0 0 15.18 2c5.83 0 12.3-1 17.49-4.46a20.82 20.82 0 0 0 9.19-15.23c2.25-17.28-14.27-22.11-24.15-24.97m64 0c-4-1.16-8.14-2.35-10.45-3.84c-1.25-.81-1.23-1-1.12-1.9a4.54 4.54 0 0 1 2-3.67c4.6-3.12 15.34-1.73 19.82-.56a8 8 0 0 0 4.07-15.48c-2.11-.55-21-5.22-32.83 2.76a20.58 20.58 0 0 0-8.95 14.95c-2 15.88 13.65 20.41 23 23.11c12.06 3.49 13.12 4.92 12.78 7.59c-.31 2.41-1.26 3.34-2.15 3.93c-4.6 3.06-15.16 1.56-19.54.36a8 8 0 0 0-4.3 15.44a61.3 61.3 0 0 0 15.19 2c5.82 0 12.3-1 17.49-4.46a20.81 20.81 0 0 0 9.18-15.23c2.21-17.31-14.31-22.14-24.2-25ZM40 112V40a16 16 0 0 1 16-16h96a8 8 0 0 1 5.66 2.34l56 56A8 8 0 0 1 216 88v24a8 8 0 1 1-16 0V96h-48a8 8 0 0 1-8-8V40H56v72a8 8 0 0 1-16 0m120-32h28.68L160 51.31Z",
},
md: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152",
},
};
const CHECK: Glyph = {
dim: "M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16",
solid: "m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32",
};
function Icon({ glyph, size }: { glyph: Glyph; size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size}>
<g fill="currentColor">
<path d={glyph.dim} opacity=".2" />
<path d={glyph.solid} />
</g>
</svg>
);
}
const DEFAULT_ITEMS: FileItem[] = [
{ name: "hero.jpg", kind: "img" },
{ name: "resume.pdf", kind: "pdf" },
{ name: "bundle.zip", kind: "zip" },
{ name: "main.js", kind: "js" },
{ name: "style.css", kind: "css" },
{ name: "notes.md", kind: "md" },
];
export default function MultiSelectTree({
label = "자료",
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2, .8, .2, 1)",
color = "#2f6df6",
}: MultiSelectTreeProps) {
const [sel, setSel] = useState<boolean[]>(items.map(() => false));
const [anchor, setAnchor] = useState(0);
const [active, setActive] = useState(0);
const listRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const count = sel.filter(Boolean).length;
const last = items.length - 1;
function hit(i: number, e: ClickEvt) {
if (e.shiftKey) {
const a = Math.min(anchor, i);
const z = Math.max(anchor, i);
setSel(items.map((_, k) => k >= a && k <= z));
} else if (e.ctrlKey || e.metaKey) {
setSel((prev) => prev.map((v, k) => (k === i ? !v : v)));
setAnchor(i);
} else {
setSel(items.map((_, k) => k === i));
setAnchor(i);
}
setActive(i);
listRef.current?.focus({ preventScroll: true });
}
function onKey(e: KeyEvt) {
if (e.key === "ArrowDown") {
setActive((a) => Math.min(a + 1, last));
e.preventDefault();
} else if (e.key === "ArrowUp") {
setActive((a) => Math.max(a - 1, 0));
e.preventDefault();
} else if (e.key === " ") {
setSel((prev) => prev.map((v, k) => (k === active ? !v : v)));
setAnchor(active);
e.preventDefault();
}
}
return (
<div className={styles.ms} style={vars}>
<div className={styles.head}>
<span className={styles.title} id="ms-title">{`${label} ${items.length}개`}</span>
<span className={`${styles.badge} ${count > 0 ? styles.badgeOn : ""}`} aria-live="polite">
{`${count}개 선택`}
</span>
</div>
<ul
ref={listRef}
className={styles.list}
role="tree"
aria-labelledby="ms-title"
aria-multiselectable="true"
tabIndex={0}
aria-activedescendant={`ms-o${active}`}
onKeyDown={onKey}
>
{items.map((it, i) => (
<li
key={it.name}
id={`ms-o${i}`}
className={`${styles.item} ${sel[i] ? styles.isSel : ""} ${i === active ? styles.isActive : ""}`}
role="treeitem"
aria-level={1}
aria-selected={sel[i]}
>
<div className={styles.row} onClick={(e: ClickEvt) => hit(i, e)}>
<span className={styles.ico} aria-hidden="true">
<Icon glyph={GLYPH[it.kind]} size={13} />
</span>
<span className={styles.name}>{it.name}</span>
<span className={styles.ok} aria-hidden="true">
<Icon glyph={CHECK} size={12} />
</span>
</div>
</li>
))}
</ul>
</div>
);
}05 · Shift and Ctrl multi-select — Measured area 16.023% · moving frames 8/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
A single anchor variable remembers where the last deliberate click landed, and the list carries aria-multiselectable="true".
function sync() {
var n = 0;
items.forEach(function (li, i) {
var on = li.classList.contains('is-sel');
li.setAttribute('aria-selected', String(on));
if (on) { n++; list.setAttribute('aria-activedescendant', li.id); }
});
badge.textContent = n + ' selected';
}
if (e.shiftKey) {
var a = Math.min(anchor, i), z = Math.max(anchor, i);
items.forEach(function (l, k) { l.classList.toggle('is-sel', k >= a && k <= z); });
} else if (e.ctrlKey || e.metaKey) {
li.classList.toggle('is-sel');
anchor = i;
} else {
items.forEach(function (l) { l.classList.remove('is-sel'); });
li.classList.add('is-sel');
anchor = i;
}
06Search-filtered tree
What moves
Typing keeps only the files whose names match, wraps the matched characters in a mark element, and dims the surviving ancestor folders into a path instead of hiding them. On a tree of several hundred files, this beats opening folders one by one.

<main class="stage">
<div class="sf is-demo" id="sf">
<div class="sf__bar">
<span class="sf__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80" opacity=".2"/><path d="m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72"/></g></svg></span>
<span class="sf__stack" aria-hidden="true">
<span class="sf__t sf__t--0"></span>
<span class="sf__t sf__t--1">he</span>
<span class="sf__t sf__t--2">hero</span>
</span>
<input class="sf__input" id="sf-input" type="text" placeholder="Search files" autocomplete="off" aria-label="Search files">
</div>
<div class="sf__count" aria-live="polite">
<span class="sf__c sf__c--0">6</span>
<span class="sf__c sf__c--2">2</span>
<span class="sf__creal" id="sf-count" hidden>6</span>
</div>
<ul class="sf__tree" id="sf-tree" role="tree" aria-label="files" tabindex="0" aria-activedescendant="sf-1">
<li class="sf__f" id="sf-1" role="treeitem" aria-level="1" data-folder="site">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name">site</span></div>
</li>
<li class="sf__i" id="sf-2" role="treeitem" aria-level="2" data-anc="site" data-name="hero.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="sf__name">hero.tsx</span></div>
</li>
<li class="sf__i" id="sf-3" role="treeitem" aria-level="2" data-anc="site" data-name="button.tsx">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="sf__name">button.tsx</span></div>
</li>
<li class="sf__f" id="sf-4" role="treeitem" aria-level="1" data-folder="assets">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name">assets</span></div>
</li>
<li class="sf__i" id="sf-5" role="treeitem" aria-level="2" data-anc="assets" data-name="hero.png">
<div class="sf__row sf__row--deep" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name">hero.png</span></div>
</li>
<li class="sf__i" id="sf-6" role="treeitem" aria-level="1" data-name="bundle.zip">
<div class="sf__row" data-hit><span class="sf__fi" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="sf__name">bundle.zip</span></div>
</li>
</ul>
<div class="sf__hits" aria-hidden="true">
<div class="sf__hrow"><span class="sf__fi"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name sf__name--path">site</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="sf__name"><mark class="sf__m">hero</mark>.tsx</span></div>
<div class="sf__hrow"><span class="sf__fi"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="sf__name sf__name--path">assets</span></div>
<div class="sf__hrow sf__hrow--deep"><span class="sf__fi"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z" opacity=".2"/><path d="M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z"/></g></svg></span><span class="sf__name"><mark class="sf__m">hero</mark>.png</span></div>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-yellow);
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-10 0 $sp-2;
margin-bottom: $sp-2;
border-radius: $r-sm;
background: #fff;
border: 1px solid rgba(23, 20, 26, .14);
}
.sf__ico { display: inline-flex; flex: 0 0 auto; color: rgba(23, 20, 26, .45); }
.sf__stack { position: relative; display: block; flex: 1; height: 28px; }
.sf__t {
position: absolute; left: 0; top: 0;
line-height: 28px;
font-size: 12px; font-weight: 600;
opacity: 0;
}
.sf__t--0 { opacity: 1; }
.sf__input {
position: absolute; inset: 0;
border: 0; padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px; line-height: 28px; font-weight: 600;
color: $stage-ink;
outline: none;
}
.sf.is-demo .sf__input::placeholder { color: transparent; }
.sf:not(.is-demo) .sf__stack { display: none; }
.sf__count {
position: absolute;
top: 6px; right: 0;
height: 20px;
padding: 0 $sp-2;
display: inline-flex; align-items: center;
border-radius: $r-pill;
background: rgba(23, 20, 26, .08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute; right: $sp-2;
font-size: 10px; line-height: 20px; font-weight: 800;
white-space: nowrap;
}
.sf__c { opacity: 0; }
.sf__c--0 { opacity: 1; }
.sf__c--2 { color: $color; }
.sf__tree { list-style: none; margin: 0; padding: 0; }
.sf__tree:focus-visible { outline: none; }
.sf__tree li[aria-selected="true"] > .sf__row { outline: 2px solid $color; outline-offset: -2px; }
.sf__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.sf__row:hover { background: rgba(23, 20, 26, .07); }
.sf__row--deep { margin-left: $sp-4; }
.sf__tree li:last-child .sf__row { margin-bottom: 0; }
.sf__fi { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.sf__f .sf__fi { color: $color; }
.sf__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.sf__name--path { color: rgba(23, 20, 26, .45); }
.sf__m {
background: $stage-yellow;
color: $stage-ink;
font-weight: 800;
}
// 검색 결과 상태 — 실제 트리 위에 겹친다
.sf:not(.is-demo) .sf__hits { display: none; }
.sf__hits {
position: absolute;
top: 36px; left: 0; right: 0;
opacity: 0;
pointer-events: none;
}
// ── 자동 루프: he → hero 로 치면 흐린 경로와 일치 행만 남는다 ──
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% { opacity: 1; }
16.9% { opacity: 1; }
17% { opacity: 0; }
92% { opacity: 0; }
100% { opacity: 1; }
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: $dur-loop;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% { opacity: 0; }
17% { opacity: 1; }
80% { opacity: 1; }
92%, 100% { opacity: 0; }
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 { 0% { opacity: 1; } 13% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 { 0% { opacity: 0; } 13% { opacity: 1; } 17% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 { 0% { opacity: 1; } 17% { opacity: 0; } 92% { opacity: 1; } 100% { opacity: 1; } }
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 { 0% { opacity: 0; } 17% { opacity: 1; } 92% { opacity: 0; } 100% { opacity: 0; } }
@media (max-width: 340px) {
.sf__bar { height: 24px; }
.sf__stack { height: 24px; }
.sf__t { line-height: 24px; }
.sf__input { line-height: 24px; }
.sf__hits { top: 32px; }
.sf__row { height: 20px; font-size: 11px; }
.sf__row--deep { margin-left: $sp-3; }
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c { animation: none !important; }
}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);
}
.sf {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.sf__bar {
position: relative;
box-sizing: border-box;
height: 28px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 40px 0 8px;
margin-bottom: 8px;
border-radius: 8px;
background: #fff;
border: 1px solid rgba(23, 20, 26, 0.14);
}
.sf__ico {
display: inline-flex;
flex: 0 0 auto;
color: rgba(23, 20, 26, 0.45);
}
.sf__stack {
position: relative;
display: block;
flex: 1;
height: 28px;
}
.sf__t {
position: absolute;
left: 0;
top: 0;
line-height: 28px;
font-size: 12px;
font-weight: 600;
opacity: 0;
}
.sf__t--0 {
opacity: 1;
}
.sf__input {
position: absolute;
inset: 0;
border: 0;
padding: 0;
background: transparent;
font-family: inherit;
font-size: 12px;
line-height: 28px;
font-weight: 600;
color: #17141a;
outline: none;
}
.sf.is-demo .sf__input::placeholder {
color: transparent;
}
.sf:not(.is-demo) .sf__stack {
display: none;
}
.sf__count {
position: absolute;
top: 6px;
right: 0;
height: 20px;
padding: 0 8px;
display: inline-flex;
align-items: center;
border-radius: 999px;
background: rgba(23, 20, 26, 0.08);
overflow: hidden;
}
.sf__c, .sf__creal {
position: absolute;
right: 8px;
font-size: 10px;
line-height: 20px;
font-weight: 800;
white-space: nowrap;
}
.sf__c {
opacity: 0;
}
.sf__c--0 {
opacity: 1;
}
.sf__c--2 {
color: #2f6df6;
}
.sf__tree {
list-style: none;
margin: 0;
padding: 0;
}
.sf__tree:focus-visible {
outline: none;
}
.sf__tree li[aria-selected=true] > .sf__row {
outline: 2px solid #2f6df6;
outline-offset: -2px;
}
.sf__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.sf__row:hover {
background: rgba(23, 20, 26, 0.07);
}
.sf__row--deep {
margin-left: 16px;
}
.sf__tree li:last-child .sf__row {
margin-bottom: 0;
}
.sf__fi {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.sf__f .sf__fi {
color: #2f6df6;
}
.sf__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.sf__name--path {
color: rgba(23, 20, 26, 0.45);
}
.sf__m {
background: #ffd23f;
color: #17141a;
font-weight: 800;
}
.sf:not(.is-demo) .sf__hits {
display: none;
}
.sf__hits {
position: absolute;
top: 36px;
left: 0;
right: 0;
opacity: 0;
pointer-events: none;
}
.sf.is-demo .sf__tree {
animation-name: sf-full;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-full {
0%, 6% {
opacity: 1;
}
16.9% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__hits {
animation-name: sf-hit;
animation-duration: 2s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
@keyframes sf-hit {
0%, 16.9% {
opacity: 0;
}
17% {
opacity: 1;
}
80% {
opacity: 1;
}
92%, 100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--0 {
animation-name: sf-t0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t0 {
0% {
opacity: 1;
}
13% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__t--1 {
animation-name: sf-t1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t1 {
0% {
opacity: 0;
}
13% {
opacity: 1;
}
17% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__t--2 {
animation-name: sf-t2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-t2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.sf.is-demo .sf__c--0 {
animation-name: sf-c0;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c0 {
0% {
opacity: 1;
}
17% {
opacity: 0;
}
92% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.sf.is-demo .sf__c--2 {
animation-name: sf-c2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes sf-c2 {
0% {
opacity: 0;
}
17% {
opacity: 1;
}
92% {
opacity: 0;
}
100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.sf__bar {
height: 24px;
}
.sf__stack {
height: 24px;
}
.sf__t {
line-height: 24px;
}
.sf__input {
line-height: 24px;
}
.sf__hits {
top: 32px;
}
.sf__row {
height: 20px;
font-size: 11px;
}
.sf__row--deep {
margin-left: 12px;
}
}
@media (prefers-reduced-motion: reduce) {
.sf.is-demo .sf__tree, .sf.is-demo .sf__hits, .sf.is-demo .sf__t, .sf.is-demo .sf__c {
animation: none !important;
}
}// 실제 동작: 칠 때마다 목록이 줄고 조상 폴더는 흐린 길로 남는다.
var root = document.querySelector('.sf');
var input = document.getElementById('sf-input');
var count = document.getElementById('sf-count');
var tree = document.getElementById('sf-tree');
var files = [].slice.call(tree.querySelectorAll('.sf__i'));
var folders = [].slice.call(tree.querySelectorAll('.sf__f'));
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<'); }
function render(q) {
root.classList.remove('is-demo');
document.querySelectorAll('.sf__t, .sf__c').forEach(function (t) { t.hidden = true; });
count.hidden = false;
var hits = 0;
files.forEach(function (li) {
var name = li.dataset.name, k = q ? name.toLowerCase().indexOf(q) : -1;
var hit = !q || k >= 0;
li.hidden = !hit;
if (hit && q) {
hits++;
li.querySelector('.sf__name').innerHTML = esc(name.slice(0, k)) + '<mark class="sf__m">' + esc(name.slice(k, k + q.length)) + '</mark>' + esc(name.slice(k + q.length));
} else {
li.querySelector('.sf__name').textContent = name;
}
});
folders.forEach(function (f) {
var alive = files.some(function (li) { return !li.hidden && li.dataset.anc === f.dataset.folder; });
f.hidden = !alive;
f.classList.toggle('is-path', alive && !!q);
});
count.textContent = (q ? hits : files.length) + ' files';
}
input.addEventListener('input', function () { render(input.value.trim().toLowerCase()); });
input.addEventListener('keydown', function (e) {
if (e.key === 'Escape') { input.value = ''; render(''); tree.focus({ preventScroll: true }); }
});
tree.addEventListener('keydown', function (e) {
if (e.key === '/' && document.activeElement !== input) { input.focus({ preventScroll: true }); e.preventDefault(); }
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SearchFilterTree.module.scss";
export type NodeKind = "folder" | "ts" | "js" | "img" | "zip";
export type TreeNode = { name: string; kind: NodeKind; level: 1 | 2; parent?: string };
export type SearchFilterTreeProps = {
placeholder?: string;
nodes?: TreeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
type Glyph = { dim: string; solid: string };
const PAGE = "M208 88h-56V32Z";
const GLYPH: Record<NodeKind, Glyph> = {
folder: {
dim: "M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z",
solid: "M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z",
},
ts: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8",
},
js: {
dim: PAGE,
solid: "m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0",
},
img: {
dim: "m104 152l48 72H24l36-56l16.36 25.45Zm48-120v56h56Z",
solid: "M110.66 147.56a8 8 0 0 0-13.32 0l-20.85 31.29l-9.76-15.18a8 8 0 0 0-13.46 0l-36 56A8 8 0 0 0 24 232h128a8 8 0 0 0 6.66-12.44ZM38.65 216L60 182.79l9.63 15a8 8 0 0 0 13.39.11l21-31.47L137.05 216Zm175-133.66l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v88a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-8a8 8 0 0 0 0 16h8a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66ZM160 51.31L188.69 80H160Z",
},
zip: {
dim: PAGE,
solid: "M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z",
},
};
const SEARCH: Glyph = {
dim: "M192 112a80 80 0 1 1-80-80a80 80 0 0 1 80 80",
solid: "m229.66 218.34l-50.06-50.06a88.21 88.21 0 1 0-11.32 11.31l50.06 50.07a8 8 0 0 0 11.32-11.32M40 112a72 72 0 1 1 72 72a72.08 72.08 0 0 1-72-72",
};
function Icon({ glyph, size }: { glyph: Glyph; size: number }) {
return (
<svg viewBox="0 0 256 256" width={size} height={size}>
<g fill="currentColor">
<path d={glyph.dim} opacity=".2" />
<path d={glyph.solid} />
</g>
</svg>
);
}
function Name({ name, q }: { name: string; q: string }) {
const k = q ? name.toLowerCase().indexOf(q) : -1;
if (k < 0) return <span className={styles.name}>{name}</span>;
return (
<span className={styles.name}>
{name.slice(0, k)}
<mark className={styles.mark}>{name.slice(k, k + q.length)}</mark>
{name.slice(k + q.length)}
</span>
);
}
const DEFAULT_NODES: TreeNode[] = [
{ name: "site", kind: "folder", level: 1 },
{ name: "hero.tsx", kind: "ts", level: 2, parent: "site" },
{ name: "button.tsx", kind: "js", level: 2, parent: "site" },
{ name: "assets", kind: "folder", level: 1 },
{ name: "hero.png", kind: "img", level: 2, parent: "assets" },
{ name: "bundle.zip", kind: "zip", level: 1 },
];
export default function SearchFilterTree({
placeholder = "파일 검색",
nodes = DEFAULT_NODES,
duration = 220,
easing = "ease-out",
color = "#2f6df6",
}: SearchFilterTreeProps) {
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const q = query.trim().toLowerCase();
function fileHit(n: TreeNode): boolean {
return n.kind !== "folder" && (!q || n.name.toLowerCase().indexOf(q) >= 0);
}
function folderAlive(n: TreeNode): boolean {
return nodes.some((c) => c.parent === n.name && fileHit(c));
}
const shown = nodes.map((n) => (n.kind === "folder" ? folderAlive(n) : fileHit(n)));
const files = nodes.filter((n) => n.kind !== "folder");
const hits = q ? files.filter(fileHit).length : files.length;
const first = shown.indexOf(true);
const activeId = `sf-n${first < 0 ? 0 : first}`;
function onInputKey(e: KeyEvt) {
if (e.key === "Escape") {
setQuery("");
treeRef.current?.focus({ preventScroll: true });
}
}
function onTreeKey(e: KeyEvt) {
if (e.key === "/") {
inputRef.current?.focus({ preventScroll: true });
e.preventDefault();
}
}
return (
<div className={styles.sf} style={vars}>
<div className={styles.bar}>
<span className={styles.ico} aria-hidden="true">
<Icon glyph={SEARCH} size={13} />
</span>
<input
ref={inputRef}
className={styles.input}
type="text"
value={query}
placeholder={placeholder}
autoComplete="off"
aria-label={placeholder}
onChange={(e: ChangeEvt) => setQuery(e.target.value)}
onKeyDown={onInputKey}
/>
</div>
<div className={styles.count} aria-live="polite">{`${hits}건`}</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={placeholder}
tabIndex={0}
aria-activedescendant={activeId}
onKeyDown={onTreeKey}
>
{nodes.map((n, i) => {
const folder = n.kind === "folder";
const path = folder && !!q;
return (
<li
key={n.name}
id={`sf-n${i}`}
className={`${styles.item} ${folder ? styles.isFolder : ""} ${path ? styles.isPath : ""}`}
role="treeitem"
aria-level={n.level}
hidden={!shown[i]}
>
<div className={`${styles.row} ${n.level === 2 ? styles.rowDeep : ""}`}>
<span className={styles.fi} aria-hidden="true">
<Icon glyph={GLYPH[n.kind]} size={folder ? 13 : 12} />
</span>
{folder ? <span className={styles.name}>{n.name}</span> : <Name name={n.name} q={q} />}
</div>
</li>
);
})}
</ul>
</div>
);
}06 · Search-filtered tree — Measured area 2.089% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Non-matching rows are removed with li.hidden — a real removal, not an opacity trick — and every focus move runs through focus({ preventScroll: true }) so the page never jumps.
function esc(s) { return s.replace(/&/g, '&').replace(/</g, '<'); }
function render(q) {
root.classList.remove('is-demo');
document.querySelectorAll('.sf__t, .sf__c').forEach(function (t) { t.hidden = true; });
count.hidden = false;
var hits = 0;
files.forEach(function (li) {
var name = li.dataset.name, k = q ? name.toLowerCase().indexOf(q) : -1;
var hit = !q || k >= 0;
li.hidden = !hit;
if (hit && q) {
hits++;
li.querySelector('.sf__name').innerHTML = esc(name.slice(0, k)) + '<mark class="sf__m">' + esc(name.slice(k, k + q.length)) + '</mark>' + esc(name.slice(k + q.length));
} else {
li.querySelector('.sf__name').textContent = name;
}
});
folders.forEach(function (f) {
var alive = files.some(function (li) { return !li.hidden && li.dataset.anc === f.dataset.folder; });
f.hidden = !alive;
f.classList.toggle('is-path', alive && !!q);
});
count.textContent = (q ? hits : files.length) + ' files';
}
07Right-click context menu
What you see
Right-clicking a row pops a menu open at the pointer, clamped so it cannot leave the frame, and a highlight walks its items until Enter picks one or Escape closes it. It keeps open, rename, copy, and delete under the cursor instead of spreading them across the screen as buttons.

<main class="stage">
<div class="cm is-demo" id="cm">
<ul class="cm__tree" id="cm-tree" role="tree" aria-label="files" tabindex="0" aria-activedescendant="cm-1">
<li class="cm__item is-row1" id="cm-1" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8"/></g></svg></span><span class="cm__name">hero.tsx</span></div>
</li>
<li class="cm__item" id="cm-2" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="cm__name">button.tsx</span></div>
</li>
<li class="cm__item" id="cm-3" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico cm__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="cm__name">site</span></div>
</li>
<li class="cm__item" id="cm-4" role="treeitem" aria-level="1">
<div class="cm__row" data-hit><span class="cm__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="cm__name">bundle.zip</span></div>
</li>
</ul>
<span class="cm__dot" aria-hidden="true"></span>
<div class="cm__menu" id="cm-menu" role="menu" aria-label="hero.tsx menu" hidden>
<span class="cm__hl" aria-hidden="true"></span>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M184 80v128a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h128a8 8 0 0 1 8 8" opacity=".2"/><path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8"/></g></svg></span>Open</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2"/><path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z"/></g></svg></span>Rename</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M216 40v128h-48V88H88V40Z" opacity=".2"/><path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z"/></g></svg></span>Copy</button>
<button class="cm__mi" type="button" role="menuitem"><span class="cm__mi-ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2"/><path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0"/></g></svg></span>Delete</button>
</div>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $stage-yellow;
@include stage($stage-orange);
.cm {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $subject-ink; // 주황 무대 위 흰 글자는 3.32:1 — 잉크 5.50:1
}
.cm__tree { list-style: none; margin: 0; padding: 0; }
.cm__tree:focus-visible { outline: none; }
.cm__tree li[aria-selected="true"] > .cm__row { outline: 2px solid $subject-ink; outline-offset: -2px; }
.cm__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: context-menu;
user-select: none;
}
.cm__row:hover { background: rgba(255, 255, 255, .1); }
.cm__row:focus { outline: none; }
.cm__ico { display: inline-flex; flex: 0 0 auto; color: $subject-ink; }
.cm__ico--folder { color: $color; }
.cm__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
// 우클릭 지점 표시
.cm__dot {
position: absolute;
left: 96px; top: 10px;
width: 8px; height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
}
// 컨텍스트 메뉴 — 절대 배치라 부모 높이에서 빠진다. 무대 안에 다 들어가게 열린다
.cm__menu {
position: absolute;
left: 88px; top: 26px;
box-sizing: border-box;
width: 148px;
padding: $sp-1 0;
border-radius: $r-sm;
background: #fff;
box-shadow: $shadow-raised;
transform-origin: top left; // 규칙8: 우클릭 지점에서 팝
z-index: 2;
}
.cm__menu:focus { outline: none; }
.cm__hl {
position: absolute;
left: $sp-1; right: $sp-1; top: $sp-1;
height: 28px;
border-radius: $r-xs;
background: rgba(255, 77, 31, .12);
transition: transform 120ms $ease-out;
}
.cm__mi {
position: relative;
display: flex; align-items: center; gap: $sp-2;
height: 28px;
padding: 0 $sp-2;
border: 0;
background: none;
border-radius: $r-xs;
font-family: inherit;
font-size: 11px; line-height: 28px; font-weight: 700;
color: $stage-ink;
cursor: pointer;
width: 100%;
text-align: left;
}
.cm__mi-ico { display: inline-flex; flex: 0 0 auto; color: $stage-orange; }
.cm__mi:hover { background: rgba(255, 77, 31, .12); }
.cm__mi:focus { outline: none; }
// ── 자동 루프: 점이 닿은 뒤 메뉴가 열리고 하이라이트가 내려간다 ──
.cm.is-demo .cm__dot {
animation-name: cm-dot;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes cm-dot {
0% { transform: scale(0); opacity: 1; }
8%, 78% { transform: scale(1); opacity: 1; }
90%, 100% { transform: scale(0); opacity: 0; }
}
.cm.is-demo .cm__menu {
display: block;
animation-name: cm-menu;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
}
@keyframes cm-menu {
0%, 7.9% { opacity: 0; transform: scale(.7); }
14% { opacity: 1; transform: scale(1); }
72% { opacity: 1; transform: scale(1); }
80%, 100% { opacity: 0; transform: scale(.9); }
}
.cm.is-demo .cm__hl {
animation-name: cm-bar;
animation-duration: $dur-loop;
animation-timing-function: $ease-out;
animation-iteration-count: infinite;
}
@keyframes cm-bar {
0%, 15% { transform: translateY(0); }
25%, 35% { transform: translateY(28px); }
45%, 55% { transform: translateY(56px); }
65%, 100% { transform: translateY(84px); }
}
.cm.is-demo .cm__item.is-row1 .cm__row {
animation-name: cm-tint;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cm-tint {
0%, 7.9% { background: transparent; }
8%, 72% { background: rgba(255, 255, 255, .1); }
72.01%, 100% { background: transparent; }
}
@media (max-width: 340px) {
.cm__row { height: 22px; font-size: 11px; }
.cm__mi { height: 24px; line-height: 24px; font-size: 10px; }
.cm__hl { height: 24px; }
.cm__menu { left: 72px; top: 22px; width: 132px; }
.cm__dot { left: 80px; top: 8px; }
@keyframes cm-bar {
0%, 15% { transform: translateY(0); }
25%, 35% { transform: translateY(24px); }
45%, 55% { transform: translateY(48px); }
65%, 100% { transform: translateY(72px); }
}
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dot, .cm.is-demo .cm__menu, .cm.is-demo .cm__hl, .cm.is-demo .cm__item.is-row1 .cm__row { animation: none !important; }
.cm__hl { transition: none; }
.cm.is-demo .cm__menu { display: 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);
}
.cm {
position: relative;
box-sizing: border-box;
width: min(268px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.cm__tree {
list-style: none;
margin: 0;
padding: 0;
}
.cm__tree:focus-visible {
outline: none;
}
.cm__tree li[aria-selected=true] > .cm__row {
outline: 2px solid #17141a;
outline-offset: -2px;
}
.cm__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: context-menu;
user-select: none;
}
.cm__row:hover {
background: rgba(255, 255, 255, 0.1);
}
.cm__row:focus {
outline: none;
}
.cm__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.cm__ico--folder {
color: #ffd23f;
}
.cm__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm__dot {
position: absolute;
left: 96px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #fff;
opacity: 0;
}
.cm__menu {
position: absolute;
left: 88px;
top: 26px;
box-sizing: border-box;
width: 148px;
padding: 4px 0;
border-radius: 8px;
background: #fff;
box-shadow: 0 14px 24px rgba(23, 20, 26, 0.32);
transform-origin: top left;
z-index: 2;
}
.cm__menu:focus {
outline: none;
}
.cm__hl {
position: absolute;
left: 4px;
right: 4px;
top: 4px;
height: 28px;
border-radius: 4px;
background: rgba(255, 77, 31, 0.12);
transition: transform 120ms ease-out;
}
.cm__mi {
position: relative;
display: flex;
align-items: center;
gap: 8px;
height: 28px;
padding: 0 8px;
border: 0;
background: none;
border-radius: 4px;
font-family: inherit;
font-size: 11px;
line-height: 28px;
font-weight: 700;
color: #17141a;
cursor: pointer;
width: 100%;
text-align: left;
}
.cm__mi-ico {
display: inline-flex;
flex: 0 0 auto;
color: #ff4d1f;
}
.cm__mi:hover {
background: rgba(255, 77, 31, 0.12);
}
.cm__mi:focus {
outline: none;
}
.cm.is-demo .cm__dot {
animation-name: cm-dot;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cm-dot {
0% {
transform: scale(0);
opacity: 1;
}
8%, 78% {
transform: scale(1);
opacity: 1;
}
90%, 100% {
transform: scale(0);
opacity: 0;
}
}
.cm.is-demo .cm__menu {
display: block;
animation-name: cm-menu;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes cm-menu {
0%, 7.9% {
opacity: 0;
transform: scale(0.7);
}
14% {
opacity: 1;
transform: scale(1);
}
72% {
opacity: 1;
transform: scale(1);
}
80%, 100% {
opacity: 0;
transform: scale(0.9);
}
}
.cm.is-demo .cm__hl {
animation-name: cm-bar;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
}
@keyframes cm-bar {
0%, 15% {
transform: translateY(0);
}
25%, 35% {
transform: translateY(28px);
}
45%, 55% {
transform: translateY(56px);
}
65%, 100% {
transform: translateY(84px);
}
}
.cm.is-demo .cm__item.is-row1 .cm__row {
animation-name: cm-tint;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes cm-tint {
0%, 7.9% {
background: transparent;
}
8%, 72% {
background: rgba(255, 255, 255, 0.1);
}
72.01%, 100% {
background: transparent;
}
}
@media (max-width: 340px) {
.cm__row {
height: 22px;
font-size: 11px;
}
.cm__mi {
height: 24px;
line-height: 24px;
font-size: 10px;
}
.cm__hl {
height: 24px;
}
.cm__menu {
left: 72px;
top: 22px;
width: 132px;
}
.cm__dot {
left: 80px;
top: 8px;
}
@keyframes cm-bar {
0%, 15% {
transform: translateY(0);
}
25%, 35% {
transform: translateY(24px);
}
45%, 55% {
transform: translateY(48px);
}
65%, 100% {
transform: translateY(72px);
}
}
}
@media (prefers-reduced-motion: reduce) {
.cm.is-demo .cm__dot, .cm.is-demo .cm__menu, .cm.is-demo .cm__hl, .cm.is-demo .cm__item.is-row1 .cm__row {
animation: none !important;
}
.cm__hl {
transition: none;
}
.cm.is-demo .cm__menu {
display: none;
}
}// 실제 동작: 우클릭하면 그 자리에 메뉴가 열리고 화살표·Enter·Escape 가 듣는다.
var root = document.querySelector('.cm');
var tree = document.getElementById('cm-tree');
var menu = document.getElementById('cm-menu');
var mis = [].slice.call(menu.querySelectorAll('.cm__mi'));
var hl = menu.querySelector('.cm__hl');
var at = 0, row = null;
function open(x, y, li) {
root.classList.remove('is-demo');
menu.hidden = false;
row = li;
var r = root.getBoundingClientRect();
menu.style.left = Math.max(0, Math.min(x - r.left, root.offsetWidth - 152)) + 'px';
menu.style.top = Math.min(y - r.top, window.innerHeight - 128) + 'px';
menu.setAttribute('aria-label', li.querySelector('.cm__name').textContent + ' menu');
move(0);
menu.focus({ preventScroll: true });
}
function close() {
menu.hidden = true;
if (row) row.querySelector('.cm__row').focus({ preventScroll: true });
}
function move(i) {
at = Math.max(0, Math.min(i, mis.length - 1));
hl.style.transform = 'translateY(' + (at * 28) + 'px)';
mis.forEach(function (b, k) { b.setAttribute('aria-current', String(k === at)); });
mis[at].focus({ preventScroll: true });
}
tree.querySelectorAll('.cm__item').forEach(function (li) {
var r = li.querySelector('.cm__row');
r.setAttribute('tabindex', '-1');
li.setAttribute('aria-selected', 'false');
r.addEventListener('click', function () {
tree.querySelectorAll('.cm__item').forEach(function (l2) { l2.setAttribute('aria-selected', 'false'); });
li.setAttribute('aria-selected', 'true');
tree.setAttribute('aria-activedescendant', li.id);
r.focus({ preventScroll: true });
});
r.addEventListener('contextmenu', function (e) { e.preventDefault(); open(e.clientX, e.clientY, li); });
});
mis.forEach(function (b, i) {
b.addEventListener('mouseenter', function () { move(i); });
b.addEventListener('click', function () { close(); });
});
menu.setAttribute('tabindex', '0');
menu.addEventListener('keydown', function (e) {
if (e.key === 'ArrowDown') { move(at + 1); e.preventDefault(); }
else if (e.key === 'ArrowUp') { move(at - 1); e.preventDefault(); }
else if (e.key === 'Enter') { close(); e.preventDefault(); }
else if (e.key === 'Escape') { close(); e.preventDefault(); }
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./ContextMenuTree.module.scss";
export type TreeNode = { name: string; kind?: "file" | "folder" };
export type MenuEntry = { label: string; icon?: "open" | "rename" | "copy" | "trash" };
export type ContextMenuTreeProps = {
label?: string;
nodes?: TreeNode[];
items?: MenuEntry[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type MenuEvt = { clientX: number; clientY: number; preventDefault: () => void };
const DEFAULT_NODES: TreeNode[] = [
{ name: "hero.tsx" },
{ name: "button.tsx" },
{ name: "site", kind: "folder" },
{ name: "bundle.zip" },
];
const DEFAULT_ITEMS: MenuEntry[] = [
{ label: "열기", icon: "open" },
{ label: "이름 바꾸기", icon: "rename" },
{ label: "복사", icon: "copy" },
{ label: "삭제", icon: "trash" },
];
const ROW_H = 28;
const MENU_W = 152;
function fileIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M88 152a8 8 0 0 1-8 8H68v48a8 8 0 0 1-16 0v-48H40a8 8 0 0 1 0-16h40a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function folderIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function menuIcon(kind: string) {
if (kind === "rename") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M221.66 90.34L192 120l-56-56l29.66-29.66a8 8 0 0 1 11.31 0L221.66 79a8 8 0 0 1 0 11.34" opacity=".2" />
<path d="m227.31 73.37l-44.68-44.69a16 16 0 0 0-22.63 0L36.69 152A15.86 15.86 0 0 0 32 163.31V208a16 16 0 0 0 16 16h44.69a15.86 15.86 0 0 0 11.31-4.69L227.31 96a16 16 0 0 0 0-22.63M92.69 208H48v-44.69l88-88L180.69 120ZM192 108.68L147.31 64l24-24L216 84.68Z" />
</g>
</svg>
);
}
if (kind === "copy") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M216 40v128h-48V88H88V40Z" opacity=".2" />
<path d="M216 32H88a8 8 0 0 0-8 8v40H40a8 8 0 0 0-8 8v128a8 8 0 0 0 8 8h128a8 8 0 0 0 8-8v-40h40a8 8 0 0 0 8-8V40a8 8 0 0 0-8-8m-56 176H48V96h112Zm48-48h-32V88a8 8 0 0 0-8-8H96V48h112Z" />
</g>
</svg>
);
}
if (kind === "trash") {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M200 56v152a8 8 0 0 1-8 8H64a8 8 0 0 1-8-8V56Z" opacity=".2" />
<path d="M216 48h-40v-8a24 24 0 0 0-24-24h-48a24 24 0 0 0-24 24v8H40a8 8 0 0 0 0 16h8v144a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16V64h8a8 8 0 0 0 0-16M96 40a8 8 0 0 1 8-8h48a8 8 0 0 1 8 8v8H96Zm96 168H64V64h128Zm-80-104v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0m48 0v64a8 8 0 0 1-16 0v-64a8 8 0 0 1 16 0" />
</g>
</svg>
);
}
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M184 80v128a8 8 0 0 1-8 8H48a8 8 0 0 1-8-8V80a8 8 0 0 1 8-8h128a8 8 0 0 1 8 8" opacity=".2" />
<path d="M224 104a8 8 0 0 1-16 0V59.32l-66.33 66.34a8 8 0 0 1-11.32-11.32L196.68 48H152a8 8 0 0 1 0-16h64a8 8 0 0 1 8 8Zm-40 24a8 8 0 0 0-8 8v72H48V80h72a8 8 0 0 0 0-16H48a16 16 0 0 0-16 16v128a16 16 0 0 0 16 16h128a16 16 0 0 0 16-16v-72a8 8 0 0 0-8-8" />
</g>
</svg>
);
}
export default function ContextMenuTree({
label = "파일",
nodes = DEFAULT_NODES,
items = DEFAULT_ITEMS,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#ffd23f",
}: ContextMenuTreeProps) {
const [pos, setPos] = useState<{ x: number; y: number } | null>(null);
const [atRow, setAtRow] = useState(0);
const [atItem, setAtItem] = useState(0);
const rootRef = useRef<HTMLDivElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const rowRefs = useRef<(HTMLDivElement | null)[]>([]);
const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 열릴 때: 첫 항목을 짚고 메뉴로 포커스를 옮긴다 (vanilla 의 move(0) + menu.focus 와 같은 순서)
useEffect(() => {
if (!pos) return;
const list = itemRefs.current;
if (list && list[0]) list[0].focus({ preventScroll: true });
menuRef.current?.focus({ preventScroll: true });
}, [pos]);
function openMenu(e: MenuEvt, i: number) {
e.preventDefault();
const root = rootRef.current;
if (!root) return;
const r = root.getBoundingClientRect();
const x = Math.max(0, Math.min(e.clientX - r.left, root.offsetWidth - MENU_W));
const y = Math.max(0, Math.min(e.clientY - r.top, window.innerHeight - 128));
setAtRow(i);
setAtItem(0);
setPos({ x, y });
}
function close() {
setPos(null);
const rows = rowRefs.current;
if (rows && rows[atRow]) rows[atRow]?.focus({ preventScroll: true });
}
function move(i: number) {
const k = Math.max(0, Math.min(i, items.length - 1));
setAtItem(k);
const list = itemRefs.current;
if (list && list[k]) list[k]?.focus({ preventScroll: true });
}
function onMenuKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { move(atItem + 1); e.preventDefault(); }
else if (e.key === "ArrowUp") { move(atItem - 1); e.preventDefault(); }
else if (e.key === "Enter") { close(); e.preventDefault(); }
else if (e.key === "Escape") { close(); e.preventDefault(); }
}
const menuStyle: CSSProperties = pos ? { left: `${pos.x}px`, top: `${pos.y}px` } : {};
const target = nodes[atRow] ? nodes[atRow].name : label;
return (
<div className={styles.cm} style={vars} ref={rootRef}>
<ul
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`cm-${atRow}`}
>
{nodes.map((n, i) => (
<li
key={n.name}
id={`cm-${i}`}
className={styles.item}
role="treeitem"
aria-level={1}
aria-selected={i === atRow}
>
<div
ref={(el: HTMLDivElement | null) => { if (rowRefs.current) rowRefs.current[i] = el; }}
className={`${styles.row} ${i === atRow ? styles.isSelected : ""}`}
tabIndex={-1}
onClick={() => setAtRow(i)}
onContextMenu={(e: MenuEvt) => openMenu(e, i)}
>
<span className={`${styles.ico} ${n.kind === "folder" ? styles.icoFolder : ""}`}>
{n.kind === "folder" ? folderIcon() : fileIcon()}
</span>
<span className={styles.name}>{n.name}</span>
</div>
</li>
))}
</ul>
<div
ref={menuRef}
className={styles.menu}
style={menuStyle}
role="menu"
aria-label={`${target} 메뉴`}
hidden={!pos}
tabIndex={0}
onKeyDown={onMenuKeyDown}
>
<span className={styles.hl} style={{ transform: `translateY(${atItem * ROW_H}px)` }} aria-hidden="true" />
{items.map((it, i) => (
<button
key={it.label}
ref={(el: HTMLButtonElement | null) => { if (itemRefs.current) itemRefs.current[i] = el; }}
className={styles.mi}
type="button"
role="menuitem"
aria-current={i === atItem ? "true" : "false"}
onMouseEnter={() => move(i)}
onClick={() => close()}
>
<span className={styles.miIco}>{menuIcon(it.icon || "open")}</span>
{it.label}
</button>
))}
</div>
</div>
);
}07 · Right-click context menu — Measured area 21.474% · moving frames 15/23Original by GODRICH · SCSS and React ship in the zip · See the password
Where to look in the code
The highlight is one element moved with translateY(at * 28), and the menu itself scales up from transform-origin: top left.
function open(x, y, li) {
root.classList.remove('is-demo');
menu.hidden = false;
row = li;
var r = root.getBoundingClientRect();
menu.style.left = Math.max(0, Math.min(x - r.left, root.offsetWidth - 152)) + 'px';
menu.style.top = Math.min(y - r.top, window.innerHeight - 128) + 'px';
menu.setAttribute('aria-label', li.querySelector('.cm__name').textContent + ' menu');
move(0);
menu.focus({ preventScroll: true });
}
function close() {
menu.hidden = true;
if (row) row.querySelector('.cm__row').focus({ preventScroll: true });
}
function move(i) {
at = Math.max(0, Math.min(i, mis.length - 1));
hl.style.transform = 'translateY(' + (at * 28) + 'px)';
mis.forEach(function (b, k) { b.setAttribute('aria-current', String(k === at)); });
mis[at].focus({ preventScroll: true });
}
08Inline rename in place
What happens on screen
Double-clicking turns the name into a text field in the same spot, with the old name already selected; Enter commits, Escape cancels, and F2 does the same job from the keyboard. Renaming belongs where the eye already is, not in a dialog.

<main class="stage">
<div class="rn is-demo" id="rn">
<div class="rn__head">
<span class="rn__title">Project files</span>
<button class="rn__add" type="button" id="rn-add" aria-label="Add new file"><svg viewBox="0 0 256 256" width="11" height="11"><g fill="currentColor"><path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2"/><path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8"/></g></svg><span>Add file</span></button>
</div>
<ul class="rn__tree" id="rn-tree" role="tree" aria-label="Project files" tabindex="0" aria-activedescendant="rn-2">
<li class="rn__item" id="rn-1" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico rn__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span><span class="rn__name">src</span></div>
</li>
<li class="rn__item is-edit" id="rn-2" role="treeitem" aria-level="1" aria-label="notes.md">
<div class="rn__row" data-hit>
<span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span>
<span class="rn__stack" aria-hidden="true">
<span class="rn__lab rn__lab--1">notes.md</span>
<span class="rn__lab rn__lab--2">release-notes.md</span>
<span class="rn__field"><span class="rn__fv rn__fv--1">notes.md</span><span class="rn__fv rn__fv--2">release-notes.md</span><span class="rn__caret"></span></span>
</span>
<span class="rn__ok" aria-hidden="true"><svg viewBox="0 0 256 256" width="12" height="12"><g fill="currentColor"><path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2"/><path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32"/></g></svg></span>
</div>
</li>
<li class="rn__item" id="rn-3" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v120h-24a8 8 0 0 0 0 16h24a16 16 0 0 0 16-16V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160Zm-12.19 145a20.82 20.82 0 0 1-9.19 15.23C133.43 215 127 216 121.13 216a61 61 0 0 1-15.13-2a8 8 0 1 1 4.3-15.41c4.38 1.2 15 2.7 19.55-.36c.88-.59 1.83-1.52 2.14-3.93c.35-2.67-.71-4.1-12.78-7.59c-9.35-2.7-25-7.23-23-23.11a20.56 20.56 0 0 1 9-14.95c11.84-8 30.71-3.31 32.83-2.76a8 8 0 0 1-4.07 15.48c-4.49-1.17-15.23-2.56-19.83.56a4.54 4.54 0 0 0-2 3.67c-.12.9-.14 1.09 1.11 1.9c2.31 1.49 6.45 2.68 10.45 3.84c9.79 2.83 26.35 7.66 24.11 24.97M80 152v38a26 26 0 0 1-52 0a8 8 0 0 1 16 0a10 10 0 0 0 20 0v-38a8 8 0 0 1 16 0"/></g></svg></span><span class="rn__name">app.js</span></div>
</li>
<li class="rn__item" id="rn-4" role="treeitem" aria-level="1">
<div class="rn__row" data-hit><span class="rn__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span><span class="rn__name">bundle.zip</span></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-quick;
$easing: $ease-pop;
$color: $subject-blue;
@include stage($stage-paper);
.rn {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: $stage-ink;
}
.rn__head {
display: flex; align-items: center; justify-content: space-between;
margin: 0 0 $sp-2;
}
.rn__title {
font-size: 10px; line-height: 12px; font-weight: 700;
letter-spacing: .04em;
color: rgba(23, 20, 26, .72);
}
.rn__add {
display: inline-flex; align-items: center; gap: $sp-1;
height: 20px;
padding: 0 $sp-2;
border: 1px solid rgba(23, 20, 26, .16);
border-radius: $r-pill;
background: #fff;
font-family: inherit;
font-size: 10px; line-height: 20px; font-weight: 800;
color: $stage-ink;
cursor: pointer;
}
.rn__add:hover { border-color: $color; color: $color; }
.rn__add svg { color: $color; }
.rn__tree { list-style: none; margin: 0; padding: 0; }
.rn__tree:focus-visible { outline: none; }
.rn__row {
box-sizing: border-box;
height: 26px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
margin-bottom: $sp-1;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: default;
user-select: none;
}
.rn__row:hover { background: rgba(23, 20, 26, .05); }
.rn__ico { display: inline-flex; flex: 0 0 auto; color: $stage-ink; }
.rn__ico--folder { color: $color; }
.rn__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.rn__ok {
margin-left: auto;
display: inline-flex; flex: 0 0 auto;
color: $subject-mint;
opacity: 0;
}
.rn__ok.is-flash { animation-name: rn-ok; animation-duration: 480ms; animation-timing-function: $ease-pop; animation-iteration-count: 1; }
@keyframes rn-ok { 0% { opacity: 0; transform: scale(.5); } 40% { opacity: 1; transform: scale(1); } 100% { opacity: 0; transform: scale(1); } }
// 이름과 입력칸을 같은 자리에 겹쳐 둔다 — 교체는 컷
.rn__stack { position: relative; display: block; flex: 1; height: 26px; min-width: 0; }
.rn__lab {
position: absolute; left: 0; top: 0;
line-height: 26px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 100%;
opacity: 0;
}
.rn__field {
position: absolute; left: -4px; top: 1px; right: 0;
height: 24px;
display: none; align-items: center;
padding: 0 $sp-2;
border: 1px solid $color;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, .18);
opacity: 0;
}
.rn__fv { font-size: 12px; font-weight: 600; white-space: nowrap; }
.rn__fv--1 { background: rgba(47, 109, 246, .18); }
.rn__caret {
display: inline-block;
width: 1px; height: 14px;
margin-left: 0;
background: $color;
}
.rn__live { display: inline-flex; flex: 1; min-width: 0; }
.rn__edit {
box-sizing: border-box;
width: 100%; height: 22px;
padding: 0 $sp-2;
border: 1px solid $color;
border-radius: $r-xs;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, .18);
font-family: inherit;
font-size: 12px; font-weight: 600;
color: $stage-ink;
outline: none;
}
// ── 자동 루프: 두 번 눌러 열고 → 치고 → Enter ──
.rn.is-demo .rn__lab--1 {
animation-name: rn-l1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l1 { 0% { opacity: 1; } 8% { opacity: 0; } 94% { opacity: 1; } 100% { opacity: 1; } }
.rn.is-demo .rn__lab--2 {
animation-name: rn-l2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l2 { 0% { opacity: 0; } 52% { opacity: 1; } 82% { opacity: 1; } 82.01%, 100% { opacity: 0; } }
.rn.is-demo .rn__field {
display: flex;
animation-name: rn-f;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-f { 0% { opacity: 0; } 8% { opacity: 1; } 52% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__fv--1 {
animation-name: rn-v1;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v1 { 0%, 7.9% { opacity: 0; } 8% { opacity: 1; } 36% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__fv--2 {
animation-name: rn-v2;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v2 { 0%, 35.9% { opacity: 0; } 36% { opacity: 1; } 52% { opacity: 0; } 100% { opacity: 0; } }
.rn.is-demo .rn__caret {
animation-name: rn-caret;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-caret {
0%, 7.9% { opacity: 0; }
8% { opacity: 1; } 16% { opacity: 0; } 24% { opacity: 1; } 32% { opacity: 0; } 40% { opacity: 1; }
48% { opacity: 0; } 52%, 100% { opacity: 0; }
}
.rn.is-demo .rn__ok {
animation-name: rn-oka;
animation-duration: $dur-loop;
animation-timing-function: $ease-pop;
animation-iteration-count: infinite;
}
@keyframes rn-oka {
0%, 51.9% { opacity: 0; transform: scale(.5); }
56% { opacity: 1; transform: scale(1); }
80% { opacity: 1; transform: scale(1); }
92%, 100% { opacity: 0; transform: scale(.5); }
}
@media (max-width: 340px) {
.rn__row { height: 22px; font-size: 11px; }
.rn__lab { line-height: 22px; }
.rn__field { height: 20px; }
}
@media (prefers-reduced-motion: reduce) {
.rn.is-demo .rn__lab, .rn.is-demo .rn__field, .rn.is-demo .rn__fv, .rn.is-demo .rn__caret, .rn.is-demo .rn__ok { animation: none !important; }
.rn.is-demo .rn__lab--1 { opacity: 1; }
.rn.is-demo .rn__field { display: 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);
}
.rn {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #17141a;
}
.rn__head {
display: flex;
align-items: center;
justify-content: space-between;
margin: 0 0 8px;
}
.rn__title {
font-size: 10px;
line-height: 12px;
font-weight: 700;
letter-spacing: 0.04em;
color: rgba(23, 20, 26, 0.72);
}
.rn__add {
display: inline-flex;
align-items: center;
gap: 4px;
height: 20px;
padding: 0 8px;
border: 1px solid rgba(23, 20, 26, 0.16);
border-radius: 999px;
background: #fff;
font-family: inherit;
font-size: 10px;
line-height: 20px;
font-weight: 800;
color: #17141a;
cursor: pointer;
}
.rn__add:hover {
border-color: #2f6df6;
color: #2f6df6;
}
.rn__add svg {
color: #2f6df6;
}
.rn__tree {
list-style: none;
margin: 0;
padding: 0;
}
.rn__tree:focus-visible {
outline: none;
}
.rn__row {
box-sizing: border-box;
height: 26px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
margin-bottom: 4px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: default;
user-select: none;
}
.rn__row:hover {
background: rgba(23, 20, 26, 0.05);
}
.rn__ico {
display: inline-flex;
flex: 0 0 auto;
color: #17141a;
}
.rn__ico--folder {
color: #2f6df6;
}
.rn__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.rn__ok {
margin-left: auto;
display: inline-flex;
flex: 0 0 auto;
color: #4cd4a6;
opacity: 0;
}
.rn__ok.is-flash {
animation-name: rn-ok;
animation-duration: 480ms;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: 1;
}
@keyframes rn-ok {
0% {
opacity: 0;
transform: scale(0.5);
}
40% {
opacity: 1;
transform: scale(1);
}
100% {
opacity: 0;
transform: scale(1);
}
}
.rn__stack {
position: relative;
display: block;
flex: 1;
height: 26px;
min-width: 0;
}
.rn__lab {
position: absolute;
left: 0;
top: 0;
line-height: 26px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
opacity: 0;
}
.rn__field {
position: absolute;
left: -4px;
top: 1px;
right: 0;
height: 24px;
display: none;
align-items: center;
padding: 0 8px;
border: 1px solid #2f6df6;
border-radius: 4px;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.18);
opacity: 0;
}
.rn__fv {
font-size: 12px;
font-weight: 600;
white-space: nowrap;
}
.rn__fv--1 {
background: rgba(47, 109, 246, 0.18);
}
.rn__caret {
display: inline-block;
width: 1px;
height: 14px;
margin-left: 0;
background: #2f6df6;
}
.rn__live {
display: inline-flex;
flex: 1;
min-width: 0;
}
.rn__edit {
box-sizing: border-box;
width: 100%;
height: 22px;
padding: 0 8px;
border: 1px solid #2f6df6;
border-radius: 4px;
background: #fff;
box-shadow: 0 0 0 2px rgba(47, 109, 246, 0.18);
font-family: inherit;
font-size: 12px;
font-weight: 600;
color: #17141a;
outline: none;
}
.rn.is-demo .rn__lab--1 {
animation-name: rn-l1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l1 {
0% {
opacity: 1;
}
8% {
opacity: 0;
}
94% {
opacity: 1;
}
100% {
opacity: 1;
}
}
.rn.is-demo .rn__lab--2 {
animation-name: rn-l2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-l2 {
0% {
opacity: 0;
}
52% {
opacity: 1;
}
82% {
opacity: 1;
}
82.01%, 100% {
opacity: 0;
}
}
.rn.is-demo .rn__field {
display: flex;
animation-name: rn-f;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-f {
0% {
opacity: 0;
}
8% {
opacity: 1;
}
52% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__fv--1 {
animation-name: rn-v1;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v1 {
0%, 7.9% {
opacity: 0;
}
8% {
opacity: 1;
}
36% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__fv--2 {
animation-name: rn-v2;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-v2 {
0%, 35.9% {
opacity: 0;
}
36% {
opacity: 1;
}
52% {
opacity: 0;
}
100% {
opacity: 0;
}
}
.rn.is-demo .rn__caret {
animation-name: rn-caret;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
@keyframes rn-caret {
0%, 7.9% {
opacity: 0;
}
8% {
opacity: 1;
}
16% {
opacity: 0;
}
24% {
opacity: 1;
}
32% {
opacity: 0;
}
40% {
opacity: 1;
}
48% {
opacity: 0;
}
52%, 100% {
opacity: 0;
}
}
.rn.is-demo .rn__ok {
animation-name: rn-oka;
animation-duration: 2s;
animation-timing-function: cubic-bezier(0.2, 1.4, 0.4, 1);
animation-iteration-count: infinite;
}
@keyframes rn-oka {
0%, 51.9% {
opacity: 0;
transform: scale(0.5);
}
56% {
opacity: 1;
transform: scale(1);
}
80% {
opacity: 1;
transform: scale(1);
}
92%, 100% {
opacity: 0;
transform: scale(0.5);
}
}
@media (max-width: 340px) {
.rn__row {
height: 22px;
font-size: 11px;
}
.rn__lab {
line-height: 22px;
}
.rn__field {
height: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.rn.is-demo .rn__lab, .rn.is-demo .rn__field, .rn.is-demo .rn__fv, .rn.is-demo .rn__caret, .rn.is-demo .rn__ok {
animation: none !important;
}
.rn.is-demo .rn__lab--1 {
opacity: 1;
}
.rn.is-demo .rn__field {
display: none;
}
}// 실제 동작: 두 번 누르거나 F2 로 그 자리에서 고치고, + 로 새 줄을 넣는다.
var root = document.querySelector('.rn');
var tree = document.getElementById('rn-tree');
function edit(li, startName) {
root.classList.remove('is-demo');
li.querySelector('.rn__stack').style.display = 'none';
var name = li.querySelector('.rn__name') || li.querySelector('.rn__stack');
var holder = document.createElement('span');
holder.className = 'rn__live';
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'rn__edit';
inp.value = startName != null ? startName : (li.getAttribute('aria-label') || 'notes.md');
inp.setAttribute('aria-label', 'File name');
holder.appendChild(inp);
li.querySelector('.rn__row').insertBefore(holder, li.querySelector('.rn__ok'));
inp.focus({ preventScroll: true });
inp.select();
function done(save) {
var v = inp.value.trim();
if (save && v) {
var stack = li.querySelector('.rn__stack');
if (stack) {
stack.remove();
var n = document.createElement('span');
n.className = 'rn__name';
n.textContent = v;
li.querySelector('.rn__row').insertBefore(n, holder);
li.setAttribute('aria-label', v);
} else {
var nm = li.querySelector('.rn__name');
if (nm) { nm.textContent = v; nm.style.visibility = ''; }
}
var ok = li.querySelector('.rn__ok');
if (ok) { ok.classList.add('is-flash'); setTimeout(function () { ok.classList.remove('is-flash'); }, 480); }
} else {
var nm2 = li.querySelector('.rn__name');
if (nm2) nm2.style.visibility = '';
var st2 = li.querySelector('.rn__stack');
if (st2) st2.style.display = '';
}
holder.remove();
tree.focus({ preventScroll: true });
}
inp.addEventListener('keydown', function (e) {
if (e.key === 'Enter') { done(true); e.preventDefault(); }
else if (e.key === 'Escape') { done(false); e.preventDefault(); }
});
}
tree.querySelectorAll('.rn__item').forEach(function (li) {
li.querySelector('.rn__row').addEventListener('click', function () {
tree.setAttribute('aria-activedescendant', li.id);
});
li.querySelector('.rn__row').addEventListener('dblclick', function () {
if (li.classList.contains('is-edit')) { edit(li); return; }
var nm = li.querySelector('.rn__name');
if (nm) { edit(li, nm.textContent); nm.style.visibility = 'hidden'; }
});
});
tree.addEventListener('keydown', function (e) {
var li = document.getElementById(tree.getAttribute('aria-activedescendant'));
if ((e.key === 'F2' || e.key === 'Enter') && li) {
var nm = li.querySelector('.rn__name');
if (li.classList.contains('is-edit')) edit(li);
else if (nm) { edit(li, nm.textContent); nm.style.visibility = 'hidden'; }
e.preventDefault();
}
});
document.getElementById('rn-add').addEventListener('click', function () {
root.classList.remove('is-demo');
var li = document.createElement('li');
li.className = 'rn__item';
li.id = 'rn-new-' + Date.now();
li.setAttribute('role', 'treeitem');
li.setAttribute('aria-level', '1');
li.innerHTML = '<div class="rn__row"><span class="rn__ico"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152"/></g></svg></span><span class="rn__name">new-file.md</span><span class="rn__ok"></span></div>';
tree.appendChild(li);
tree.setAttribute('aria-activedescendant', li.id);
li.querySelector('.rn__row').addEventListener('click', function () { tree.setAttribute('aria-activedescendant', li.id); });
edit(li, 'new-file.md');
});import { useEffect, useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./InlineRenameTree.module.scss";
export type RenameNode = { id: string; name: string; kind?: "file" | "folder" };
export type InlineRenameTreeProps = {
title?: string;
addLabel?: string;
newName?: string;
nodes?: RenameNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
type ChangeEvt = { target: { value: string } };
const DEFAULT_NODES: RenameNode[] = [
{ id: "rn-1", name: "src", kind: "folder" },
{ id: "rn-2", name: "notes.md" },
{ id: "rn-3", name: "app.js" },
{ id: "rn-4", name: "bundle.zip" },
];
function folderIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function fileIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="m213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v128a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 51.31L188.69 80H160ZM144 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 8 8h16a36 36 0 0 0 0-72m0 56h-8v-40h8a20 20 0 0 1 0 40m-40-48v56a8 8 0 0 1-16 0v-30.62l-13.45 19.21a8 8 0 0 1-13.1 0L48 177.38V208a8 8 0 0 1-16 0v-56a8 8 0 0 1 14.55-4.59L68 178.05l21.45-30.64A8 8 0 0 1 104 152" />
</g>
</svg>
);
}
function plusIcon() {
return (
<svg viewBox="0 0 256 256" width="11" height="11" aria-hidden="true">
<g fill="currentColor">
<path d="M216 56v144a16 16 0 0 1-16 16H56a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h144a16 16 0 0 1 16 16" opacity=".2" />
<path d="M224 128a8 8 0 0 1-8 8h-80v80a8 8 0 0 1-16 0v-80H40a8 8 0 0 1 0-16h80V40a8 8 0 0 1 16 0v80h80a8 8 0 0 1 8 8" />
</g>
</svg>
);
}
function okIcon() {
return (
<svg viewBox="0 0 256 256" width="12" height="12" aria-hidden="true">
<g fill="currentColor">
<path d="M232 56v144a16 16 0 0 1-16 16H40a16 16 0 0 1-16-16V56a16 16 0 0 1 16-16h176a16 16 0 0 1 16 16" opacity=".2" />
<path d="m205.66 85.66l-96 96a8 8 0 0 1-11.32 0l-40-40a8 8 0 0 1 11.32-11.32L104 164.69l90.34-90.35a8 8 0 0 1 11.32 11.32" />
</g>
</svg>
);
}
export default function InlineRenameTree({
title = "프로젝트 파일",
addLabel = "파일 추가",
newName = "new-file.md",
nodes = DEFAULT_NODES,
duration = 220,
easing = "cubic-bezier(.2,1.4,.4,1)",
color = "#2f6df6",
}: InlineRenameTreeProps) {
const [list, setList] = useState<RenameNode[]>(nodes);
const [at, setAt] = useState(nodes.length > 1 ? nodes[1].id : nodes[0].id);
const [editing, setEditing] = useState<string | null>(null);
const [draft, setDraft] = useState("");
const [flash, setFlash] = useState<string | null>(null);
const treeRef = useRef<HTMLUListElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const seq = useRef<number>(0);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
// 입력칸이 뜨면 그 자리로 포커스 + 기존 이름 전체선택
useEffect(() => {
if (!editing) return;
const input = inputRef.current;
if (!input) return;
input.focus({ preventScroll: true });
input.select();
}, [editing]);
// 저장 뒤 체크 표시 한 번 (vanilla 의 is-flash 480ms 와 같은 길이)
useEffect(() => {
if (!flash) return;
const t = window.setTimeout(() => setFlash(null), 480);
return () => window.clearTimeout(t);
}, [flash]);
function edit(id: string, name: string) {
setAt(id);
setDraft(name);
setEditing(id);
}
function done(save: boolean) {
const id = editing;
if (!id) return;
const v = draft.trim();
if (save && v) {
setList((ns) => ns.map((n) => (n.id === id ? { id: n.id, name: v, kind: n.kind } : n)));
setFlash(id);
}
setEditing(null);
treeRef.current?.focus({ preventScroll: true });
}
function onInputKeyDown(e: KeyEvt) {
if (e.key === "Enter") { done(true); e.preventDefault(); }
else if (e.key === "Escape") { done(false); e.preventDefault(); }
}
function onTreeKeyDown(e: KeyEvt) {
if (editing) return;
if (e.key === "F2" || e.key === "Enter") {
const n = list.filter((x) => x.id === at)[0];
if (n) edit(n.id, n.name);
e.preventDefault();
}
}
function add() {
const k = (seq.current || 0) + 1;
seq.current = k;
const id = `rn-new-${k}`;
setList((ns) => ns.concat([{ id, name: newName }]));
edit(id, newName);
}
return (
<div className={styles.rn} style={vars}>
<div className={styles.head}>
<span className={styles.title} id="rn-title">{title}</span>
<button className={styles.add} type="button" aria-label={addLabel} onClick={add}>
{plusIcon()}
<span>{addLabel}</span>
</button>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-labelledby="rn-title"
tabIndex={0}
aria-activedescendant={at}
onKeyDown={onTreeKeyDown}
>
{list.map((n) => (
<li key={n.id} id={n.id} className={styles.item} role="treeitem" aria-level={1} aria-label={n.name}>
<div
className={`${styles.row} ${at === n.id ? styles.isAt : ""}`}
onClick={() => setAt(n.id)}
onDoubleClick={() => edit(n.id, n.name)}
>
<span className={`${styles.ico} ${n.kind === "folder" ? styles.icoFolder : ""}`}>
{n.kind === "folder" ? folderIcon() : fileIcon()}
</span>
{editing === n.id ? (
<span className={styles.live}>
<input
ref={inputRef}
className={styles.edit}
type="text"
aria-label="파일 이름"
value={draft}
onChange={(e: ChangeEvt) => setDraft(e.target.value)}
onKeyDown={onInputKeyDown}
/>
</span>
) : (
<span className={styles.name}>{n.name}</span>
)}
<span className={`${styles.ok} ${flash === n.id ? styles.isFlash : ""}`} aria-hidden="true">
{okIcon()}
</span>
</div>
</li>
))}
</ul>
</div>
);
}08 · Inline rename in place — Measured area 2.036% · moving frames 10/23Original by GODRICH · SCSS and React ship in the zip · See the password
How to read the code
The old label is kept rather than destroyed — visibility: hidden — so canceling restores it without a rebuild, and the confirm check flashes once, 480ms of scale(.5→1).
function edit(li, startName) {
root.classList.remove('is-demo');
li.querySelector('.rn__stack').style.display = 'none';
var name = li.querySelector('.rn__name') || li.querySelector('.rn__stack');
var holder = document.createElement('span');
holder.className = 'rn__live';
var inp = document.createElement('input');
inp.type = 'text'; inp.className = 'rn__edit';
inp.value = startName != null ? startName : (li.getAttribute('aria-label') || 'notes.md');
inp.setAttribute('aria-label', 'File name');
holder.appendChild(inp);
li.querySelector('.rn__row').insertBefore(holder, li.querySelector('.rn__ok'));
inp.focus({ preventScroll: true });
inp.select();
} else {
var nm2 = li.querySelector('.rn__name');
if (nm2) nm2.style.visibility = '';
var st2 = li.querySelector('.rn__stack');
if (st2) st2.style.display = '';
}
holder.remove();
tree.focus({ preventScroll: true });
09Tree with size bars
What moves
Every row carries a horizontal bar for its size, filled by transform: scaleX(var(--v)) from the left edge rather than by animating a width. Press one and every bar re-scales against it, which turns the tree into a cleanup screen — the longest bar is what needs deleting.

<main class="stage">
<div class="zb is-demo" id="zb">
<div class="zb__head">
<span class="zb__title">Storage 6.8 GB</span>
<span class="zb__hint">Click a folder to re-scale against it</span>
</div>
<ul class="zb__tree" id="zb-tree" role="tree" aria-label="Storage" tabindex="0" aria-activedescendant="zb-1">
<li class="zb__item" id="zb-1" role="treeitem" aria-level="1" data-v="4.2">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="zb__name">media</span>
<span class="zb__size" aria-hidden="true">4.2 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="82" aria-label="media size" style="--v: .82"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-2" role="treeitem" aria-level="1" data-v="1.1">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="zb__name">docs</span>
<span class="zb__size" aria-hidden="true">1.1 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="21" aria-label="docs size" style="--v: .21"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-3" role="treeitem" aria-level="1" data-v="0.6">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico zb__ico--folder" aria-hidden="true"><svg viewBox="0 0 256 256" width="14" height="14"><g fill="currentColor"><path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2"/><path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z"/></g></svg></span>
<span class="zb__name">src</span>
<span class="zb__size" aria-hidden="true">0.6 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="12" aria-label="src size" style="--v: .12"><i class="zb__fill"></i></div>
</li>
<li class="zb__item" id="zb-4" role="treeitem" aria-level="1" data-v="0.9">
<div class="zb__row" data-hit tabindex="-1">
<span class="zb__ico" aria-hidden="true"><svg viewBox="0 0 256 256" width="13" height="13"><g fill="currentColor"><path d="M208 88h-56V32Z" opacity=".2"/><path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z"/></g></svg></span>
<span class="zb__name">backup.zip</span>
<span class="zb__size" aria-hidden="true">0.9 GB</span>
</div>
<div class="zb__bar" role="meter" aria-valuemin="0" aria-valuemax="100" aria-valuenow="17" aria-label="backup.zip size" style="--v: .17"><i class="zb__fill"></i></div>
</li>
</ul>
</div>
</main>@use "../../../../site/static/demo-tokens" as *;
$duration: $dur-base;
$easing: $ease-out;
$color: $subject-blue;
@include stage($stage-ink);
.zb {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.zb__head {
display: flex; align-items: baseline; justify-content: space-between; gap: $sp-2;
margin: 0 0 $sp-2;
}
.zb__title {
font-size: 10px; line-height: 12px; font-weight: 800;
letter-spacing: .04em;
color: rgba(255, 255, 255, .75);
}
.zb__hint {
font-size: 9px; line-height: 12px;
color: rgba(255, 255, 255, .52);
}
.zb__tree { list-style: none; margin: 0; padding: 0; }
.zb__tree:focus-visible { outline: none; }
.zb__item { margin-bottom: $sp-1; }
.zb__row {
box-sizing: border-box;
height: 22px;
display: flex; align-items: center; gap: $sp-2;
padding: 0 $sp-2;
border-radius: $r-xs;
font-size: 12px; font-weight: 600;
cursor: pointer;
user-select: none;
}
.zb__row:hover { background: rgba(255, 255, 255, .07); }
.zb__item[aria-selected="true"] .zb__row { background: rgba(47, 109, 246, .22); }
.zb__ico { display: inline-flex; flex: 0 0 auto; color: #fff; }
.zb__ico--folder { color: $color; }
.zb__name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.zb__size {
margin-left: auto;
flex: 0 0 auto;
font-size: 10px; line-height: 12px; font-weight: 800;
color: rgba(255, 255, 255, .55);
}
.zb__bar {
display: block;
height: 6px;
margin: 0 $sp-2 $sp-1;
border-radius: $r-pill;
background: rgba(255, 255, 255, .12);
overflow: hidden;
}
.zb__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $color;
transform-origin: left center; // 규칙8: 크기 막대 왼쪽 채움
transform: scaleX(var(--v));
transition: transform $duration $easing;
}
// ── 자동 루프: 막대가 차오르고 크기가 컷으로 붙는다. 끝에는 다시 빠진다 ──
.zb.is-demo .zb__fill {
animation-name: zb-grow;
animation-duration: $dur-loop;
animation-timing-function: $easing;
animation-iteration-count: infinite;
animation-delay: var(--d, 0ms);
}
.zb__item:nth-child(1) .zb__fill { --d: 0ms; }
.zb__item:nth-child(2) .zb__fill { --d: 90ms; }
.zb__item:nth-child(3) .zb__fill { --d: 180ms; }
.zb__item:nth-child(4) .zb__fill { --d: 270ms; }
@keyframes zb-grow {
0% { transform: scaleX(0); }
16% { transform: scaleX(var(--v)); }
80% { transform: scaleX(var(--v)); }
96%, 100% { transform: scaleX(0); }
}
.zb.is-demo .zb__size {
animation-name: zb-size;
animation-duration: $dur-loop;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zb__item:nth-child(1) .zb__size { animation-delay: 0ms; }
.zb__item:nth-child(2) .zb__size { animation-delay: 90ms; }
.zb__item:nth-child(3) .zb__size { animation-delay: 180ms; }
.zb__item:nth-child(4) .zb__size { animation-delay: 270ms; }
@keyframes zb-size {
0%, 15.9% { opacity: 0; }
16%, 80% { opacity: 1; }
80.01%, 100% { opacity: 0; }
}
@media (max-width: 340px) {
.zb__row { height: 20px; font-size: 11px; }
.zb__hint { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.zb.is-demo .zb__fill, .zb.is-demo .zb__size { animation: none !important; }
.zb__fill { 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);
}
.zb {
box-sizing: border-box;
width: min(276px, 88vw);
font-family: -apple-system, "Segoe UI", "Noto Sans KR", sans-serif;
color: #fff;
}
.zb__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
margin: 0 0 8px;
}
.zb__title {
font-size: 10px;
line-height: 12px;
font-weight: 800;
letter-spacing: 0.04em;
color: rgba(255, 255, 255, 0.75);
}
.zb__hint {
font-size: 9px;
line-height: 12px;
color: rgba(255, 255, 255, 0.52);
}
.zb__tree {
list-style: none;
margin: 0;
padding: 0;
}
.zb__tree:focus-visible {
outline: none;
}
.zb__item {
margin-bottom: 4px;
}
.zb__row {
box-sizing: border-box;
height: 22px;
display: flex;
align-items: center;
gap: 8px;
padding: 0 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
cursor: pointer;
user-select: none;
}
.zb__row:hover {
background: rgba(255, 255, 255, 0.07);
}
.zb__item[aria-selected=true] .zb__row {
background: rgba(47, 109, 246, 0.22);
}
.zb__ico {
display: inline-flex;
flex: 0 0 auto;
color: #fff;
}
.zb__ico--folder {
color: #2f6df6;
}
.zb__name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.zb__size {
margin-left: auto;
flex: 0 0 auto;
font-size: 10px;
line-height: 12px;
font-weight: 800;
color: rgba(255, 255, 255, 0.55);
}
.zb__bar {
display: block;
height: 6px;
margin: 0 8px 4px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.12);
overflow: hidden;
}
.zb__fill {
display: block;
height: 100%;
border-radius: 999px;
background: #2f6df6;
transform-origin: left center;
transform: scaleX(var(--v));
transition: transform 300ms ease-out;
}
.zb.is-demo .zb__fill {
animation-name: zb-grow;
animation-duration: 2s;
animation-timing-function: ease-out;
animation-iteration-count: infinite;
animation-delay: var(--d, 0ms);
}
.zb__item:nth-child(1) .zb__fill {
--d: 0ms;
}
.zb__item:nth-child(2) .zb__fill {
--d: 90ms;
}
.zb__item:nth-child(3) .zb__fill {
--d: 180ms;
}
.zb__item:nth-child(4) .zb__fill {
--d: 270ms;
}
@keyframes zb-grow {
0% {
transform: scaleX(0);
}
16% {
transform: scaleX(var(--v));
}
80% {
transform: scaleX(var(--v));
}
96%, 100% {
transform: scaleX(0);
}
}
.zb.is-demo .zb__size {
animation-name: zb-size;
animation-duration: 2s;
animation-timing-function: steps(1, end);
animation-iteration-count: infinite;
}
.zb__item:nth-child(1) .zb__size {
animation-delay: 0ms;
}
.zb__item:nth-child(2) .zb__size {
animation-delay: 90ms;
}
.zb__item:nth-child(3) .zb__size {
animation-delay: 180ms;
}
.zb__item:nth-child(4) .zb__size {
animation-delay: 270ms;
}
@keyframes zb-size {
0%, 15.9% {
opacity: 0;
}
16%, 80% {
opacity: 1;
}
80.01%, 100% {
opacity: 0;
}
}
@media (max-width: 340px) {
.zb__row {
height: 20px;
font-size: 11px;
}
.zb__hint {
display: none;
}
}
@media (prefers-reduced-motion: reduce) {
.zb.is-demo .zb__fill, .zb.is-demo .zb__size {
animation: none !important;
}
.zb__fill {
transition: none;
}
}// 실제 동작: 폴더를 누르면 그 폴더를 100% 기준으로 막대가 다시 잡힌다.
var root = document.querySelector('.zb');
var tree = document.getElementById('zb-tree');
var title = document.querySelector('.zb__title');
var items = [].slice.call(tree.querySelectorAll('.zb__item'));
var base = null;
function paint() {
var baseV = base ? parseFloat(base.dataset.v) : 4.2;
items.forEach(function (li) {
var v = parseFloat(li.dataset.v);
var share = base ? Math.min(v / baseV, 1) : (v / 4.2) * .82;
li.querySelector('.zb__fill').style.transform = 'scaleX(' + share + ')';
li.querySelector('.zb__bar').setAttribute('aria-valuenow', String(Math.round(share * 100)));
li.setAttribute('aria-selected', String(base != null && li === base));
});
title.textContent = base ? ('Base: ' + base.querySelector('.zb__name').textContent + ' ' + base.dataset.v + ' GB') : 'Storage 6.8 GB';
}
items.forEach(function (li, i) {
li.querySelector('.zb__row').addEventListener('click', function () {
root.classList.remove('is-demo');
tree.setAttribute('aria-activedescendant', li.id);
base = (base === li) ? null : li;
paint();
});
});
tree.addEventListener('keydown', function (e) {
var at = items.indexOf(document.getElementById(tree.getAttribute('aria-activedescendant')));
if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
var li = items[Math.max(0, Math.min(at + (e.key === 'ArrowDown' ? 1 : -1), items.length - 1))];
tree.setAttribute('aria-activedescendant', li.id);
e.preventDefault();
} else if (e.key === 'Enter' && at >= 0) {
root.classList.remove('is-demo');
base = (base === items[at]) ? null : items[at];
paint();
e.preventDefault();
}
});import { useRef, useState } from "react";
import type { CSSProperties } from "react";
import styles from "./SizeBarTree.module.scss";
export type SizeNode = { name: string; gb: number; folder?: boolean };
export type SizeBarTreeProps = {
label?: string;
hint?: string;
nodes?: SizeNode[];
duration?: number;
easing?: string;
color?: string;
};
type Vars = CSSProperties & { "--duration"?: string; "--easing"?: string; "--color"?: string };
type BarVars = CSSProperties & { "--v"?: string };
type KeyEvt = { key: string; preventDefault: () => void };
const DEFAULT_NODES: SizeNode[] = [
{ name: "media", gb: 4.2, folder: true },
{ name: "docs", gb: 1.1, folder: true },
{ name: "src", gb: 0.6, folder: true },
{ name: "backup.zip", gb: 0.9 },
];
function folderIcon() {
return (
<svg viewBox="0 0 256 256" width="14" height="14" aria-hidden="true">
<g fill="currentColor">
<path d="M128 80H32V56a8 8 0 0 1 8-8h52.69a8 8 0 0 1 5.65 2.34Z" opacity=".2" />
<path d="M216 72h-84.69L104 44.69A15.86 15.86 0 0 0 92.69 40H40a16 16 0 0 0-16 16v144.62A15.4 15.4 0 0 0 39.38 216h177.51A15.13 15.13 0 0 0 232 200.89V88a16 16 0 0 0-16-16M92.69 56l16 16H40V56ZM216 200H40V88h176Z" />
</g>
</svg>
);
}
function fileIcon() {
return (
<svg viewBox="0 0 256 256" width="13" height="13" aria-hidden="true">
<g fill="currentColor">
<path d="M208 88h-56V32Z" opacity=".2" />
<path d="M184 144h-16a8 8 0 0 0-8 8v56a8 8 0 0 0 16 0v-8h8a28 28 0 0 0 0-56m0 40h-8v-24h8a12 12 0 0 1 0 24m-48-32v56a8 8 0 0 1-16 0v-56a8 8 0 0 1 16 0m-40 56a8 8 0 0 1-8 8H56a8 8 0 0 1-7-12l25.16-44H56a8 8 0 0 1 0-16h32a8 8 0 0 1 7 12l-25.21 44H88a8 8 0 0 1 8 8M213.66 82.34l-56-56A8 8 0 0 0 152 24H56a16 16 0 0 0-16 16v72a8 8 0 0 0 16 0V40h88v48a8 8 0 0 0 8 8h48v16a8 8 0 0 0 16 0V88a8 8 0 0 0-2.34-5.66M160 80V51.31L188.69 80Z" />
</g>
</svg>
);
}
export default function SizeBarTree({
label = "저장소",
hint = "폴더를 누르면 그 기준으로 다시 잽니다",
nodes = DEFAULT_NODES,
duration = 300,
easing = "ease-out",
color = "#2f6df6",
}: SizeBarTreeProps) {
const [base, setBase] = useState(-1);
const [at, setAt] = useState(0);
const treeRef = useRef<HTMLUListElement>(null);
const vars: Vars = { "--duration": `${duration}ms`, "--easing": easing, "--color": color };
const total = nodes.reduce((s, n) => s + n.gb, 0);
const top = nodes.reduce((m, n) => Math.max(m, n.gb), 0) || 1;
const baseV = base >= 0 && nodes[base] ? nodes[base].gb : top;
// 기준이 없으면 최대 폴더를 82% 로, 기준이 있으면 그 폴더를 100% 로 다시 잰다.
function share(n: SizeNode) {
return base >= 0 ? Math.min(n.gb / baseV, 1) : (n.gb / top) * 0.82;
}
const title = base >= 0 && nodes[base]
? `기준: ${nodes[base].name} ${nodes[base].gb} GB`
: `${label} ${total.toFixed(1)} GB`;
function pick(i: number) {
setAt(i);
setBase((b) => (b === i ? -1 : i));
treeRef.current?.focus({ preventScroll: true });
}
function onKeyDown(e: KeyEvt) {
if (e.key === "ArrowDown") { setAt((a) => Math.min(a + 1, nodes.length - 1)); e.preventDefault(); }
else if (e.key === "ArrowUp") { setAt((a) => Math.max(a - 1, 0)); e.preventDefault(); }
else if (e.key === "Home") { setAt(0); e.preventDefault(); }
else if (e.key === "End") { setAt(nodes.length - 1); e.preventDefault(); }
else if (e.key === "Enter" || e.key === " ") { setBase((b) => (b === at ? -1 : at)); e.preventDefault(); }
}
return (
<div className={styles.zb} style={vars}>
<div className={styles.head}>
<span className={styles.title}>{title}</span>
<span className={styles.hint}>{hint}</span>
</div>
<ul
ref={treeRef}
className={styles.tree}
role="tree"
aria-label={label}
tabIndex={0}
aria-activedescendant={`zb-${at}`}
onKeyDown={onKeyDown}
>
{nodes.map((n, i) => {
const s = share(n);
const bar: BarVars = { "--v": String(s) };
return (
<li
key={n.name}
id={`zb-${i}`}
className={`${styles.item} ${base === i ? styles.isBase : ""} ${at === i ? styles.isAt : ""}`}
role="treeitem"
aria-level={1}
aria-selected={base === i}
>
<div className={styles.row} onClick={() => pick(i)} tabIndex={-1}>
<span className={`${styles.ico} ${n.folder ? styles.icoFolder : ""}`} aria-hidden="true">
{n.folder ? folderIcon() : fileIcon()}
</span>
<span className={styles.name}>{n.name}</span>
<span className={styles.size} aria-hidden="true">{n.gb} GB</span>
</div>
<div
className={styles.bar}
role="meter"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(s * 100)}
aria-label={`${n.name} 용량`}
style={bar}
>
<i className={styles.fill} />
</div>
</li>
);
})}
</ul>
</div>
);
}09 · Tree with size bars — Measured area 1.824% · moving frames 12/23Original by GODRICH · SCSS and React ship in the zip · See the password
What to change
Each bar is a role="meter" whose aria-valuenow is rewritten in the same pass as the pixels.
.zb__fill {
display: block;
height: 100%;
border-radius: $r-pill;
background: $color;
transform-origin: left center;
transform: scaleX(var(--v));
transition: transform $duration $easing;
}
function paint() {
var baseV = base ? parseFloat(base.dataset.v) : 4.2;
items.forEach(function (li) {
var v = parseFloat(li.dataset.v);
var share = base ? Math.min(v / baseV, 1) : (v / 4.2) * .82;
li.querySelector('.zb__fill').style.transform = 'scaleX(' + share + ')';
li.querySelector('.zb__bar').setAttribute('aria-valuenow', String(Math.round(share * 100)));
li.setAttribute('aria-selected', String(base != null && li === base));
});
title.textContent = base ? ('Base: ' + base.querySelector('.zb__name').textContent + ' ' + base.dataset.v + ' GB') : 'Storage 6.8 GB';
}
Where it breaks — the trap
You cannot fold a height you never set. height: auto cannot be transitioned, so a tree that collapses needs a different lever. All three folding demos here (01 .ft__kids, 02 .ft__kids, 04 .dg__kids) wrap the children in a grid whose single row track goes from 0fr to 1fr, and every one carries the same pair on the inner wrapper: overflow: hidden; min-height: 0. Drop that line and the track hits zero while the content still pokes through, so the folder looks open forever. The pair sits at 01/style.scss line 58, 02/style.scss line 51, and 04/style.scss line 67.
The second trap is a number you baked in. Bars and highlights that ride the rows move by translateY(index * rowHeight), and at 340px and under the rows get shorter. 03 escapes that on the interactive side by measuring offsetHeight at runtime; the autoplay keyframes cannot measure anything, so @keyframes hl-bar is re-declared wholesale inside @media (max-width: 340px) with 24/48 dropped to 22/44 (03/style.scss lines 106–115), and 07 rewrites cm-bar the same way, 28/56/84 becoming 24/48/72 (07/style.scss lines 142–147). Shrink the rows and forget the keyframes, and the bar lands straddling two rows.
Third, two labels cross-fading in the same spot smear into mush halfway through. A closed and an open folder icon, three breadcrumb paths taking turns, a badge counting 0, 1, then 4 — every state swap in this roundup cuts hard with animation-timing-function: steps(1, end) instead of fading: ft-icf/ft-ico in 01, cs-1/2/3 in 03, ms-b0/b1/b4 in 05, sf-t0/t1/t2 in 06, rn-l1/l2 and rn-f in 08, zb-size in 09. Only the things that change position — the bar, the drag ghost, the menu — earn the spring and pop curves.
Fourth, the autoplay overlay must vanish on first touch, not fade away. The recorded loops in 04 (.dg__after) and 06 (.sf__hits) are fake layers stacked over the real list; the moment you interact, the script drops is-demo and the CSS follows with a flat display: none (04/style.scss line 74, 06/style.scss line 95). Hide them with opacity: 0 instead and the ghost rows keep stealing clicks while screen readers read them aloud. That same overlay layer, used over images, reappears as a loading veil in Image Zoom Viewer Interactions. All four fixes, at the exact lines above, ship in the source files inside the zip, which unlocks with the password pbqranpk.
Accessibility (reduced-motion)
Under prefers-reduced-motion: reduce all nine drop the recorded autoplay loop and keep every state; what differs is the cleanup each one needed besides that. In 01 the four loops (ft-kids, ft-caret, ft-icf, ft-ico) and the transitions on the kids and the caret switch off, while a folder you actually opened stands at grid-template-rows: 1fr, caret rotated 90 degrees, open icon on — all applied at once. In 02 the row entrance animations stop and the indent guides stay, because a border-left is not an animation, and rows sit at their default full opacity. In 03 the bar stops sliding and simply lands, and an extra rule keeps the first demo path visible so the path line never goes blank. In 04 the loops and the folder blink stop, but the drop-target ring .is-hot stays lit — it is a state class, not a flourish. In 05 all seven demo loops stop while the tint, the check, aria-selected, and the counting badge update the instant you click. In 06 the typing loops stop and nothing else needed stopping, since the filter itself was never animated. In 07 the menu's spring and the walking highlight stop, and a separate display: none keeps the menu from freezing half-open; right-click still opens it normally. In 08 the demo swaps stop, two extra rules revive the real name and clear the fake field, and renaming by double-click or F2 works exactly as before. In 09 the fill-up and the number cut stop, and each bar renders already filled to its inline --v value, numbers in place.
Across all nine the tree is a real role="tree" with tabindex="0" and aria-activedescendant, every row a role="treeitem" with aria-level, and every focus handoff goes through focus({ preventScroll: true }). The role and its required properties are specified in the MDN tree role reference. A tree usually lives in a sidebar, so the sidebar drawer build is a natural pair; folding lists in general are collected in the list-expand checklist patterns, and narrowing a tree by typing is close kin to the command palette search UI.
FAQ
Does a file explorer ui like this need JavaScript, or is it all CSS?
The fold, the guides, the sliding bar, and the size bars are CSS. The moves that change data — drag, multi-select, filter, menu, rename — run in a plain script tag, 29 to 90 lines per demo with no framework: 03 is the shortest at about 29 lines and 04 the longest at about 90. When a code window is what the sidebar needs beside it, Code Block UI Patterns takes that column.
Which one should I build first?
- Its markup is what the other eight build on, and 02 and 04 reuse the
0fr → 1frfold outright. Get its caret and keyboard working, then add the one skill your screen actually needs.
Can I change the speed and colors in one place?
Yes. Every stylesheet opens with the same three variables — $duration, $easing, $color — and the fold, the sliding bar, and the size bars all read their timing from them. A handful of one-off numbers sit outside that (07's 120ms highlight, 04's 320ms drop-in, 08's 480ms check). The shared values are $dur-quick: 220ms, $dur-base: 300ms, and the spring cubic-bezier(.2,.8,.2,1).









