Skip to content

Components / UI

File Tree

A project tree whose folders open with an animated height, a turning chevron and a folder glyph that tips open; rows can be lit up to say "this is the file you change". Zero JavaScript: folders are native <details> and the height is ::details-content laid out as a grid row from 0fr to 1fr, the Accordion technique nested as deep as the tree goes.

Live demo

  • src
    • components
      • Hero.astro
      • Nav.astro
    • layouts
      • Base.astro
    • pages
      • index.astro
      • work.astro
  • astro.config.mjs
  • package.json

Live, from the library itself: scroll, hover, navigate away and back. The demo is never gated.

Measured

JavaScript of its own

0 B

CSS 6.5 kB raw including the base tokens. Measured from a production build, and again in CI for every change that can move it.

Browser support

Baseline · newly available

Chrome 131 · Firefox 143 · Safari 18.4; needs details-content, grid-rows-animation. Elsewhere: folders open at once.

Install

One command adds the integration, the base tokens and every component. Then import what you use.

npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc
src/pages/index.astro
---
import FileTree from '@moonarc/core/FileTree';
---
<FileTree
  open={['src', 'src/components']}
  highlight={['src/components/Reveal.astro']}
  tree={[
    { name: 'src', children: [
      { name: 'components', children: [{ name: 'Reveal.astro' }, { name: 'Marquee.astro' }] },
      { name: 'pages', children: [{ name: 'index.astro' }] },
    ] },
    { name: 'astro.config.mjs' },
    { name: 'package.json' },
  ]}
/>
Copy it into your project instead (shadcn registry)

Owns the file, no dependency. The registry item also installs the base tokens.

terminal
npx shadcn@latest add https://moonarc.dev/r/file-tree.json

The CLI needs a components.json and the @/* alias, which Setup has. The file lands in src/components/moonarc/.

Source

The whole component. Self-contained styles in a cascade layer so your classes always win; it imports nothing.

FileTree.astro
---
/**
 * FileTree — a project tree of folders that open with an animated height,
 * zero JS.
 *
 * Folders are native <details>: the click, the keyboard and the open state
 * are the browser's; the height animates through ::details-content laid
 * out as a grid whose only row goes 0fr → 1fr (the Accordion technique),
 * the chevron turns and the folder glyph opens. `open` lists the paths to
 * start open; `highlight` the files to light up ("this is the one you
 * change"). Semantics are nested lists — role="tree" is not claimed because
 * arrow-key navigation is not provided. Renders itself for each level.
 */
import type { HTMLAttributes } from 'astro/types';

export interface Node {
  name: string;
  children?: Node[];
  /** Light this row up. */
  highlight?: boolean;
  /** Start open (folders); also settable through the `open` prop by path. */
  open?: boolean;
}

interface Props extends HTMLAttributes<'div'> {
  tree: Node[];
  /** Paths to start open, e.g. ['src', 'src/pages']. */
  open?: string[];
  /** Paths to light up, e.g. ['src/pages/index.astro']. */
  highlight?: string[];
  /** Accessible name of the tree. */
  label?: string;
  /** Internal: the path of this level. */
  path?: string;
  /** Internal: nested level. */
  nested?: boolean;
}

const { tree, open = [], highlight = [], label = 'Files', path = '', nested = false, class: className, ...rest } = Astro.props;
const join = (a: string, b: string) => (a ? `${a}/${b}` : b);
const Level = Astro.self;
---

