# File Tree (Moonarc)

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.

- Import: `import FileTree from '@moonarc/core/FileTree'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/file-tree.json`
- Tier A · category ui · trigger click
- Readout: `<FileTree open={["src"]}>`
- Browser support: newly (Chrome 131 · Firefox 143 · Safari 18.4); elsewhere: folders open at once
- Measured cost: 0 B JS (CSS 6.5 kB raw)
- Page: https://moonarc.dev/components/file-tree/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `tree` | `{ name: string; children?: Node[]; highlight?: boolean; open?: boolean }[]` | none | The tree; a node with children is a folder. |
| `open` | `string[]` | `[]` | Paths that start open, joined with "/" (src/pages). |
| `highlight` | `string[]` | `[]` | Paths to light up. |
| `label` | `string` | `'Files'` | Accessible name of the tree. |

## Usage

```astro
<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' },
  ]}
/>
```

## 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.

## Craft

- Native <details> per folder: click, Enter, Space, and the open attribute are the browser's, at every depth.
- The height comes from ::details-content as a grid row interpolating 0fr → 1fr. Nothing is measured, and nested folders animate independently because each has its own row.
- The folder glyph is two paths; the flap skews and shortens from its bottom edge when open, so the icon tells the state without a second icon set.
- Nested lists sit behind a hairline guide, indented by one row, so depth reads at a glance in a docs sidebar. Indents, the guide and the highlight dot are logical properties, and under a dir="rtl" ancestor the closed chevron points left and turns the other way to open, so a right-to-left page gets the tree the other way round. The selector reads the attribute because 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.
- The semantics are nested lists and a group label. role="tree" is not claimed because the arrow-key navigation the role promises is not provided.

## Replaces

- FileTree (Magic UI)
- Tree (shadcn / Radix)
- FileTree (Fumadocs)

## Source

```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>

```
