Inputs

Nested Flyout

A cascader as a nested menu: a branch opens to the right in a native HTML popover, without a single line of client-side JavaScript.

  • cascader
  • menu
  • popover
  • no-js

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/cascader-003?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-003" (Nested Flyout) 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-003.json

Registry item: https://vibeui.ru/r/cascader-003.json
Installs to: components/vibeui/cascader-003.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 as a nested menu: a branch opens to the right in a native HTML popover, without a single line of client-side JavaScript.

A cascading menu that opens to the right: levels live in HTML popovers, and Esc plus outside clicks are handled by the browser. One file, zero dependencies, a server component with no client JS.

## 3. How to use it
import { Cascader003 } from "@/components/vibeui/cascader-003"

<Cascader003 id="workspace-cascader" heading="Workspace settings" accent="#7c3aed" />

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-003-* palette — do not swap it for your theme tokens
- the opener button inside the parent panel: only then does the browser treat the popovers as nested and keep the parent open
- the generated anchor rules and the data-anchor pair on button and panel — without them the panel flies to the centre of the screen
- the @supports guard around anchor positioning: in browsers without anchors the centred fallback still works
- popover="auto" instead of hand-rolled state: Esc, outside clicks and the top layer come for free
- the menu's own light surface: dark text disappears on the dark catalogue card

## 6. You may change
- the menu caption through heading
- the accent colour through accent
- the panel id prefix through id
- the set of branches through tree

## 7. Rules
- There are exactly 24 anchor slots: a wider tree starts reusing names and panels stack on each other.
- Keyboard navigation here is the browser's: there are no arrow keys between items, only Tab and Esc.
- A panel in the top layer is never clipped by its parent — but it does not inherit the parent's scrolling either.
- 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-003.json
https://vibeui.ru/r/cascader-003.json

Компонент самодостаточен: один файл, без зависимостей, собственная палитра в локальных переменных --vibeui-cascader-003-*. Серверный: хуков нет, открытием управляет браузер через popover и popovertarget. Кнопка третьего уровня лежит внутри панели второго, поэтому браузер считает попoveры вложенными и не гасит родителя при открытии потомка. Положение панели держит CSS anchor positioning: имя якоря нельзя собрать на лету, поэтому правила для 24 слотов генерируются заранее, а ветка получает номер слота при рендере. Без поддержки якорей панель открывается по центру экрана — некрасиво, но кликабельно.

Component source

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

import type { CSSProperties, ReactNode } from "react"

export type Cascader003Node = {
  label: string
  children?: Cascader003Node[]
}

export type Cascader003Props = {
  id?: string
  heading?: string
  tree?: Cascader003Node[]
  accent?: string
  className?: string
  style?: CSSProperties
}

/**
 * Слотов под якоря ровно столько, сколько веток помещается в разумное меню.
 * Имя якоря нельзя собрать на лету в статическом CSS, поэтому правила
 * генерируются заранее, а ветка получает номер слота при рендере.
 */
const ANCHOR_SLOTS = 24

const ANCHORS = Array.from(
  { length: ANCHOR_SLOTS },
  (_, slot) => `
[data-vibeui-block="cascader-003"] [data-part="branch"][data-anchor="s${slot}"]{anchor-name:--vibeui-cascader-003-s${slot}}
[data-vibeui-block="cascader-003"] [data-part="flyout"][data-anchor="s${slot}"]{position-anchor:--vibeui-cascader-003-s${slot}}`,
).join("")

