GODRICH
#css-animation#button#loader#transition#scroll#dashboard#ui#design-trends

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

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.

Caret-expand base tree
Open in a new tab

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

grid-template-rowsaria-expandedrole=tree

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.

Type icons with indent guides
Open in a new tab

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

inline SVGanimation-delayaria-level

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.

Active highlight with breadcrumb
Open in a new tab

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

translateYaria-activedescendantsteps(1,end)

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.

Drag a file into a folder
Open in a new tab

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

pointer eventselementFromPointEscape

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.

Shift and Ctrl multi-select
Open in a new tab

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

shiftKeyctrlKeyaria-multiselectable

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.

Search-filtered tree
Open in a new tab

06 · Search-filtered tree — Measured area 2.089% · moving frames 6/23Original by GODRICH · SCSS and React ship in the zip · See the password

inputmark::placeholder

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, '&amp;').replace(/</g, '&lt;'); }
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.

Right-click context menu
Open in a new tab

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

contextmenurole=menuEscape

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.

Inline rename in place
Open in a new tab

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

F2inputEnter

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.

Tree with size bars
Open in a new tab

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

scaleXaria-valuenowtransform-origin

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?

  1. Its markup is what the other eight build on, and 02 and 04 reuse the 0fr → 1fr fold 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).

Enter the archive password

The password is inside this article. You will find it as you read.