01Caret-expand base tree
Clicking a folder's caret really slides its child rows down into view and collapses them back on the second click.
Best forThe base skeleton you lay first when a sidebar shows nested documents, projects, or categories
grid-template-rowsaria-expandedrole=tree

<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 · 9 File Tree CSS Views — Folders That Really Open
The post this demo lives in
9 File Tree CSS Views — Folders That Really OpenA file tree css view is the nested folder list in a sidebar: folders that open, files with icons, a highlight on the row See all demos in this post


