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.
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 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:
| # | 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
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. The track itself grows — grid-template-rows animates from 0fr to 1fr — rather than anything inside the children moving.
// 열림/닫힘 — 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; }
02Type icons with indent guides
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. 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
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. 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
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. 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
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. 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 + '개 선택';
}
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
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. 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) + '건';
}
07Right-click context menu
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. 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 + ' 메뉴');
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
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. 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', '파일 이름');
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
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. 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.querySelector('.zb__name').textContent + ' ' + base.dataset.v + ' GB') : '저장소 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. 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
Do these 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.
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).