Display

Lazy Branch Tree

A tree whose branch loads on first expansion rather than on render: while the request runs the branch is marked aria-busy and holds its space with three placeholder rows.

  • tree
  • lazy
  • loading
  • 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-007?lang=en

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-007" (Lazy Branch 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-007.json

Registry item: https://vibeui.ru/r/tree-007.json
Installs to: components/vibeui/tree-007.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 tree whose branch loads on first expansion rather than on render: while the request runs the branch is marked aria-busy and holds its space with three placeholder rows.

A tree with lazy branch loading: the request fires on first expansion, the result is cached, and the wait is marked with aria-busy and placeholders. Zero dependencies, one file, client component.

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

<Tree007
  label="Services"
  nodes={[{ name: "Production cluster", lazy: ["api-gateway", "auth-service"] }]}
  delay={900}
/>

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-007-* palette — do not swap it for your theme tokens
- aria-busy on the loading branch: without it the wait is never announced
- the placeholders during the request — without them the tree jumps when the data lands
- the cache of loaded children: refetching on every expansion hits the network for nothing
- clearing the timers on unmount, otherwise state updates target a node that is gone
- its own light surface: without it the dark captions disappear on a dark background

## 6. You may change
- the tree contents and lazy branches through nodes
- the tree caption through label
- the simulated request delay through delay
- the count and widths of the placeholders in CSS

## 7. Rules
- The delay here fakes the network with setTimeout: in production replace it with your request and error handling.
- There is no failure state — add one with a retry affordance.
- Lazy branches are first level only in this variant: nested lazy loading needs recursion.
- 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-007.json
https://vibeui.ru/r/tree-007.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-tree-007-*. Клиентский: множества раскрытых и загружаемых веток плюс кэш пришедших детей живут в useState, таймеры имитации запроса собираются в ref и снимаются при размонтировании. Ветка с полем lazy грузится один раз: результат кладётся в кэш, и повторное раскрытие уже ничего не запрашивает. На время запроса ветка получает aria-busy и группу из трёх заглушек — без них дерево прыгает на высоту пришедшего списка. Строка ветки — настоящая кнопка, поэтому раскрытие работает с клавиатуры без своего обработчика клавиш.

Component source

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

"use client"

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

export type Tree007Node = {
  name: string
  /** Дети, которые «лежат на сервере»: подгружаются при первом раскрытии. */
  lazy?: string[]
  children?: Tree007Node[]
}

export type Tree007Props = {
  nodes?: Tree007Node[]
  label?: string
  /** Задержка имитации запроса в миллисекундах. */
  delay?: number
  className?: string
  style?: CSSProperties
}

// Идея компонента: ветка подгружается при первом раскрытии, а не при показе
// дерева. Пока идёт запрос, ветка помечена aria-busy и держит место тремя
// строками-заглушками: без них дерево прыгает на высоту пришедшего списка.
// Результат кладётся в кэш, поэтому повторное раскрытие уже не грузит ничего.
const STYLES = `
:where([data-vibeui-block="tree-007"]){
--vibeui-tree-007-bg:oklch(1 0 0);
--vibeui-tree-007-fg:oklch(0.24 0.014 265);
--vibeui-tree-007-muted:oklch(0.56 0.014 265);
--vibeui-tree-007-border:oklch(0.9 0.006 265);
--vibeui-tree-007-hover:oklch(0.97 0.004 265);
--vibeui-tree-007-skeleton:oklch(0.93 0.005 265);
--vibeui-tree-007-accent:oklch(0.53 0.19 265);
--vibeui-tree-007-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="tree-007"]{
width:100%;max-width:21rem;box-sizing:border-box;padding:0.625rem;
background:var(--vibeui-tree-007-bg);
border:1px solid var(--vibeui-tree-007-border);border-radius:0.875rem;
font-family:var(--vibeui-tree-007-font);font-size:0.8125rem;
color:var(--vibeui-tree-007-fg);
}
[data-vibeui-block="tree-007"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="tree-007"] [role="group"]{
margin-left:0.5625rem;padding-left:0.5rem;
border-left:1px solid var(--vibeui-tree-007-border);
}
[data-vibeui-block="tree-007"] [data-part="row"]{
appearance:none;border:0;background:none;cursor:pointer;
box-sizing:border-box;width:100%;
display:flex;align-items:center;gap:0.4375rem;
min-height:1.875rem;padding:0 0.5rem;border-radius:0.5rem;
font:inherit;color:inherit;text-align:left;
}
[data-vibeui-block="tree-007"] [data-part="row"]:hover{background:var(--vibeui-tree-007-hover)}
[data-vibeui-block="tree-007"] [data-part="row"]:focus-visible{
outline:2px solid var(--vibeui-tree-007-accent);outline-offset:-2px;
}
[data-vibeui-block="tree-007"] [data-part="caret"]{
flex:none;width:0.6875rem;text-align:center;
color:var(--vibeui-tree-007-muted);font-size:0.5625rem;line-height:1;
transition:transform .14s ease;
}
[data-vibeui-block="tree-007"] li[aria-expanded="true"] > [data-part="row"] [data-part="caret"]{
transform:rotate(90deg);
}
[data-vibeui-block="tree-007"] [data-part="name"]{
flex:1 1 auto;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="tree-007"] li[data-branch="true"] > [data-part="row"] [data-part="name"]{
font-weight:650;
}
[data-vibeui-block="tree-007"] [data-part="leaf"]{
display:flex;align-items:center;gap:0.4375rem;
min-height:1.75rem;padding:0 0.5rem;border-radius:0.5rem;
}
[data-vibeui-block="tree-007"] [data-part="dot"]{
flex:none;width:0.3125rem;height:0.3125rem;border-radius:9999px;
background:var(--vibeui-tree-007-muted);
}
/* Заглушки держат высоту ветки: иначе дерево прыгает на приходе данных. */
[data-vibeui-block="tree-007"] [data-part="ghost"]{
display:block;height:0.625rem;margin:0.5rem 0.5rem;border-radius:0.25rem;
background:var(--vibeui-tree-007-skeleton);
animation:vibeui-tree-007-pulse 1.2s ease-in-out infinite;
}
[data-vibeui-block="tree-007"] [data-part="ghost"]:nth-child(1){width:70%}
[data-vibeui-block="tree-007"] [data-part="ghost"]:nth-child(2){width:52%;animation-delay:.12s}
[data-vibeui-block="tree-007"] [data-part="ghost"]:nth-child(3){width:61%;animation-delay:.24s}
@keyframes vibeui-tree-007-pulse{
0%,100%{opacity:1}
50%{opacity:.45}
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="tree-007"] *{animation:none!important;transition:none!important}
}
`

