Display

Selectable Keyboard Tree

A tree with a selected node and a full keyboard: arrows only move focus, Enter or Space selects, so the tree does not "select" everything you pass over.

  • tree
  • keyboard
  • selection
  • 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-004?lang=en

  • Продажи
    • Прямые продажи
    • Партнёры
      • Реселлеры
      • Интеграторы
  • Поддержка

Стрелки — переход, Enter или пробел — выбор

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-004" (Selectable Keyboard 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-004.json

Registry item: https://vibeui.ru/r/tree-004.json
Installs to: components/vibeui/tree-004.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 with a selected node and a full keyboard: arrows only move focus, Enter or Space selects, so the tree does not "select" everything you pass over.

A tree with node selection and APG-style keyboard support: arrows, Home and End over a flat traversal order, selection on Enter and Space. Zero dependencies, one file, client component.

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

<Tree004
  label="Business units"
  nodes={[{ name: "Sales", open: true, children: [{ name: "Partners" }] }]}
/>

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-004-* palette — do not swap it for your theme tokens
- keeping focus and selection separate: selecting with arrows would select everything on the way
- the flat traversal order for the keys: computing Down over the nested structure gets it wrong
- the tree, treeitem and group roles with aria-level and aria-expanded — all the speech rests on them
- the left bar on the selected row — the fill alone fails for colour blindness
- its own light surface: without it the dark captions 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 selected row colour through the palette variables
- the status line copy under the tree

## 7. Rules
- Space both selects and expands here: drop the toggle call if you only want selection.
- First-letter type-ahead is not implemented — worth adding for long trees.
- A node's identity is the path of names: two identical names in one branch collapse.
- 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-004.json
https://vibeui.ru/r/tree-004.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-tree-004-*. Клиентский: состояние раскрытия, фокуса и выбора живёт в useState, узлы держатся в ref-карте для программного focus(). Разметка вложенная — treeitem владеет своей группой, — а порядок обхода собирается отдельным плоским списком видимых узлов: по нему считаются стрелки вверх и вниз, Home и End. Стрелка вправо раскрывает ветку или спускается в неё, стрелка влево сворачивает или поднимается к родителю. Фокус и выбор разведены сознательно: aria-selected меняет только Enter и пробел. Выбранная строка помечена не только заливкой, но и полосой слева.

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 Tree004Node = {
  name: string
  children?: Tree004Node[]
  open?: boolean
}

export type Tree004Props = {
  nodes?: Tree004Node[]
  label?: string
  className?: string
  style?: CSSProperties
}

// Идея компонента: дерево с выбранным узлом и полной клавиатурой. Разметка
// вложенная — treeitem владеет своей группой, — а порядок обхода собирается
// отдельным плоским списком видимых узлов: по нему считаются стрелки, Home и
// End. Выбор и фокус разведены: стрелки только переносят фокус, выбирает
// Enter или пробел, поэтому дерево не «выбирает» всё, над чем пробежали.
const STYLES = `
:where([data-vibeui-block="tree-004"]){
--vibeui-tree-004-bg:oklch(1 0 0);
--vibeui-tree-004-fg:oklch(0.24 0.014 265);
--vibeui-tree-004-muted:oklch(0.56 0.014 265);
--vibeui-tree-004-border:oklch(0.9 0.006 265);
--vibeui-tree-004-hover:oklch(0.97 0.004 265);
--vibeui-tree-004-selected:oklch(0.94 0.04 265);
--vibeui-tree-004-accent:oklch(0.53 0.19 265);
--vibeui-tree-004-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="tree-004"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:21rem;box-sizing:border-box;padding:0.625rem;
background:var(--vibeui-tree-004-bg);
border:1px solid var(--vibeui-tree-004-border);border-radius:0.875rem;
font-family:var(--vibeui-tree-004-font);font-size:0.8125rem;
color:var(--vibeui-tree-004-fg);
}
[data-vibeui-block="tree-004"] ul{margin:0;padding:0;list-style:none}
[data-vibeui-block="tree-004"] [role="group"]{
margin-left:0.5625rem;padding-left:0.5rem;
border-left:1px solid var(--vibeui-tree-004-border);
}
[data-vibeui-block="tree-004"] li{outline:none}
[data-vibeui-block="tree-004"] [data-part="row"]{
display:flex;align-items:center;gap:0.4375rem;
min-height:1.875rem;padding:0 0.5rem;border-radius:0.5rem;cursor:pointer;
}
[data-vibeui-block="tree-004"] [data-part="row"]:hover{background:var(--vibeui-tree-004-hover)}
/* Выбор виден не только заливкой: слева встаёт полоса выбранного узла. */
[data-vibeui-block="tree-004"] li[aria-selected="true"] > [data-part="row"]{
background:var(--vibeui-tree-004-selected);font-weight:650;
box-shadow:inset 0.1875rem 0 0 var(--vibeui-tree-004-accent);
}
[data-vibeui-block="tree-004"] li:focus-visible > [data-part="row"]{
outline:2px solid var(--vibeui-tree-004-accent);outline-offset:-2px;
}
[data-vibeui-block="tree-004"] [data-part="caret"]{
flex:none;width:0.6875rem;text-align:center;
color:var(--vibeui-tree-004-muted);font-size:0.5625rem;line-height:1;
transition:transform .14s ease;
}
[data-vibeui-block="tree-004"] li[aria-expanded="true"] > [data-part="row"] [data-part="caret"]{
transform:rotate(90deg);
}
[data-vibeui-block="tree-004"] [data-part="name"]{
flex:1 1 auto;min-width:0;
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="tree-004"] [data-part="status"]{
margin:0;padding:0.375rem 0.5rem;border-radius:0.5rem;
background:oklch(0.97 0.004 265);
font-size:0.6875rem;color:var(--vibeui-tree-004-muted);
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="tree-004"] [data-part="status"] strong{
color:var(--vibeui-tree-004-fg);font-weight:650;
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="tree-004"] *{animation:none!important;transition:none!important}
}
`