{
  nested ? (
    <ul class="ma-ftree__list">
      {tree.map((n) => {
        const p = join(path, n.name);
        return n.children ? (
          <li class="ma-ftree__folder">
            <details class="ma-ftree__details" open={n.open || open.includes(p)}>
              <summary class="ma-ftree__row" data-highlight={n.highlight || highlight.includes(p) ? '' : undefined}>
                <svg class="ma-ftree__chevron" aria-hidden="true" viewBox="0 0 16 16" width="12" height="12"><path d="M6 4l4 4-4 4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" /></svg>
                <svg class="ma-ftree__glyph" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16">
                  <path class="ma-ftree__folder-back" d="M2 5.5A1.5 1.5 0 0 1 3.5 4h4l2 2h7A1.5 1.5 0 0 1 18 7.5v7A1.5 1.5 0 0 1 16.5 16h-13A1.5 1.5 0 0 1 2 14.5z" fill="currentColor" opacity="0.35" />
                  <path class="ma-ftree__folder-flap" d="M2 8.5h16v6A1.5 1.5 0 0 1 16.5 16h-13A1.5 1.5 0 0 1 2 14.5z" fill="currentColor" />
                </svg>
                <span class="ma-ftree__name">{n.name}</span>
              </summary>
              <div class="ma-ftree__body">
                <Level tree={n.children} open={open} highlight={highlight} path={p} nested />
              </div>
            </details>
          </li>
        ) : (
          <li class="ma-ftree__file">
            <span class="ma-ftree__row" data-highlight={n.highlight || highlight.includes(p) ? '' : undefined}>
              <svg class="ma-ftree__glyph" aria-hidden="true" viewBox="0 0 20 20" width="16" height="16"><path d="M5 2h7l4 4v11a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V3a1 1 0 0 1 1-1z" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /><path d="M12 2v4h4" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round" /></svg>
              <span class="ma-ftree__name">{n.name}</span>
            </span>
          </li>
        );
      })}
    </ul>
  ) : (
    <div class:list={['ma-ftree', className]} role="group" aria-label={label} {...rest}>
      <Level tree={tree} open={open} highlight={highlight} path={path} nested />
    </div>
  )
}