// Идея компонента: каскад как вложенное меню — ветка раскрывается вправо
// отдельной панелью, а не перерисовывает список на месте. Держится на HTML
// popover: подменю живёт в верхнем слое, Esc и клик мимо закрывают его без
// единой строки JS. Кнопка-раскрыватель третьего уровня лежит внутри панели
// второго, поэтому браузер считает панели вложенными и не гасит родителя.
const STYLES = `
:where([data-vibeui-block="cascader-003"]){
--vibeui-cascader-003-bg:oklch(1 0 0);
--vibeui-cascader-003-fg:oklch(0.23 0.015 285);
--vibeui-cascader-003-muted:oklch(0.55 0.014 285);
--vibeui-cascader-003-border:oklch(0.9 0.006 285);
--vibeui-cascader-003-accent:oklch(0.56 0.17 300);
--vibeui-cascader-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="cascader-003"]{
display:flex;flex-direction:column;gap:0.5rem;
width:100%;max-width:20rem;box-sizing:border-box;padding:0.75rem;
background:var(--vibeui-cascader-003-bg);color:var(--vibeui-cascader-003-fg);
border:1px solid var(--vibeui-cascader-003-border);border-radius:0.875rem;
font-family:var(--vibeui-cascader-003-font);
box-shadow:0 18px 40px -32px oklch(0.2 0.03 285 / 60%);
}
[data-vibeui-block="cascader-003"] *{box-sizing:border-box}
[data-vibeui-block="cascader-003"] [data-part="heading"]{
margin:0 0 0.125rem;padding:0 0.375rem;
font-size:0.6875rem;font-weight:650;letter-spacing:0.06em;text-transform:uppercase;
color:var(--vibeui-cascader-003-muted);
}
[data-vibeui-block="cascader-003"] [data-part="list"]{
display:flex;flex-direction:column;gap:0.0625rem;margin:0;padding:0;list-style:none;
}
[data-vibeui-block="cascader-003"] [data-part="branch"],
[data-vibeui-block="cascader-003"] [data-part="leaf"]{
display:flex;align-items:center;gap:0.5rem;width:100%;
padding:0.4375rem 0.5rem;border:0;border-radius:0.5rem;
background:transparent;color:inherit;font:inherit;font-size:0.8125rem;
text-align:left;cursor:pointer;
transition:background-color .14s ease;
}
[data-vibeui-block="cascader-003"] [data-part="branch"] span,
[data-vibeui-block="cascader-003"] [data-part="leaf"] span{
flex:1 1 auto;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
}
[data-vibeui-block="cascader-003"] [data-part="branch"]:hover,
[data-vibeui-block="cascader-003"] [data-part="leaf"]:hover{
background:color-mix(in oklab,var(--vibeui-cascader-003-accent) 10%,transparent);
}
[data-vibeui-block="cascader-003"] [data-part="branch"]:focus-visible,
[data-vibeui-block="cascader-003"] [data-part="leaf"]:focus-visible{
outline:2px solid var(--vibeui-cascader-003-accent);outline-offset:-2px;
}
[data-vibeui-block="cascader-003"] [data-part="chevron"]{
flex:0 0 auto;width:0.3125rem;height:0.3125rem;
border-right:1.5px solid var(--vibeui-cascader-003-muted);
border-top:1.5px solid var(--vibeui-cascader-003-muted);
transform:rotate(45deg);
}
[data-vibeui-block="cascader-003"] [data-part="dot"]{
flex:0 0 auto;width:0.3125rem;height:0.3125rem;border-radius:50%;
background:color-mix(in oklab,var(--vibeui-cascader-003-accent) 55%,transparent);
}
/* Панель второго и третьего уровня. Без якорей она открывается по центру
   экрана: это некрасиво, но кликабельно, а скрипта по-прежнему нет. */
[data-vibeui-block="cascader-003"] [data-part="flyout"]{
position:fixed;inset:auto;top:50%;left:50%;translate:-50% -50%;
margin:0;padding:0.3125rem;min-width:11rem;max-width:14rem;list-style:none;
border:1px solid var(--vibeui-cascader-003-border);border-radius:0.75rem;
background:var(--vibeui-cascader-003-bg);color:var(--vibeui-cascader-003-fg);
box-shadow:0 20px 44px -22px oklch(0.2 0.03 285 / 50%);
opacity:0;
transition:opacity .14s ease,display .14s allow-discrete,overlay .14s allow-discrete;
}
[data-vibeui-block="cascader-003"] [data-part="flyout"]:popover-open{opacity:1}
@starting-style{
[data-vibeui-block="cascader-003"] [data-part="flyout"]:popover-open{opacity:0}
}
@supports (anchor-name: --a){
[data-vibeui-block="cascader-003"] [data-part="flyout"]{
top:auto;left:auto;translate:none;
position-area:right span-block-end;margin-left:0.25rem;
position-try-fallbacks:flip-inline,flip-block;
}
}
${ANCHORS}
@media (prefers-reduced-motion:reduce){
[data-vibeui-block="cascader-003"] *{animation:none!important;transition:none!important}
[data-vibeui-block="cascader-003"] [data-part="flyout"]{transition:none!important}
}
`

const DEFAULT_TREE: Cascader003Node[] = [
  {
    label: "Аналитика",
    children: [
      {
        label: "Отчёты",
        children: [
          { label: "Ежедневный" },
          { label: "Когорты" },
          { label: "Воронка" },
        ],
      },
      {
        label: "Экспорт",
        children: [{ label: "CSV" }, { label: "Google Sheets" }],
      },
    ],
  },
  {
    label: "Команда",
    children: [
      {
        label: "Доступы",
        children: [{ label: "Роли" }, { label: "Приглашения" }],
      },
      { label: "Активность", children: [{ label: "Журнал входов" }] },
    ],
  },
  {
    label: "Биллинг",
    children: [
      {
        label: "Тариф",
        children: [{ label: "Смена плана" }, { label: "Лимиты" }],
      },
      { label: "Документы", children: [{ label: "Счета" }, { label: "Акты" }] },
    ],
  },
]

/**
 * Каскадное меню с раскрытием вправо на HTML popover: без клиентского JS.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Cascader003({
  id = "vibeui-cascader-003",
  heading = "Настройки рабочего пространства",
  tree = DEFAULT_TREE,
  accent,
  className,
  style,
}: Cascader003Props) {
  let cursor = 0

  const renderNodes = (nodes: Cascader003Node[], prefix: string): ReactNode =>
    nodes.map((node, index) => {
      const key = `${prefix}-${index}`

      if (!node.children?.length) {
        return (
          <li key={key}>
            <button data-part="leaf" type="button">
              <i data-part="dot" aria-hidden="true" />
              <span>{node.label}</span>
            </button>
          </li>
        )
      }

      const slot = `s${cursor++ % ANCHOR_SLOTS}`

      return (
        <li key={key}>
          <button
            data-part="branch"
            data-anchor={slot}
            type="button"
            popoverTarget={key}
            aria-label={`${node.label}: открыть вложенный список`}
          >
            <span>{node.label}</span>
            <i data-part="chevron" aria-hidden="true" />
          </button>
          <ul
            id={key}
            data-part="flyout"
            data-anchor={slot}
            popover="auto"
            aria-label={node.label}
          >
            {renderNodes(node.children, key)}
          </ul>
        </li>
      )
    })

  const palette = {
    ...(accent ? { "--vibeui-cascader-003-accent": accent } : null),
    ...style,
  } as CSSProperties

  return (
    <>
      <style href="vibeui-cascader-003" precedence="medium">
        {STYLES}
      </style>
      <nav
        data-vibeui-block="cascader-003"
        className={className}
        style={palette}
        aria-label={heading}
        id={id}
      >
        <p data-part="heading">{heading}</p>
        <ul data-part="list">{renderNodes(tree, id)}</ul>
      </nav>
    </>
  )
}