ファイルツリー 作成 9選 — フォルダが実際に開くツリービュー
ファイルツリー 作成 とは、エクスプローラーの左側にあるような階層リストを HTML と CSS、それに少しの JavaScript で組み立てることです。9つとも、フォルダは画像でも飾りでもなく実際に開き、移動も選択もマウスと矢印キーの両方でできます。
自動再生 · タイルを押すとその項目へ · すべて1つのzip
- 01 キャレットで開く基本ツリー
- 02 種類別アイコンとインデント線
- 03 選択ハイライトとパンくず
- 04 ドラッグでフォルダへ入れる
- 05 Shift・Ctrl 複数選択
- 06 検索で絞るツリー
- 07 右クリック コンテキストメニュー
- 08 その場で名前を変える
- 09 容量バー付きツリー
並び順は人気ではなく、人がツリーを実際に使う順です。まずフォルダの開閉(01)がすべての土台で、深くなっても階層が読める表示(02)、選んだ行を示す強調とパンくず(03)が続きます。ここから先はファイルを操作する順です。整理のためにドラッグでフォルダへ入れ(04)、まとめて扱うために複数選択を用意し(05)、数が増えたら名前で絞り込み(06)、操作は右クリックにまとめ(07)、名前はその場で直します(08)。最後の09だけは操作する側ではなく眺める側の番号で、行ごとの容量をバーで示します。9つとも一覧に role="tree" と tabindex="0" が、行ごとに role="treeitem" と aria-level が付き、キー操作は項目ごとに違います。01・02・03 は矢印キーと Home・End、04 と 07 は矢印キーと Enter・Escape、05 は矢印キーと Space、06 は / と Escape、08 は F2 と Enter・Escape、09 は矢印キーと Enter です。
| 番号 | 仕掛けの核 | 累積面積 | 動いたフレーム |
|---|---|---|---|
| 01 | grid-template-rows 0fr→1fr | 12.287% | 9/24 |
| 02 | インデント線 + animation-delay | 5.168% | 11/24 |
| 03 | translateY で動くバー1本 | 13.612% | 9/24 |
| 04 | setPointerCapture + elementFromPoint | 13.852% | 14/24 |
| 05 | shiftKey 範囲 + ctrlKey トグル | 16.023% | 8/24 |
| 06 | li.hidden + mark | 2.089% | 6/24 |
| 07 | contextmenu + translateY | 21.478% | 15/24 |
| 08 | input をその場に挿入 | 2.036% | 11/24 |
| 09 | scaleX(var(--v)) | 1.785% | 12/24 |
01キャレットで開く基本ツリー
フォルダ行の三角を押すと子の行が実際にスライドして開き、もう一度押すと閉じます。畳む仕組みは、子孫を1本ずつ動かすのではなく、グリッドのトラックそのものを grid-template-rows で 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種類別アイコンとインデント線
ファイルごとに種類別のアイコンが付き、階層が深くなるほど縦のインデント線が引かれて、どのまとまりに入っているのかが一目で分かります。線は深さごとの border-left: 1px solid で、第3階層は色を薄くして読み分けます。行ごとにインラインの --i へ 0 から 6 までの番号が振ってあり、animation-delay: 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); }
}
03選択ハイライトとパンくず
選んだ行だけに色の付いたバーが乗り、バーは行から行へ滑って移動します。行ごとに背景を塗り直す代わりに position: absolute のバー1本を translateY で動かし、移動量のもとになる行の高さは offsetHeight で実測します。上のパンくずは3枚の文字を steps(1, end) で切り替えて繋ぎ、選んだ行を aria-activedescendant で指す作りはセレクトボックスの記事と同じ土台です。
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ドラッグでフォルダへ入れる
ファイル行をドラッグしてフォルダに重ねると、フォルダ側が枠と背景で反応し、離した瞬間にその中へ実際に移動します。ポインタは setPointerCapture で1つの要素に固定し、指先の下にある要素を document.elementFromPoint() で調べ、移し先が決まったら appendChild で本物の DOM を動かします。フォルダは .dg__f:has(.dg__kids li) だけで開くので、開閉のための JavaScript は要りません。
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 複数選択
1行を選んでから Shift で範囲を取り、Ctrl か Cmd で個別に拾うと、選んだ行に背景とチェックが重なり、上のバッジに個数が出ます。範囲選択も個別選択も直前の選択も、基準は anchor 変数1つで足ります。一覧には aria-multiselectable="true" が付き、個数は aria-live="polite" の領域が読み上げます。
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検索で絞るツリー
検索欄に文字を打つと一致するファイルだけが残り、上位のフォルダは薄いまま道筋を残し、一致した文字にだけマーカーが付きます。一致しない行は li.hidden で表示からも読み上げからも外し、当てはまった文字は <mark> で包みます。検索欄への出入りは / キーと Escape で、どちらも focus({ preventScroll: true }) を通して画面を飛ばさずに移します。
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 で閉じます。座標は Math.min(x - r.left, root.offsetWidth - 152) のように端で挟み込んで枠の外へ出さず、項目に mouseenter したときも矢印キーと同じ1本のハイライトが translateY(at * 28) で付いてきます。閉じるときは focus({ preventScroll: true }) でフォーカスを元の行へ戻します。
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その場で名前を変える
行をダブルクリックするか F2 を押すと、名前の場所がそのまま入力欄に変わり、元の名前がすべて選択された状態で始まります。入力欄は高さ 22px・font-family: inherit で行の文字と同じ書体にそろえ、確定の印は 480ms だけ scale(.5→1) で弾みます。Escape なら隠しておいた元の名前をそのまま戻し、入力欄も一緒に片付けます。
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容量バー付きツリー
行ごとに容量バーが下に敷かれて左端から満ち、どれか1行を押すとその行を基準にすべてのバーが引き直されます。バーは width ではなく transform: scaleX(var(--v)) で、transform-origin: left center を起点に右へ伸びます。入れ物には role="meter" と aria-valuenow が付き、基準を変えるたびに値が書き換わります。
.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';
}
どこで壊れるか — 落とし穴
まず折りたたみです。height: auto は transition の対象にならないため、子が何行あっても滑らかには畳めません。この記事で畳む3つ(01 と 02 の .ft__kids、04 の .dg__kids)はすべて、包む要素を display: grid; grid-template-rows: 0fr にして、開くとき 1fr へ遷移させます。このとき対になる .ft__kwrap と .dg__kwrap に overflow: hidden; min-height: 0 の1行がないと、トラックが 0fr でも中身がはみ出して、まったく畳めません。
次に、行の上を滑る部品のずれです。translateY(番号 * 行の高さ) で動くバーとハイライトは、幅 340px 以下では行の高さ自体が縮み、行と行の間にまたがってしまいます。03 は JavaScript 側では rowH() で offsetHeight を実測してこれを避け、CSS の自動ループ側は避けられないため @media (max-width: 340px) の中で @keyframes hl-bar を 24/48 から 22/44 へ書き直しています。07 も同じ理由で @keyframes cm-bar を 28/56/84 から 24/48/72 へ書き直しています。
3つ目は文字の重なりです。同じ場所に重ねた2枚の文字を opacity でクロスフェードさせると、途中のフレームで両方が半透明に重なってにじみます。この記事では状態の入れ替えをすべて steps(1, end) で切り替えます。01 のフォルダアイコン、03 のパンくず3枚、05 の個数、06 の入力文字、08 の名前と入力欄、09 の容量文字です。逆に位置が動くもの(バー・ドラッグのゴースト・メニュー)だけが spring と pop のイージングを使います。最後に自動再生の重ね層です。04 の .dg__after と 06 の .sf__hits は見せかけの結果を本物の一覧の上に置いた層で、触れた瞬間に classList.remove('is-demo') と display: none で完全に片付けます。opacity: 0 だけだとクリックを横取りし、スクリーンリーダーが見せかけの行を読み上げてしまいます。直しまで含めた9つ分のソースは、解凍パスワード pbqranpk で開く zip にすべて入れてあります。
アクセシビリティ
9つとも prefers-reduced-motion: reduce では自動再生のループを animation: none !important で止め、状態の値はそのまま残します。01 は4つのループ(ft-kids・ft-caret・ft-icf・ft-ico)が止まって開閉の遷移もなくなりますが、クリックで開いた .is-open の結果はその場ですぐ反映されます。02 は行のアニメーションが止まっても行は opacity: 1 の既定値で見え、縦のガイド線と aria-level が残ります。03 は最初のパンくずを .ft__cs--1 { opacity: 1; } で別に書いて空白を防ぎ、04 は .dg:not(.is-demo) .dg__after { display: none; } を減速側でもう一度念押ししています。05 の選択の背景 rgba(47,109,246,.3) と aria-live の個数、06 の絞り込みとマーカー、07 のメニューの出現(ハイライトの位置だけ即時)、08 の名前変更、09 の最初から満ちたバーと aria-valuenow は、それぞれ動きを消しても働きます。07 は .cm.is-demo .cm__menu { display: none; } でメニューが開いたまま固まるのを防ぎ、08 は元の名前を戻して見せかけの入力欄を片付けます。
ツリーのロールと属性の定義は MDN の tree ロールの解説にまとまっています。フォーカスを戻すときに focus({ preventScroll: true }) を通すのは、これらの部品が一覧ページで iframe に埋め込まれており、素の focus() だと読んでいる人の画面が勝手にスクロールしてしまうためです。ツリーが置かれる場所そのものの作りはサイドバー・ドロワーの記事に続きます。
FAQ
フォルダの開閉を height ではなく grid-template-rows で作るのはなぜですか
height: auto はアニメーションできないため、子の行数が分からないと目標値も書けません。grid-template-rows: 0fr → 1fr は目標値が割合なので行数を知らなくても畳め、min-height: 0 と overflow: hidden の2行で完成します。01・02・04 の3つがこの作りです。
9つともマウスなしで使えますか
使えます。9つとも一覧に tabindex="0" が付いていて Tab で届き、01・02・03・04・05・09 は上下の矢印キーで行を移れます。両端へ一気に飛ぶ Home と End が効くのは 01・02・03 の3つで、01 は左矢印で開いたフォルダを閉じ、05 は Space で選び外しができます。04 はファイルの行で Enter を押してから矢印キーで移動先のフォルダを選んで Escape で取り消し、06 は / キーで検索欄に入り、08 は F2 で名前を直せます。
zip はビルドなしで動きますか
動きます。vanilla の各フォルダに index.html・style.scss・コンパイル済みの style.css が入っており、index.html をブラウザで開くだけでその番号のデモが動きます。react/ には同じ9つの React 版が入っています。