<style is:global>
  @layer components {
    :where(.ma-ftree) {
      font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
      font-size: 0.8125rem;
      line-height: 1.4;
    }
    :where(.ma-ftree__list) {
      margin: 0;
      padding: 0;
      list-style: none;
    }
    /* nested levels indent behind a guide line */
    :where(.ma-ftree__list .ma-ftree__list) {
      margin-inline-start: 0.8rem;
      padding-inline-start: 0.7rem;
      border-inline-start: 1px solid var(--ma-edge);
    }
    :where(.ma-ftree__row) {
      position: relative;
      display: flex;
      align-items: center;
      gap: 0.4rem;
      padding: 0.25rem 0.5rem;
      border-radius: 0.375rem;
      color: inherit;
      transition: background-color var(--ma-duration-fast) var(--ma-ease-out);
    }
    :where(.ma-ftree__file .ma-ftree__row) {
      padding-inline-start: 1.55rem;
    }
    :where(summary.ma-ftree__row) {
      cursor: pointer;
      list-style: none;
      font-weight: 500;
    }
    :where(summary.ma-ftree__row)::-webkit-details-marker {
      display: none;
    }
    :where(summary.ma-ftree__row:hover) {
      background: color-mix(in srgb, var(--ma-ink) 6%, transparent);
    }
    :where(summary.ma-ftree__row:focus-visible) {
      outline: 2px solid currentColor;
      outline-offset: -2px;
    }
    :where(.ma-ftree__glyph) {
      flex: none;
      opacity: 0.75;
    }
    :where(.ma-ftree__chevron) {
      flex: none;
      opacity: 0.55;
      transition: rotate var(--ma-duration) var(--ma-ease);
    }
    /* right to left the closed chevron points left, into the line it starts (a half turn of a symmetric glyph), and the
       open rule below turns it the other way to point down. [dir] and not :dir(): Chrome has :dir() only from 120, and a
       build that targets an older Chrome rewrites it into a guess from lang, which a page that sets only dir never
       matches. An ltr island one level inside an rtl page is set back */
    :where([dir='rtl'] .ma-ftree__chevron, .ma-ftree[dir='rtl'] .ma-ftree__chevron) {
      rotate: 180deg;
    }
    :where([dir='rtl'] [dir='ltr'] .ma-ftree__chevron, [dir='rtl'] .ma-ftree[dir='ltr'] .ma-ftree__chevron) {
      rotate: none;
    }
    :where(.ma-ftree__details[open] > .ma-ftree__row .ma-ftree__chevron) {
      rotate: 90deg;
    }
    /* the folder opens: the flap tips down from its bottom edge */
    :where(.ma-ftree__folder-flap) {
      transform-box: fill-box;
      transform-origin: 50% 100%;
      transition: transform var(--ma-duration) var(--ma-ease);
    }
    :where(.ma-ftree__details[open] > .ma-ftree__row .ma-ftree__folder-flap) {
      transform: skewX(-16deg) scaleY(0.82);
    }
    /* the animated part: the content box is a grid whose only row interpolates 0fr → 1fr */
    :where(.ma-ftree__details)::details-content {
      display: grid;
      grid-template-rows: 0fr;
      transition:
        grid-template-rows var(--ma-duration) var(--ma-ease),
        content-visibility var(--ma-duration) allow-discrete;
    }
    :where(.ma-ftree__details[open])::details-content {
      grid-template-rows: 1fr;
    }
    :where(.ma-ftree__body) {
      min-height: 0;
      overflow: clip;
    }
    /* the highlighted row: a tint and a dot in the accent, default the ink */
    :where(.ma-ftree__row[data-highlight]) {
      background: color-mix(in srgb, var(--ma-ftree-accent, var(--ma-ink)) 10%, transparent);
      color: var(--ma-ftree-accent, inherit);
      font-weight: 600;
    }
    :where(.ma-ftree__row[data-highlight])::after {
      content: '';
      position: absolute;
      top: 50%;
      inset-inline-end: 0.6rem;
      width: 6px;
      height: 6px;
      border-radius: 999px;
      background: var(--ma-ftree-accent, currentColor);
      translate: 0 -50%;
    }
    /* ::details-content in a rule of its own: in an engine that does not know it, a selector list that names it is
       dropped whole, and the chevron, the flap and the rows kept their transitions under reduced motion. The minifier merges
       neighbouring rules with the same declarations back into one list, so this one sits behind a feature test */
    @media (prefers-reduced-motion: reduce) {
      @supports selector(::details-content) {
        :where(.ma-ftree__details)::details-content {
          transition: none;
        }
      }
      :where(.ma-ftree__chevron),
      :where(.ma-ftree__folder-flap),
      :where(.ma-ftree__row) {
        transition: none;
      }
    }
  }
</style>

Props

PropTypeDefaultDescription
tree{ name: string; children?: Node[]; highlight?: boolean; open?: boolean }[]noneThe tree; a node with children is a folder.
openstring[][]Paths that start open, joined with "/" (src/pages).
highlightstring[][]Paths to light up.
labelstring'Files'Accessible name of the tree.

Reduced motion

Folders open at once; the chevron and the flap jump.

With ClientRouter

Native; nothing to rebind. The open state lives in the details elements and resets with the page.

Why it is built this way

Replaces: FileTree (Magic UI) · Tree (shadcn / Radix) · FileTree (Fumadocs). See the migration table.

Zero JavaScript?

For this one, yes. The browser animates the height.

Keyboard?

Native: Tab, Enter, Space.

One at a time?

details name=, also native.

Native <details> items whose height animates open and closed, with an optional one-at-a-time rule the browser enforces.

newly · Chrome 131 · Firefox 143 · Safari 18.4click
npx astro add moonarc
pnpm astro add moonarc
bunx astro add moonarc

A code block with tabs (for example npm, pnpm, bun) and a copy button per panel.

every browserclick

A documentation layout: a sticky sidebar with the current page marked, a reading-progress hairline bound to the scroll, the header and content revealed in two beats, and previous/next links.

Chrome 115 · not Firefox · Safari 26scroll

Also: view transitions · how costs are measured · browser support · accessibility policy · five-minute setup · MCP for agents · this page as Markdown