Display

Typed File Tree

A file tree where the type is visible before you read the name: the badge hue is hashed from the extension, so a new file type gets its own colour without a lookup table.

  • tree
  • files
  • icons
  • display

Preview

1440pxHost theme

Use it with AI

  1. 1. Copy the link.
  2. 2. Write to your agent in your own words and drop the link into the sentence.
  3. 3. The agent opens the link and installs the component from the registry.

put this in the header: https://vibeui.ru/c/tree-002?lang=en

  • app
  • layout.tsxM
  • page.tsx
  • globals.cssA
  • registry
  • index.ts
  • package.json
  • README.mdD
Show the full instructions

What the link says, spelled out. Use it when your agent cannot open links — paste this text instead.

# Install and place "tree-002" (Typed File Tree) from VibeUI

## 1. Install first — do not skip, do not recreate
Run this exact command before writing any code:
npx shadcn@latest add https://vibeui.ru/r/tree-002.json

Registry item: https://vibeui.ru/r/tree-002.json
Installs to: components/vibeui/tree-002.tsx (the exact path follows this project's components.json aliases).
npm dependencies: none.
Registry dependencies: none.

Install it from the registry. Do not recreate it from the description,
do not substitute a similar component from another library, and do not
rewrite it to match the project's existing style.

## 2. What it is
A file tree where the type is visible before you read the name: the badge hue is hashed from the extension, so a new file type gets its own colour without a lookup table.

A file tree with type badges and arrow-key focus movement: one tab stop, levels declared by attributes. Zero dependencies, one file, client component.

## 3. How to use it
import { Tree002 } from "@/components/vibeui/tree-002"

<Tree002
  label="Project files"
  nodes={[{ name: "app", open: true, children: [{ name: "page.tsx" }] }]}
/>

Read the installed file for the full prop list.

## 4. Where to place it
This is a small inline component. Put it exactly where the user asked,
inside the existing markup. Do not create a new page, section or
wrapper for it. If a similar control already sits in that spot,
replace it instead of adding a second one.

Placement: ___
(The user fills this line in. If it is still blank, ask where to put it
instead of guessing.)

## 5. Keep exactly as installed
- the local --vibeui-tree-002-* palette — do not swap it for your theme tokens
- aria-level together with aria-posinset and aria-setsize: in a flat list there is nothing else to declare structure with
- the tree and treeitem roles with aria-expanded on branches — without them it sounds like a plain list
- one tab stop for the tree: tabIndex=0 only on the current node, or Tab has to be pressed dozens of times
- the status letter alongside the colour — colour alone fails for colour blindness
- its own light surface: without it the dark file names disappear on a dark background

## 6. You may change
- the tree contents through nodes, including the open flag on branches
- the tree caption through label
- the hue formula inside the hue function to match your brand
- the level indent through the indent variable

## 7. Rules
- A node's identity is the path of names: two identical names in one folder collapse into one node.
- The badge shows the first two characters of the extension — not a full type name for long ones.
- The markup is flat: there is deliberately no role="group" wrapper, the attributes carry the level.
- Do not lift the CSS variables into globals.css: the component has to stay a single file.

## 8. Verify
- it renders with no console errors;
- it looks like the preview on the VibeUI page you copied this from;
- if it does not, you changed something listed in section 5 — put it back.

For developers

npx shadcn@latest add https://vibeui.ru/r/tree-002.json
https://vibeui.ru/r/tree-002.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-tree-002-*. Клиентский: раскрытие живёт в useState, а не в details, потому что нужны role="treeitem" и aria-expanded, которых у details нет. Видимые узлы разворачиваются в плоский список, отступ рисуется padding-left от уровня, а структуру объявляют aria-level, aria-posinset и aria-setsize — без них плоская разметка ничего не скажет скринридеру. Фокус ездит по узлам стрелками при одном таб-стопе на всё дерево: tabIndex=0 стоит только у текущего узла, остальные получают −1. Пометка состояния файла показана буквой, а не только цветом.

Component source

The same file your agent installs. Here in case you would rather copy it by hand.

"use client"

import { useEffect, useMemo, useRef, useState } from "react"
import type { CSSProperties, KeyboardEvent } from "react"

export type Tree002Node = {
  name: string
  children?: Tree002Node[]
  open?: boolean
  /** Пометка состояния файла: M — изменён, A — добавлен, D — удалён. */
  status?: "M" | "A" | "D"
}

export type Tree002Props = {
  nodes?: Tree002Node[]
  label?: string
  className?: string
  style?: CSSProperties
}

// Идея компонента: дерево файлов, где тип виден до чтения имени. Цвет значка
// считается из расширения хешем, поэтому новый тип файла получает свой оттенок
// сам, без таблицы соответствий. Раскрытием управляет состояние, а не details:
// нужны role="treeitem" и aria-expanded, которых у details нет. Фокус
// переезжает по узлам стрелками, в дереве всего один таб-стоп.
const STYLES = `
:where([data-vibeui-block="tree-002"]){
--vibeui-tree-002-bg:oklch(1 0 0);
--vibeui-tree-002-fg:oklch(0.24 0.014 265);
--vibeui-tree-002-muted:oklch(0.56 0.014 265);
--vibeui-tree-002-border:oklch(0.9 0.006 265);
--vibeui-tree-002-hover:oklch(0.97 0.003 265);
--vibeui-tree-002-accent:oklch(0.55 0.17 265);
--vibeui-tree-002-hue:265;
--vibeui-tree-002-level:1;
--vibeui-tree-002-indent:0.875rem;
--vibeui-tree-002-mono:ui-monospace,SFMono-Regular,Menlo,Consolas,"Liberation Mono",monospace;
}
[data-vibeui-block="tree-002"]{
width:100%;max-width:21rem;box-sizing:border-box;padding:0.5rem;
background:var(--vibeui-tree-002-bg);
border:1px solid var(--vibeui-tree-002-border);border-radius:0.875rem;
font-family:var(--vibeui-tree-002-mono);font-size:0.8125rem;
color:var(--vibeui-tree-002-fg);
}
[data-vibeui-block="tree-002"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="tree-002"] li{outline:none}
[data-vibeui-block="tree-002"] [data-part="row"]{
display:flex;align-items:center;gap:0.4375rem;
min-height:1.875rem;border-radius:0.4375rem;
padding-right:0.4375rem;
padding-left:calc(0.375rem + (var(--vibeui-tree-002-level) - 1) * var(--vibeui-tree-002-indent));
cursor:pointer;
}
[data-vibeui-block="tree-002"] [data-part="row"]:hover{background:var(--vibeui-tree-002-hover)}
[data-vibeui-block="tree-002"] li:focus-visible > [data-part="row"]{
outline:2px solid var(--vibeui-tree-002-accent);outline-offset:-2px;
}
[data-vibeui-block="tree-002"] [data-part="caret"]{
flex:none;width:0.75rem;display:grid;place-items:center;
color:var(--vibeui-tree-002-muted);font-size:0.5625rem;line-height:1;
transition:transform .14s ease;
}
[data-vibeui-block="tree-002"] li[aria-expanded="true"] > [data-part="row"] [data-part="caret"]{
transform:rotate(90deg);
}
/* Значок типа: оттенок считается из расширения, таблицы соответствий нет. */
[data-vibeui-block="tree-002"] [data-part="badge"]{
flex:none;display:grid;place-items:center;
width:1.125rem;height:1.125rem;border-radius:0.3125rem;
background:oklch(0.93 0.06 var(--vibeui-tree-002-hue));
color:oklch(0.4 0.14 var(--vibeui-tree-002-hue));
font-size:0.5rem;font-weight:800;letter-spacing:0.02em;
}
[data-vibeui-block="tree-002"] [data-part="badge"][data-kind="folder"]{
background:oklch(0.94 0.05 85);color:oklch(0.45 0.11 75);
}
[data-vibeui-block="tree-002"] [data-part="name"]{
flex:1 1 auto;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
/* Состояние файла буквой, а не только цветом: цвет не читается без зрения. */
[data-vibeui-block="tree-002"] [data-part="status"]{
flex:none;font-size:0.6875rem;font-weight:700;
color:var(--vibeui-tree-002-muted);
}
[data-vibeui-block="tree-002"] [data-part="status"][data-value="M"]{color:oklch(0.55 0.14 75)}
[data-vibeui-block="tree-002"] [data-part="status"][data-value="A"]{color:oklch(0.52 0.14 155)}
[data-vibeui-block="tree-002"] [data-part="status"][data-value="D"]{color:oklch(0.55 0.17 28)}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="tree-002"] *{animation:none!important;transition:none!important}
}
`

const DEFAULT_NODES: Tree002Node[] = [
  {
    name: "app",
    open: true,
    children: [
      { name: "layout.tsx", status: "M" },
      { name: "page.tsx" },
      { name: "globals.css", status: "A" },
    ],
  },
  {
    name: "registry",
    open: true,
    children: [
      {
        name: "components",
        children: [{ name: "tree-002.tsx" }, { name: "registry.json" }],
      },
      { name: "index.ts" },
    ],
  },
  { name: "package.json" },
  { name: "README.md", status: "D" },
]

function hue(name: string) {
  let hash = 2166136261

  for (const symbol of name) {
    hash ^= symbol.codePointAt(0)!
    hash = Math.imul(hash, 16777619)
  }

  return ((hash >>> 0) % 12) * 30
}

type Flat = {
  id: string
  node: Tree002Node
  level: number
  parent: string | null
  branch: boolean
  position: number
  size: number
}

// Список рисуется плоским, поэтому уровень и место в ветке приходится
// объявлять атрибутами: без них скринридер не расскажет структуру.
function flatten(
  nodes: Tree002Node[],
  open: Set<string>,
  level = 1,
  parent: string | null = null,
  out: Flat[] = [],
) {
  nodes.forEach((node, index) => {
    const id = `${parent ?? ""}/${node.name}`
    const branch = Boolean(node.children?.length)

    out.push({
      id,
      node,
      level,
      parent,
      branch,
      position: index + 1,
      size: nodes.length,
    })

    if (branch && open.has(id)) {
      flatten(node.children!, open, level + 1, id, out)
    }
  })

  return out
}

function collectOpen(
  nodes: Tree002Node[],
  parent = "",
  into = new Set<string>(),
) {
  for (const node of nodes) {
    const id = `${parent}/${node.name}`

    if (node.open) {
      into.add(id)
    }

    if (node.children?.length) {
      collectOpen(node.children, id, into)
    }
  }

  return into
}

/**
 * Дерево файлов с типовыми значками и переездом фокуса стрелками.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Tree002({
  nodes = DEFAULT_NODES,
  label = "Файлы проекта",
  className,
  style,
}: Tree002Props) {
  const [open, setOpen] = useState(() => collectOpen(nodes))
  const rows = useMemo(() => flatten(nodes, open), [nodes, open])
  const [active, setActive] = useState(() => rows[0]?.id ?? "")
  const elements = useRef(new Map<string, HTMLLIElement>())
  const moved = useRef(false)

  useEffect(() => {
    if (moved.current) {
      elements.current.get(active)?.focus()
      moved.current = false
    }
  }, [active])

  const current = rows.findIndex((row) => row.id === active)
  const focused = current >= 0 ? current : 0

  function move(index: number) {
    const next = rows[Math.min(rows.length - 1, Math.max(0, index))]

    if (next) {
      moved.current = true
      setActive(next.id)
    }
  }

  function toggle(id: string, force?: boolean) {
    setOpen((previous) => {
      const next = new Set(previous)
      const shouldOpen = force ?? !next.has(id)

      if (shouldOpen) {
        next.add(id)
      } else {
        next.delete(id)
      }

      return next
    })
  }

  function onKeyDown(event: KeyboardEvent<HTMLLIElement>, row: Flat) {
    if (event.key === "ArrowDown") {
      event.preventDefault()
      move(focused + 1)
    } else if (event.key === "ArrowUp") {
      event.preventDefault()
      move(focused - 1)
    } else if (event.key === "ArrowRight") {
      event.preventDefault()

      if (row.branch && !open.has(row.id)) {
        toggle(row.id, true)
      } else if (row.branch) {
        move(focused + 1)
      }
    } else if (event.key === "ArrowLeft") {
      event.preventDefault()

      if (row.branch && open.has(row.id)) {
        toggle(row.id, false)
      } else if (row.parent) {
        move(rows.findIndex((entry) => entry.id === row.parent))
      }
    } else if (event.key === "Enter" || event.key === " ") {
      event.preventDefault()

      if (row.branch) {
        toggle(row.id)
      }
    }
  }

  return (
    <>
      <style href="vibeui-tree-002" precedence="medium">
        {STYLES}
      </style>
      <div data-vibeui-block="tree-002" className={className} style={style}>
        <ul role="tree" aria-label={label}>
          {rows.map((row, index) => {
            const extension = row.branch
              ? "folder"
              : (row.node.name.split(".").pop() ?? "file")

            return (
              <li
                key={row.id}
                role="treeitem"
                aria-level={row.level}
                aria-posinset={row.position}
                aria-setsize={row.size}
                aria-expanded={row.branch ? open.has(row.id) : undefined}
                aria-selected={false}
                tabIndex={index === focused ? 0 : -1}
                ref={(element) => {
                  if (element) {
                    elements.current.set(row.id, element)
                  } else {
                    elements.current.delete(row.id)
                  }
                }}
                onKeyDown={(event) => onKeyDown(event, row)}
                onFocus={() => setActive(row.id)}
                style={
                  {
                    "--vibeui-tree-002-level": row.level,
                    "--vibeui-tree-002-hue": hue(extension),
                  } as CSSProperties
                }
              >
                <span
                  data-part="row"
                  onClick={() => row.branch && toggle(row.id)}
                >
                  <span data-part="caret" aria-hidden="true">
                    {row.branch ? "▶" : ""}
                  </span>
                  <span
                    data-part="badge"
                    data-kind={row.branch ? "folder" : "file"}
                    aria-hidden="true"
                  >
                    {row.branch ? "/" : extension.slice(0, 2).toUpperCase()}
                  </span>
                  <span data-part="name">{row.node.name}</span>
                  {row.node.status ? (
                    <span data-part="status" data-value={row.node.status}>
                      {row.node.status}
                    </span>
                  ) : null}
                </span>
              </li>
            )
          })}
        </ul>
      </div>
    </>
  )
}