파일 트리 만들기 9종 — 폴더가 진짜로 열리는 사이드바
파일 트리 만들기는 폴더와 문서가 겹겹이 들어간 목록을, 누르면 실제로 펼쳐지고 접히는 사이드바로 직접 짜는 일입니다. 여기 아홉 개는 모양만 비슷한 게 아니라 끌어 넣기, 이름 고치기, 검색으로 좁히기, 용량 보기까지 전부 실제로 되는 구성입니다.
자동 재생 · 눌러서 해당 항목으로 · 전부 한 zip
- 01 삼각형 화살표로 펼치는 기본 트리
- 02 형식별 파일 아이콘과 들여쓰기 줄
- 03 선택 하이라이트와 경로 표시
- 04 드래그로 폴더에 넣기
- 05 Shift·Ctrl 다중 선택
- 06 검색으로 남기는 트리
- 07 우클릭 컨텍스트 메뉴
- 08 제자리에서 이름 바꾸기
- 09 용량 막대가 붙은 트리
아홉 개의 순서는 화려함이 아니라 사람이 트리를 쓰게 되는 차례를 따릅니다. 먼저 뼈대가 열리고 닫히고(01), 열린 줄이 무엇인지 읽혀야 하고(02), 한 줄을 짚었을 때 어디 있는지 알려 줘야 합니다(03). 그다음은 파일을 옮기고(04), 여러 개를 한꺼번에 잡고(05), 많아지면 이름으로 좁히고(06), 파일에 할 일을 커서 가까이 두고(07), 이름을 그 자리에서 고칩니다(08). 마지막 09는 트리가 관리 도구가 되는 쪽으로, 줄마다 용량을 달아 정리 대상을 보여 줍니다. 아홉 개 전부 외부 라이브러리·웹폰트·CDN 없이 두 파일로 서 있고, 목록마다 키보드 조작이 들어 있습니다 — 01·02·03은 화살표와 Home·End, 04·07은 화살표와 Enter·Escape, 05는 화살표와 Space, 06은 /와 Escape, 08은 F2, 09는 화살표와 Enter입니다.
기법과 움직임의 크기는 자동 재생 화면을 24프레임으로 찍어 실제로 변한 픽셀 비율과 프레임 수를 직접 잰 값입니다.
| 번호 | 이름 | 핵심 기법 | 누적면적 | 움직인 프레임 |
|---|---|---|---|---|
| 01 | 삼각형 화살표로 펼치는 기본 트리 | 격자 트랙 0fr→1fr | 12.287% | 9/24 |
| 02 | 형식별 파일 아이콘과 들여쓰기 줄 | 줄 번호로 순차 등장 | 5.168% | 11/24 |
| 03 | 선택 하이라이트와 경로 표시 | 막대 하나를 translateY | 13.612% | 9/24 |
| 04 | 드래그로 폴더에 넣기 | 화면 좌표로 대상 판정 | 13.852% | 14/24 |
| 05 | Shift·Ctrl 다중 선택 | 기준점 변수 하나 | 16.023% | 8/24 |
| 06 | 검색으로 남기는 트리 | 줄을 실제로 빼고 형광 | 2.089% | 6/24 |
| 07 | 우클릭 컨텍스트 메뉴 | 좌표 클램프와 하이라이트 | 21.478% | 15/24 |
| 08 | 제자리에서 이름 바꾸기 | 이름 자리에 입력칸 삽입 | 2.036% | 11/24 |
| 09 | 용량 막대가 붙은 트리 | 가로 늘이기로 막대 키움 | 1.785% | 12/24 |
가장 크게 움직이는 07과 움직인 프레임이 가장 적은 06의 누적면적 차이는 열 배가 넘는데, 06은 가만히 있는 화면이 아니라 검색에 반응하는 쪽이 목적이라 그렇습니다.
01삼각형 화살표로 펼치는 기본 트리
폴더 줄의 삼각형을 누르면 자식 줄이 통째로 미끄러지듯 내려오며 실제로 열리고, 다시 누르면 접힙니다. 접는 방식은 자손의 높이를 매 프레임 재는 게 아니라 격자 트랙 자체를 0fr에서 1fr로 키우는 것이라 줄이 몇 개든 똑같이 부드럽습니다. 문서·프로젝트·카테고리처럼 겹겹이 들어간 목록을 사이드바에 보여 줄 때 가장 먼저 깔리는 뼈대입니다.
// 열림/닫힘 — 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; }
02형식별 파일 아이콘과 들여쓰기 줄
파일마다 종류에 맞는 아이콘이 붙고 층이 깊어질수록 세로 들여쓰기 줄이 그어져 어느 묶음인지 한눈에 보입니다. 줄이 처음 떠오를 때는 위쪽 줄부터 순서대로 나타나는데, HTML에서 줄마다 style="--i: 0"처럼 차례 번호를 박아 두면 이 규칙 하나로 전체가 차례를 지킵니다. 같은 이름의 파일이 많거나 폴더가 세 겹 이상 겹쳐 계층이 안 읽힐 때 씁니다.
.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); }
}
03선택 하이라이트와 경로 표시
고른 줄에만 색 막대가 얹히고, 막대는 줄 사이를 미끄러지듯 옮겨 가며, 위쪽 경로 줄에 지금 고른 파일의 위치가 이어 붙습니다. 막대를 줄마다 하나씩 새로 칠하는 대신 하나만 만들어 옮기기 때문에 칠해지는 면적이 아니라 위치만 바뀌고, 이동 거리는 화면에서 줄 높이를 직접 재어 계산합니다. 트리가 길어져 지금 연 파일이 어느 폴더 안인지 놓치기 쉬운 편집 화면이나 자료실 사이드바에 맞습니다.
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;
}
04드래그로 폴더에 넣기
파일 줄을 끌어 폴더 위에 대면 폴더가 테두리와 배경으로 반응하고, 놓는 순간 파일이 그 폴더 안으로 실제로 이동합니다. 끌기는 손가락을 따라다니는 유령 조각으로 보여 주고 대상은 화면 좌표로 판정하며, 자식이 생기는 순간 폴더가 저절로 열리는 규칙은 .dg__f:has(.dg__kids li) 한 줄로 처리했습니다. 정리 안 된 파일을 마우스로 끌어 분류하는 자료실·업로드함·이미지 갤러리 정리 화면에 씁니다.
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·Ctrl 다중 선택
한 줄을 고른 뒤 Shift로 범위를 잡고 Ctrl로 하나씩 담으면 해당 줄에 배경이 차례로 쌓이고 개수가 위에 집계됩니다. 범위의 시작점을 기억하는 변수 하나로 세 가지 클릭 방식을 같은 규칙으로 묶었고, 목록 자체에 여러 개를 고를 수 있다는 표시를 붙였습니다. 여러 파일을 한 번에 내보내거나 옮기거나 지워야 하는 자료 목록의 선택 방식입니다.
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;
}
06검색으로 남기는 트리
검색창에 글자를 치면 일치하는 파일만 남고 그 조상 폴더는 흐리게 길을 남기며, 일치한 글자에만 형광이 입혀집니다. 해당 없는 줄은 흐리게 두는 대신 화면에서 실제로 빼고, 초점을 옮길 때는 화면이 딸려가지 않게 막아 두었습니다. 파일이 수백 개라 폴더를 하나하나 열어 찾는 게 느릴 때 이름으로 바로 좁히고 싶은 큰 트리에 씁니다.
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) + '건';
}
07우클릭 컨텍스트 메뉴
줄에서 우클릭하면 누른 자리에 메뉴가 튀어나오고, 항목 사이를 하이라이트가 오르내리며, Escape로 닫힙니다. 메뉴가 화면 밖으로 삐져나가지 않도록 좌표를 가장자리 안쪽으로 밀어 넣고, 닫히면 초점이 눌렀던 줄로 되돌아옵니다. 여는·고치는·지우는 같은 파일 동작을 화면 여기저기 단추로 늘어놓지 않고 커서 가까이 두고 싶을 때 씁니다.
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 });
}
08제자리에서 이름 바꾸기
줄을 두 번 누르면 이름 자리가 그대로 입력칸으로 바뀌어 기존 이름이 통째로 선택되고, Enter로 확정하고 Escape로 취소합니다. 입력칸은 줄 글자와 같은 글꼴에 높이 22px로 만들어 자리가 튀지 않으며, 확정 표시는 잠깐 한 번 커지는 것으로 끝냅니다. 이름을 바꾸려고 새 창이나 아랫줄 폼을 띄우는 대신 보던 자리에서 바로 고치게 하고 싶을 때 씁니다.
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 });
09용량 막대가 붙은 트리
줄마다 아래에 용량 막대가 자리 잡고 차오르며, 어느 줄이든 누르면 그 줄을 기준으로 비율을 다시 잽니다. 막대는 너비를 직접 늘리는 대신 가로로 늘이는 변형으로 키워 다시 계산하는 부담을 줄였고, 용량을 숫자뿐 아니라 막대 길이로도 읽게 했습니다. 저장소·프로젝트별 용량을 트리 안에서 곧바로 보여 정리 대상을 찾게 하는 관리 화면에 씁니다.
.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';
}
어디서 깨지는가 — 함정
폴더를 접는 애니메이션에서 가장 먼저 부딪히는 벽은 높이입니다. 자식이 몇 줄인지 모르는 목록의 높이는 미리 정해 둘 수 없는 값이라 그 자체로는 부드럽게 변하지 않는데, 이 편의 접는 트리 셋(01·02·04)은 감싼 요소를 격자로 두고 트랙을 0fr에서 1fr로 전환해 해결했습니다. 여기서 안쪽 요소에 overflow: hidden과 min-height: 0 두 줄이 없으면 트랙이 0이어도 내용이 삐져나와 전혀 접히지 않습니다. 세 파일 모두 이 두 줄을 넣어 뒀습니다.
둘째는 줄을 따라 움직이는 막대의 이동 거리입니다. 막대는 줄 번호에 줄 높이를 곱한 만큼 내려가는데, 좁은 화면에서는 줄 높이 자체가 줄어듭니다. 03은 화면에서 줄 높이를 직접 재어 돌려주는 함수로 피했고, 자동 재생 루프처럼 피할 수 없는 부분은 340px 이하 규칙 안에서 키프레임을 24/48에서 22/44로 통째로 다시 썼습니다. 07도 같은 이유로 28/56/84를 24/48/72로 고쳤습니다. 높이만 줄이고 키프레임을 그대로 두면 막대가 두 줄에 걸쳐 앉습니다.
셋째는 같은 자리에서 바뀌는 두 글자·두 아이콘입니다. 겹쳐 둔 두 벌을 서서히 크로스페이드하면 중간 프레임에서 둘 다 반투명으로 포개져 글자가 뭉개집니다. 그래서 상태가 바뀌는 교체는 전부 계단 함수로 딱 끊었습니다. 01의 폴더 아이콘, 03의 경로 세 벌, 05의 개수 배지, 06의 입력 글자, 08의 이름과 입력칸, 09의 용량 글자가 그 대상이고, 반대로 위치가 움직이는 막대·유령 조각·메뉴에만 스프링·팝 곡선을 썼습니다.
넷째는 자동 재생용 겹침 층입니다. 04와 06은 재생 결과 화면을 실제 목록 위에 겹쳐 놓은 가짜 층을 두는데, 사용자가 한 번 만지는 순간 이 층은 완전히 치워야 합니다. 두 데모 다 겹침 층을 투명도로만 숨기지 않고 아예 화면에서 빼는데, 어렴풋이 남겨 두면 클릭을 가로채거나 스크린 리더가 유령 줄을 읽기 때문입니다. 이 네 가지를 모두 고친 뒤의 소스는 압축 비밀번호 pbqranpk를 넣어야 열리는 zip에 아홉 벌 그대로 담겨 있습니다.
접근성
아홉 개 전부 시스템 설정이 움직임 줄이기로 되어 있으면 자동 재생 루프를 끄지만, 무엇을 끄고 무엇을 남기는지는 제각각입니다. 01은 반복 네 개와 전이 두 개를 끄는 대신 실제로 눌러 여닫는 상태인 트랙 1fr·화살표 90도·열린 폴더 아이콘을 즉시 적용하고, 02는 줄 등장 순서만 꺼지고 세로 들여쓰기 줄과 형식 라벨은 그대로 남습니다. 03은 막대 루프와 경로 교체가 꺼져도 첫 경로를 남겨 경로 줄이 빈칸이 되지 않게 했고, 04는 드롭 대상 테두리와 파일 이동 결과를 그대로 둔 채 유령 조각만 지웁니다. 05는 고른 배경·체크 표시·개수 배지가 즉시 바뀌고, 06은 끌 전이가 원래 없어 루프만 꺼도 실제 필터가 그대로 돕니다. 07은 애니메이션이 꺼진 채 메뉴가 열린 상태로 굳는 것을 막는 규칙을 따로 넣었고, 08은 원래 이름을 되살리고 가짜 입력칸을 치워 이름 칸이 빈 상태로 멈추지 않게 했으며, 09는 0에서 차오르는 연출만 꺼지고 막대는 처음부터 채워진 채로 보입니다. 트리의 역할과 속성 정의는 MDN의 tree 역할 문서에 정리돼 있습니다.
트리가 얹히는 사이드바 자체는 사이드바 서랍에서, 접고 펴는 목록의 다른 모음은 체크리스트 펼치기에서 다룹니다. 목록에서 고르는 방식은 셀렉트박스 9종에, 쳐서 좁히는 검색은 커맨드 팔레트에 모아 두었습니다.
FAQ
키보드만으로 이 트리들을 다 쓸 수 있나요?
네. 아홉 개 전부 목록에 tabindex="0"을 달아 Tab으로 닿고, 그중 01·02·03·04·05·09는 위아래 화살표로 줄을 옮기며 01·02·03은 Home과 End로 양 끝까지 갑니다. 01은 왼쪽 화살표가 열린 폴더를 닫고 닫힌 폴더에서는 부모 줄로 올라가며, 04는 파일 줄에서 Enter로 이동 모드에 들어가 화살표로 대상 폴더를 고르고 Escape로 취소합니다. 06은 트리에서 /로 검색창에 가고 Escape로 비우며, 08은 두 번 누르기 대신 F2로도 편집이 열립니다.
React 프로젝트에도 바로 붙일 수 있나요?
붙일 수 있습니다. zip 안에는 순수 버전 아홉 벌과 함께 React 판 아홉 벌이 들어 있고, 순수 버전은 각자 index.html과 style.scss 두 파일로 외부 라이브러리·웹폰트·CDN을 하나도 안 씁니다. 스타일 맨 위에 시간·곡선·색 변수가 모여 있어 값을 한 곳에서 바꾸면 되고, 07의 120ms 하이라이트·04의 320ms 안착·08의 480ms 확인 표시처럼 한 번만 쓰는 값 몇 개만 그 밖에 따로 적혀 있습니다.
폴더가 접힐 때 뚝뚝 끊기는데 어디를 볼까요?
접히는 대상을 높이값으로 만들었는지 먼저 보세요. 이 편의 접는 트리는 감싼 요소를 격자로 두고 트랙을 0fr에서 1fr로 전환하는데, 안쪽 요소에 overflow: hidden과 min-height: 0이 빠지면 트랙이 0이어도 내용이 삐져나와 전혀 접히지 않습니다. 01·02·04의 소스에 이 두 줄이 있는지 확인하면 대부분 해결됩니다.