Component
Tree view
A hierarchical list with expandable nodes and arrow key navigation.
- #arc
- #component
- #tree
- #hierarchy
- #navigation
- #expand
- #list
1280px
Install
One command installs the files and packages this item needs.
TerminalTerminalTerminalbash
npx shadcn@latest add https://dsui.vercel.app/r/tree-view.jsonFiles
components/arc/tree-view/tree-view.module.csscomponents/arc/tree-view/tree-view.tsx
npm packages
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
Registry items it pulls in
Source
View as Markdowntree-view.module.csstree-view.module.csstree-view.module.csscss
.tree { display: block; width: 100%; min-width: 0; box-sizing: border-box; padding: 6px 8px 8px; border: 1px solid var(--border); border-radius: 16px; background: var(--surface); color: var(--foreground); overflow: hidden; }
/* Rows clip only while their height animates (Motion sets overflow), so the selection can glide between them unclipped. */
.row { position: relative; display: flow-root; min-width: 0; height: 38px; }
.item { position: relative; display: flex; width: 100%; min-width: 0; height: 36px; margin-top: 2px; align-items: center; gap: 8px; padding: 0 10px; padding-left: calc(10px + (var(--tree-depth) - 1) * 18px); border: 0; border-radius: 10px; background: transparent; color: var(--text-secondary); font: inherit; font-size: var(--text-sm); line-height: 1; text-align: left; cursor: pointer; outline: none; transition: color var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard); }
/* Selection changes color only; a heavier weight would reflow the label. */
.item[aria-selected="true"] { color: var(--foreground); }
.selection { position: absolute; inset: 0; z-index: 0; border-radius: 10px; background: var(--surface-muted); }
.branch { position: absolute; top: 4px; bottom: 4px; left: calc(16.5px + (var(--tree-depth) - 2) * 18px); z-index: 1; width: 1px; background: var(--border); transform-origin: top; pointer-events: none; }
.disclosure, .icon, .label { position: relative; z-index: 1; }
.disclosure { display: grid; width: 14px; height: 16px; flex: 0 0 14px; place-items: center; color: var(--text-muted); }
.icon { display: grid; width: 16px; height: 16px; flex: 0 0 16px; place-items: center; color: var(--text-muted); transition: color var(--duration-fast) var(--ease-standard); }
.folder { color: var(--text-secondary); }
.item[aria-expanded="true"] .folder, .item[aria-selected="true"] .folder { color: var(--foreground); }
.folderGlyph { position: absolute; inset: 0; display: grid; place-items: center; }
.typescript { color: color-mix(in oklab, #3178c6 78%, var(--foreground)); }
.stylesheet { color: color-mix(in oklab, #8b6ace 73%, var(--foreground)); }
.markdown { color: color-mix(in oklab, #38896b 74%, var(--foreground)); }
.json { color: color-mix(in oklab, #c48637 78%, var(--foreground)); }
/* Its own line height: the row sets 1, and the clipped box (needed for the ellipsis) would cut descenders like g. */
.label { min-width: 0; overflow: hidden; line-height: 1.45; text-overflow: ellipsis; white-space: nowrap; }
@media (hover: hover) and (pointer: fine) { .item:hover { background: var(--surface-muted); color: var(--foreground); } }
@media (prefers-reduced-motion: reduce) { .item, .icon { transition: none; } }
tree-view.tsxtree-view.tsxtree-view.tsxtsx
"use client";
import { useId, useRef, useState } from "react";
import type { CSSProperties, KeyboardEvent, ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Folder01Icon, FolderOpenIcon } from "@hugeicons/core-free-icons";
import { AnimatePresence, motion, useIsPresent } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./tree-view.module.css";
import { playUiSound } from "../sound/play";
import { UiIcon } from "../theme/ui-icon";
export interface TreeNode {
id: string;
label: string;
children?: TreeNode[];
icon?: ReactNode;
}
export interface TreeViewProps {
nodes: TreeNode[];
defaultExpandedIds?: string[];
expandedIds?: string[];
onExpandedChange?: (expandedIds: string[]) => void;
onSelect?: (node: TreeNode) => void;
"aria-label"?: string;
}
type VisibleNode = {
node: TreeNode;
depth: number;
parentId?: string;
position: number;
setSize: number;
};
/** A row is 36px plus the 2px gap above it; both collapse together, so nothing snaps when a row leaves. */
const rowHeight = 38;
const still = { duration: 0 } as const;
const fadeIn = { duration: motionTokens.duration.fast, ease: [...motionTokens.ease.enter] } as const;
const fadeOut = { duration: motionTokens.duration.instant, ease: [...motionTokens.ease.standard] } as const;
const blur = (px: number) => `blur(${px}px)`;
function flatten(nodes: TreeNode[], expanded: ReadonlySet<string>, depth = 1, parentId?: string): VisibleNode[] {
return nodes.flatMap((node, index) => [
{ node, depth, parentId, position: index + 1, setSize: nodes.length },
...(node.children && expanded.has(node.id) ? flatten(node.children, expanded, depth + 1, node.id) : []),
]);
}
function fileTone(label: string, classNames: Record<string, string>) {
const name = label.toLowerCase();
if (name.endsWith(".tsx") || name.endsWith(".ts")) return classNames.typescript;
if (name.endsWith(".css")) return classNames.stylesheet;
if (name.endsWith(".md")) return classNames.markdown;
if (name.endsWith(".json")) return classNames.json;
return classNames.file;
}
/** Same markup with or without reduced motion, so the server render always hydrates cleanly; only the transition changes. */
function FolderIcon({ open, reduced }: { open: boolean; reduced: boolean }) {
return <AnimatePresence initial={false} mode="popLayout">
<motion.span
key={open ? "open" : "closed"}
className={styles.folderGlyph}
initial={reduced ? false : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle) }}
animate={{ opacity: 1, scale: 1, filter: blur(0) }}
exit={reduced ? { opacity: 0, transition: still } : { opacity: 0, scale: .6, filter: blur(motionTokens.blur.subtle), transition: fadeOut }}
transition={reduced ? still : { ...motionTokens.spring.snappy, opacity: fadeIn, filter: fadeIn }}
>{open ? <HugeiconsIcon icon={FolderOpenIcon} size={16} strokeWidth={1.7} /> : <HugeiconsIcon icon={Folder01Icon} size={16} strokeWidth={1.7} />}</motion.span>
</AnimatePresence>;
}
/**
* The selection fill glides between rows through a shared layoutId. A row that is leaving drops its fill at once: left
* in place, it held the row's exit open, so the row stayed in the tree with pointer events off and the next click on it
* after reopening its folder did nothing.
*/
function Selection({ layoutId, reduced }: { layoutId: string; reduced: boolean }) {
const present = useIsPresent();
if (!present) return null;
return <motion.span aria-hidden="true" layoutId={layoutId} className={styles.selection} transition={reduced ? still : motionTokens.spring.morph} />;
}
export function TreeView({ nodes, defaultExpandedIds = [], expandedIds, onExpandedChange, onSelect, "aria-label": ariaLabel = "파일 목록" }: TreeViewProps) {
const reduced = useReducedMotion() ?? false;
const selectionLayoutId = `tree-selection-${useId()}`;
const [internalExpanded, setInternalExpanded] = useState(() => new Set(defaultExpandedIds));
const [focusedId, setFocusedId] = useState<string | null>(nodes[0]?.id ?? null);
const [selectedId, setSelectedId] = useState<string | null>(null);
/** The folder that opened last; its new children fade in one after another, then everything else stays still. */
const [openedId, setOpenedId] = useState<string | null>(null);
const refs = useRef(new Map<string, HTMLButtonElement>());
const expanded = expandedIds ? new Set(expandedIds) : internalExpanded;
const visible = flatten(nodes, expanded);
const focusableId = visible.some(({ node }) => node.id === focusedId) ? focusedId : (visible[0]?.node.id ?? null);
const openedIndex = visible.findIndex(({ node }) => node.id === openedId);
function setExpanded(next: Set<string>) {
if (!expandedIds) setInternalExpanded(next);
onExpandedChange?.([...next]);
}
function toggle(node: TreeNode) {
if (!node.children?.length) return;
playUiSound("tick");
const next = new Set(expanded);
if (next.has(node.id)) next.delete(node.id); else { next.add(node.id); setOpenedId(node.id); }
setExpanded(next);
}
function focus(id: string) {
setFocusedId(id);
const row = refs.current.get(id);
if (row) row.focus();
else requestAnimationFrame(() => refs.current.get(id)?.focus());
}
function select(node: TreeNode) {
setSelectedId(node.id);
onSelect?.(node);
}
function onKeyDown(event: KeyboardEvent<HTMLButtonElement>, item: VisibleNode, index: number) {
const { node } = item;
if (event.key === "ArrowDown") { event.preventDefault(); focus(visible[Math.min(index + 1, visible.length - 1)]!.node.id); return; }
if (event.key === "ArrowUp") { event.preventDefault(); focus(visible[Math.max(index - 1, 0)]!.node.id); return; }
if (event.key === "Home") { event.preventDefault(); focus(visible[0]!.node.id); return; }
if (event.key === "End") { event.preventDefault(); focus(visible[visible.length - 1]!.node.id); return; }
if (event.key === "ArrowRight" && node.children?.length) {
event.preventDefault();
if (!expanded.has(node.id)) toggle(node);
else if (visible[index + 1]?.parentId === node.id) focus(visible[index + 1]!.node.id);
return;
}
if (event.key === "ArrowLeft") {
event.preventDefault();
if (node.children?.length && expanded.has(node.id)) toggle(node);
else if (item.parentId) focus(item.parentId);
return;
}
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
select(node);
if (node.children?.length) toggle(node);
}
}
return <div className={styles.tree} role="tree" aria-label={ariaLabel} aria-multiselectable="false">
<AnimatePresence initial={false} mode="sync">
{visible.map((item, index) => {
const hasChildren = Boolean(item.node.children?.length);
const isExpanded = hasChildren && expanded.has(item.node.id);
const isSelected = selectedId === item.node.id;
// Only rows that mount use this delay: the space opens in one motion and the new children follow in order.
const delay = openedIndex >= 0 && index > openedIndex ? Math.min(index - openedIndex - 1, 7) * motionTokens.stagger.item : 0;
return <motion.div
key={item.node.id}
className={styles.row}
layout={reduced ? false : "position"}
initial={reduced ? false : { height: 0, opacity: 0, x: -6, overflow: "hidden" }}
animate={{ height: rowHeight, opacity: 1, x: 0, pointerEvents: "auto", transitionEnd: { overflow: "visible" }, transition: reduced ? still : { height: motionTokens.spring.smooth, opacity: { ...fadeIn, delay }, x: { ...motionTokens.spring.smooth, delay } } }}
exit={reduced ? { opacity: 0, transition: still } : { height: 0, opacity: 0, x: -4, overflow: "hidden", pointerEvents: "none", transition: { height: motionTokens.spring.smooth, opacity: fadeOut, x: fadeOut } }}
transition={reduced ? still : { layout: motionTokens.spring.smooth }}
>
<button
ref={element => { if (element) refs.current.set(item.node.id, element); else refs.current.delete(item.node.id); }}
type="button"
className={styles.item}
role="treeitem"
aria-level={item.depth}
aria-posinset={item.position}
aria-setsize={item.setSize}
aria-expanded={hasChildren ? isExpanded : undefined}
aria-selected={isSelected}
tabIndex={focusableId === item.node.id ? 0 : -1}
style={{ "--tree-depth": item.depth } as CSSProperties}
onFocus={() => setFocusedId(item.node.id)}
onKeyDown={event => onKeyDown(event, item, index)}
onClick={() => { setFocusedId(item.node.id); select(item.node); if (hasChildren) toggle(item.node); }}
>
{isSelected && <Selection layoutId={selectionLayoutId} reduced={reduced} />}
{item.depth > 1 && <motion.span
aria-hidden="true"
className={styles.branch}
initial={reduced ? false : { opacity: 0, scaleY: 0 }}
animate={{ opacity: 1, scaleY: 1 }}
transition={reduced ? still : { duration: motionTokens.duration.standard, ease: [...motionTokens.ease.enter], delay }}
/>}
<motion.span
className={styles.disclosure}
aria-hidden="true"
initial={false}
animate={{ rotate: isExpanded ? 90 : 0 }}
transition={reduced ? still : motionTokens.spring.snappy}
>{hasChildren ? <UiIcon iconRole="chevron-right" size={14} strokeWidth={1.9} /> : null}</motion.span>
<span className={[styles.icon, hasChildren ? styles.folder : fileTone(item.node.label, styles)].join(" ")} aria-hidden="true">
{item.node.icon ?? (hasChildren ? <FolderIcon open={isExpanded} reduced={reduced} /> : <UiIcon iconRole="file" size={16} strokeWidth={1.7} />)}
</span>
<span className={styles.label}>{item.node.label}</span>
</button>
</motion.div>;
})}
</AnimatePresence>
</div>;
}
export default TreeView;