const DEFAULT_NODES: Tree004Node[] = [
  {
    name: "Продажи",
    open: true,
    children: [
      { name: "Прямые продажи" },
      {
        name: "Партнёры",
        open: true,
        children: [{ name: "Реселлеры" }, { name: "Интеграторы" }],
      },
    ],
  },
  {
    name: "Маркетинг",
    children: [{ name: "Контент" }, { name: "Мероприятия" }],
  },
  { name: "Поддержка" },
]

type Flat = {
  id: string
  name: string
  level: number
  parent: string | null
  branch: boolean
}

function flatten(
  nodes: Tree004Node[],
  open: Set<string>,
  level = 1,
  parent: string | null = null,
  out: Flat[] = [],
) {
  for (const node of nodes) {
    const id = `${parent ?? ""}/${node.name}`
    const branch = Boolean(node.children?.length)

    out.push({ id, name: node.name, level, parent, branch })

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

  return out
}

function collectOpen(
  nodes: Tree004Node[],
  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
}

/**
 * Дерево с выбором узла и полной клавиатурой: стрелки, Home и End.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Tree004({
  nodes = DEFAULT_NODES,
  label = "Направления",
  className,
  style,
}: Tree004Props) {
  const [open, setOpen] = useState(() => collectOpen(nodes))
  const order = useMemo(() => flatten(nodes, open), [nodes, open])
  const [focus, setFocus] = useState(() => order[0]?.id ?? "")
  const [selected, setSelected] = useState("")
  const elements = useRef(new Map<string, HTMLLIElement>())
  const moved = useRef(false)

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

  const index = Math.max(
    0,
    order.findIndex((entry) => entry.id === focus),
  )

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

    if (target) {
      moved.current = true
      setFocus(target.id)
    }
  }

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

      if (force ?? !next.has(id)) {
        next.add(id)
      } else {
        next.delete(id)
      }

      return next
    })
  }

  function onKeyDown(event: KeyboardEvent<HTMLLIElement>, row: Flat) {
    const keys = [
      "ArrowDown",
      "ArrowUp",
      "ArrowRight",
      "ArrowLeft",
      "Home",
      "End",
      "Enter",
      " ",
    ]

    if (!keys.includes(event.key)) {
      return
    }

    event.preventDefault()
    event.stopPropagation()

    if (event.key === "ArrowDown") {
      move(index + 1)
    } else if (event.key === "ArrowUp") {
      move(index - 1)
    } else if (event.key === "Home") {
      move(0)
    } else if (event.key === "End") {
      move(order.length - 1)
    } else if (event.key === "ArrowRight") {
      if (row.branch && !open.has(row.id)) {
        toggle(row.id, true)
      } else if (row.branch) {
        move(index + 1)
      }
    } else if (event.key === "ArrowLeft") {
      if (row.branch && open.has(row.id)) {
        toggle(row.id, false)
      } else if (row.parent) {
        move(order.findIndex((entry) => entry.id === row.parent))
      }
    } else {
      setSelected(row.id)

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

  function renderLevel(
    list: Tree004Node[],
    level: number,
    parent: string,
    groupLabel: string,
  ) {
    return (
      <ul role={level === 1 ? "tree" : "group"} aria-label={groupLabel}>
        {list.map((node) => {
          const id = `${parent}/${node.name}`
          const branch = Boolean(node.children?.length)
          const row = order.find((entry) => entry.id === id)

          return (
            <li
              key={id}
              role="treeitem"
              aria-level={level}
              aria-expanded={branch ? open.has(id) : undefined}
              aria-selected={selected === id}
              tabIndex={focus === id ? 0 : -1}
              ref={(element) => {
                if (element) {
                  elements.current.set(id, element)
                } else {
                  elements.current.delete(id)
                }
              }}
              onKeyDown={(event) => (row ? onKeyDown(event, row) : undefined)}
              onFocus={(event) => {
                event.stopPropagation()
                setFocus(id)
              }}
            >
              <span
                data-part="row"
                onClick={(event) => {
                  event.stopPropagation()
                  setSelected(id)
                  setFocus(id)

                  if (branch) {
                    toggle(id)
                  }
                }}
              >
                <span data-part="caret" aria-hidden="true">
                  {branch ? "▶" : "·"}
                </span>
                <span data-part="name">{node.name}</span>
              </span>
              {branch && open.has(id)
                ? renderLevel(node.children!, level + 1, id, node.name)
                : null}
            </li>
          )
        })}
      </ul>
    )
  }

  const chosen = order.find((entry) => entry.id === selected)

  return (
    <>
      <style href="vibeui-tree-004" precedence="medium">
        {STYLES}
      </style>
      <div data-vibeui-block="tree-004" className={className} style={style}>
        {renderLevel(nodes, 1, "", label)}
        <p data-part="status">
          {chosen ? (
            <>
              Выбрано: <strong>{chosen.name}</strong>
            </>
          ) : (
            "Стрелки — переход, Enter или пробел — выбор"
          )}
        </p>
      </div>
    </>
  )
}