const DEFAULT_NODES: Tree007Node[] = [
  {
    name: "Основной кластер",
    lazy: ["api-gateway", "auth-service", "billing-service", "search-service"],
  },
  {
    name: "Тестовый кластер",
    lazy: ["api-gateway", "mock-payments"],
  },
  {
    name: "Архив",
    children: [{ name: "legacy-monolith" }],
  },
]

/**
 * Дерево с ленивой подгрузкой ветки при первом раскрытии.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Tree007({
  nodes = DEFAULT_NODES,
  label = "Сервисы",
  delay = 900,
  className,
  style,
}: Tree007Props) {
  const [open, setOpen] = useState<Set<string>>(() => new Set())
  const [loading, setLoading] = useState<Set<string>>(() => new Set())
  const [loaded, setLoaded] = useState<Record<string, string[]>>({})
  const timers = useRef<number[]>([])

  useEffect(() => {
    const pending = timers.current

    return () => {
      for (const timer of pending) {
        window.clearTimeout(timer)
      }
    }
  }, [])

  function toggle(node: Tree007Node) {
    const id = node.name
    const isOpen = open.has(id)

    setOpen((previous) => {
      const next = new Set(previous)

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

      return next
    })

    // Кэш: повторное раскрытие уже ничего не запрашивает.
    if (isOpen || !node.lazy || loaded[id]) {
      return
    }

    setLoading((previous) => new Set(previous).add(id))

    const timer = window.setTimeout(
      () => {
        setLoaded((previous) => ({ ...previous, [id]: node.lazy ?? [] }))
        setLoading((previous) => {
          const next = new Set(previous)
          next.delete(id)
          return next
        })
      },
      Math.max(0, delay),
    )

    timers.current.push(timer)
  }

  return (
    <>
      <style href="vibeui-tree-007" precedence="medium">
        {STYLES}
      </style>
      <div data-vibeui-block="tree-007" className={className} style={style}>
        <ul role="tree" aria-label={label}>
          {nodes.map((node) => {
            const id = node.name
            const expanded = open.has(id)
            const busy = loading.has(id)
            const children =
              loaded[id]?.map((name) => ({ name })) ?? node.children ?? []
            const branch = Boolean(node.lazy?.length || node.children?.length)

            return (
              <li
                key={id}
                role="treeitem"
                aria-level={1}
                aria-expanded={branch ? expanded : undefined}
                aria-busy={busy || undefined}
                aria-selected={false}
                data-branch={branch || undefined}
              >
                <button
                  type="button"
                  data-part="row"
                  onClick={() => toggle(node)}
                >
                  <span data-part="caret" aria-hidden="true">
                    ▶
                  </span>
                  <span data-part="name">{node.name}</span>
                </button>
                {expanded ? (
                  busy ? (
                    <div role="group" aria-label={`${node.name}: загрузка`}>
                      <span data-part="ghost" />
                      <span data-part="ghost" />
                      <span data-part="ghost" />
                    </div>
                  ) : (
                    <ul role="group" aria-label={node.name}>
                      {children.map((child) => (
                        <li
                          key={child.name}
                          role="treeitem"
                          aria-level={2}
                          aria-selected={false}
                          data-part="leaf"
                        >
                          <span data-part="dot" aria-hidden="true" />
                          <span data-part="name">{child.name}</span>
                        </li>
                      ))}
                    </ul>
                  )
                ) : null}
              </li>
            )
          })}
        </ul>
      </div>
    </>
  )
}