Inputs
Tree Search
A cascader with search across the whole tree at once: every result carries the full path to the node and highlights the matched part of the name.
- cascader
- search
- tree
- filter
Preview
Use it with AI
- 1. Copy the link.
- 2. Write to your agent in your own words and drop the link into the sentence.
- 3. The agent opens the link and installs the component from the registry.
put this in the header: https://vibeui.ru/c/cascader-006?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 "cascader-006" (Tree Search) 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/cascader-006.json
Registry item: https://vibeui.ru/r/cascader-006.json
Installs to: components/vibeui/cascader-006.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 cascader with search across the whole tree at once: every result carries the full path to the node and highlights the matched part of the name.
Search across a whole cascader tree: a flat result list, the full path under each name and a highlighted match. One file, zero dependencies.
## 3. How to use it
import { Cascader006 } from "@/components/vibeui/cascader-006"
<Cascader006 label="Settings section" placeholder="Search every level" />
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-cascader-006-* palette — do not swap it for your theme tokens
- the full path under a result name: identical leaves from different branches are otherwise indistinguishable
- matching against the path too — otherwise a query naming the parent finds nothing
- the <mark> highlight: without it the reason a row is in the list is unclear
- the fixed result list height — the panel otherwise jumps on every typed character
- the panel's own light surface: dark text disappears on the dark catalogue card
## 6. You may change
- the field caption through label
- the in-field hint through placeholder
- the tree through tree and the accent colour through accent
- the result list height in CSS
## 7. Rules
- The search is linear over every leaf: on a tree of tens of thousands of nodes it belongs on the server.
- The highlight only marks a match inside the leaf name — a match in the path shows in the path line but is not marked.
- An empty query lists the whole tree: that is a deliberate choice over an empty panel.
- 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/cascader-006.jsonhttps://vibeui.ru/r/cascader-006.jsonКомпонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-cascader-006-*. Клиентский: запрос и выбранный результат живут в useState, плоский список листьев считается один раз через useMemo. Поиск смотрит и на путь, и на имя листа, поэтому «инфра» находит узлы внутри ветки «Инфраструктура». Совпадение подсвечивается через <mark>, а под именем идёт путь: без него два одинаковых «Общие настройки» из разных веток неразличимы. Пустой запрос показывает все листья, а не пустоту.
Component source
The same file your agent installs. Here in case you would rather copy it by hand.
"use client"
import { useId, useMemo, useState } from "react"
import type { CSSProperties } from "react"
export type Cascader006Node = {
label: string
children?: Cascader006Node[]
}
export type Cascader006Props = {
label?: string
placeholder?: string
tree?: Cascader006Node[]
accent?: string
className?: string
style?: CSSProperties
}
// Идея компонента: в глубоком дереве быстрее найти, чем пройти. Поиск идёт
// сразу по всем уровням, а в результате показывается полный путь до узла —
// без него два одинаковых «Общие» из разных веток неразличимы. Совпавшая
// часть подсвечивается, чтобы было видно, почему строка попала в выдачу.
// Пустой запрос показывает верхний уровень: пустая панель выглядит поломкой.
const STYLES = `
:where([data-vibeui-block="cascader-006"]){
--vibeui-cascader-006-bg:oklch(1 0 0);
--vibeui-cascader-006-field:oklch(0.975 0.003 265);
--vibeui-cascader-006-fg:oklch(0.23 0.014 265);
--vibeui-cascader-006-muted:oklch(0.55 0.012 265);
--vibeui-cascader-006-border:oklch(0.9 0.006 265);
--vibeui-cascader-006-accent:oklch(0.55 0.19 150);
--vibeui-cascader-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-006"]{
display:flex;flex-direction:column;gap:0.625rem;
width:100%;max-width:24rem;box-sizing:border-box;padding:0.875rem;
background:var(--vibeui-cascader-006-bg);color:var(--vibeui-cascader-006-fg);
border:1px solid var(--vibeui-cascader-006-border);border-radius:1rem;
font-family:var(--vibeui-cascader-006-font);
box-shadow:0 18px 40px -32px oklch(0.2 0.03 265 / 55%);
}
[data-vibeui-block="cascader-006"] *{box-sizing:border-box}
[data-vibeui-block="cascader-006"] [data-part="label"]{
font-size:0.75rem;font-weight:650;letter-spacing:-0.01em;
}
[data-vibeui-block="cascader-006"] [data-part="field"]{
display:flex;align-items:center;gap:0.5rem;
padding:0.4375rem 0.625rem;border-radius:0.75rem;
border:1px solid var(--vibeui-cascader-006-border);
background:var(--vibeui-cascader-006-field);
transition:border-color .14s ease;
}
[data-vibeui-block="cascader-006"] [data-part="field"]:focus-within{
border-color:var(--vibeui-cascader-006-accent);
}
[data-vibeui-block="cascader-006"] [data-part="glass"]{
flex:0 0 auto;width:0.75rem;height:0.75rem;border-radius:50%;
border:1.5px solid var(--vibeui-cascader-006-muted);position:relative;
}
[data-vibeui-block="cascader-006"] [data-part="glass"]::after{
content:"";position:absolute;right:-0.25rem;bottom:-0.1875rem;
width:0.3125rem;height:1.5px;border-radius:1px;
background:var(--vibeui-cascader-006-muted);transform:rotate(45deg);
}
[data-vibeui-block="cascader-006"] input{
flex:1 1 auto;min-width:0;border:0;background:transparent;color:inherit;
font:inherit;font-size:0.8125rem;outline:none;
}
[data-vibeui-block="cascader-006"] [data-part="count"]{
flex:0 0 auto;font-size:0.6875rem;color:var(--vibeui-cascader-006-muted);
}
[data-vibeui-block="cascader-006"] [data-part="results"]{
display:flex;flex-direction:column;gap:0.125rem;margin:0;padding:0;list-style:none;
height:11rem;overflow:auto;overscroll-behavior:contain;
}
[data-vibeui-block="cascader-006"] [data-part="hit"]{
display:flex;flex-direction:column;gap:0.125rem;width:100%;
padding:0.4375rem 0.5rem;border:0;border-radius:0.625rem;
background:transparent;color:inherit;font:inherit;text-align:left;cursor:pointer;
transition:background-color .14s ease;
}
[data-vibeui-block="cascader-006"] [data-part="hit"]:hover{
background:color-mix(in oklab,var(--vibeui-cascader-006-accent) 10%,transparent);
}
[data-vibeui-block="cascader-006"] [data-part="hit"]:focus-visible{
outline:2px solid var(--vibeui-cascader-006-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-006"] [data-part="hit-name"]{
font-size:0.8125rem;font-weight:600;line-height:1.25;
}
[data-vibeui-block="cascader-006"] [data-part="hit-name"] mark{
background:color-mix(in oklab,var(--vibeui-cascader-006-accent) 28%,transparent);
color:inherit;border-radius:0.1875rem;padding:0 0.0625rem;
}
[data-vibeui-block="cascader-006"] [data-part="hit-path"]{
font-size:0.6875rem;line-height:1.3;color:var(--vibeui-cascader-006-muted);
}
[data-vibeui-block="cascader-006"] [data-part="empty"]{
margin:0;padding:1.5rem 0.5rem;text-align:center;
font-size:0.75rem;line-height:1.4;color:var(--vibeui-cascader-006-muted);
}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="cascader-006"] *{animation:none!important;transition:none!important}
}
`
const DEFAULT_TREE: Cascader006Node[] = [
{
label: "Продукт",
children: [
{
label: "Онбординг",
children: [
{ label: "Приветственный экран" },
{ label: "Импорт данных" },
{ label: "Общие настройки" },
],
},
{
label: "Уведомления",
children: [{ label: "Почта" }, { label: "Пуш-сообщения" }],
},
],
},
{
label: "Инфраструктура",
children: [
{
label: "Хранилище",
children: [{ label: "Резервные копии" }, { label: "Общие настройки" }],
},
{
label: "Сеть",
children: [{ label: "Домены" }, { label: "Сертификаты" }],
},
],
},
{
label: "Поддержка",
children: [
{
label: "Обращения",
children: [{ label: "Очередь" }, { label: "Шаблоны ответов" }],
},
],
},
]
type Hit = { label: string; path: string[] }
function flatten(nodes: Cascader006Node[], trail: string[] = []): Hit[] {
return nodes.flatMap((node) =>
node.children?.length
? flatten(node.children, [...trail, node.label])
: [{ label: node.label, path: trail }],
)
}
function split(label: string, query: string) {
const at = label.toLowerCase().indexOf(query.toLowerCase())
if (!query || at < 0) {
return [label, "", ""] as const
}
return [
label.slice(0, at),
label.slice(at, at + query.length),
label.slice(at + query.length),
] as const
}
/**
* Каскад с поиском по всему дереву: результат несёт полный путь до узла.
* Один файл, ноль зависимостей, собственная палитра.
*/
export function Cascader006({
label = "Раздел настроек",
placeholder = "Искать по всем уровням",
tree = DEFAULT_TREE,
accent,
className,
style,
}: Cascader006Props) {
const id = useId()
const [query, setQuery] = useState("общие")
const [chosen, setChosen] = useState<string | null>(null)
const all = useMemo(() => flatten(tree), [tree])
const trimmed = query.trim()
const hits = trimmed
? all.filter((hit) =>
[...hit.path, hit.label]
.join(" ")
.toLowerCase()
.includes(trimmed.toLowerCase()),
)
: all
const palette = {
...(accent ? { "--vibeui-cascader-006-accent": accent } : null),
...style,
} as CSSProperties
return (
<>
<style href="vibeui-cascader-006" precedence="medium">
{STYLES}
</style>
<div
data-vibeui-block="cascader-006"
className={className}
style={palette}
>
<label data-part="label" htmlFor={id}>
{label}
</label>
<div data-part="field">
<i data-part="glass" aria-hidden="true" />
<input
id={id}
type="search"
value={query}
placeholder={placeholder}
onChange={(event) => setQuery(event.target.value)}
/>
<span data-part="count" aria-live="polite">
{hits.length}
</span>
</div>
{hits.length ? (
<ul data-part="results">
{hits.map((hit) => {
const [before, match, after] = split(hit.label, trimmed)
return (
<li key={[...hit.path, hit.label].join("/")}>
<button
data-part="hit"
type="button"
aria-current={
chosen === [...hit.path, hit.label].join(" › ")
? "true"
: undefined
}
onClick={() =>
setChosen([...hit.path, hit.label].join(" › "))
}
>
<span data-part="hit-name">
{before}
{match ? <mark>{match}</mark> : null}
{after}
</span>
<span data-part="hit-path">{hit.path.join(" › ")}</span>
</button>
</li>
)
})}
</ul>
) : (
<p data-part="empty">
Ничего не найдено. Попробуйте название родительского раздела — поиск
смотрит и на путь тоже.
</p>
)}
</div>
</>
)
}