Menu

Nested Submenu

A menu with a "Move to" submenu: the second level opens on hover and with the right arrow, while the left arrow returns focus to its parent.

  • menu
  • dropdown
  • submenu
  • keyboard

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/dropdown-006?lang=en

Отчёт за март.pdf
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 "dropdown-006" (Nested Submenu) 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/dropdown-006.json

Registry item: https://vibeui.ru/r/dropdown-006.json
Installs to: components/vibeui/dropdown-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 menu with a "Move to" submenu: the second level opens on hover and with the right arrow, while the left arrow returns focus to its parent.

A menu with a nested submenu: hover and the right arrow open the second level, the left arrow walks back. One file, zero dependencies.

## 3. How to use it
import { Dropdown006 } from "@/components/vibeui/dropdown-006"

<Dropdown006
  trigger="Message"
  side="right"
  submenuLabel="Move to"
  folders={["Inbox", "Archive"]}
/>

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-dropdown-006-* palette: the component must look the same in any project
- the submenu as a plain layer inside the popover: a second popover=auto would close the first and make the menu blink
- the aria-haspopup and aria-expanded pair on the parent item — otherwise a screen reader never mentions the second level
- returning focus to the parent on the left arrow: without it there is no keyboard way out of the submenu
- opening the submenu both on hover and on click — mouse and keyboard behave differently
- the component's own light surface: on the dark catalog card dark text disappears

## 6. You may change
- the button caption through trigger
- which way the submenu opens through side
- the second-level heading through submenuLabel and its items through folders
- the focus colour through accent

## 7. Rules
- A third nesting level is not provided: at that depth people stop hitting items with the mouse — use a dialog.
- The submenu closes when the pointer leaves; add a delay in onMouseLeave if you want a forgiving gap.
- The submenu does not flip near the screen edge by itself — in a narrow window pass side="left".
- 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/dropdown-006.json
https://vibeui.ru/r/dropdown-006.json

Один файл, ноль зависимостей, палитра --vibeui-dropdown-006-*. Клиентский: раскрытие подменю держит useState. Первый уровень — HTML popover с якорным позиционированием, второй — обычный абсолютно спозиционированный слой внутри него: вложенный popover=auto закрыл бы родителя, потому что открытие второго auto-попапа гасит первый. Родительский пункт помечен aria-haspopup=menu и aria-expanded, подменю — role=menu с aria-label. Стрелка вправо открывает подменю и ставит фокус на первый пункт, влево — закрывает и возвращает фокус; без этого из подменю не выйти с клавиатуры. Пропс side меняет сторону раскрытия.

Component source

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

"use client"

import { useId, useRef, useState } from "react"
import type {
  ComponentPropsWithoutRef,
  CSSProperties,
  KeyboardEvent,
} from "react"

export type Dropdown006Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  trigger?: string
  side?: "right" | "left"
  submenuLabel?: string
  folders?: string[]
  accent?: string
}

// Идея компонента: пункт с вложенным подменю. Подменю — не второй popover, а
// обычный абсолютно спозиционированный слой внутри первого: вложенные popover
// закрывают друг друга, и «переместить в папку» превращается в мигание.
// Подменю открывается наведением и стрелкой вправо, закрывается стрелкой
// влево с возвратом фокуса на родителя — без этого с клавиатуры из него не выйти.
const STYLES = `
:where([data-vibeui-block="dropdown-006"]){
--vibeui-dropdown-006-bg:oklch(1 0 0);
--vibeui-dropdown-006-fg:oklch(0.24 0.014 260);
--vibeui-dropdown-006-muted:oklch(0.56 0.014 260);
--vibeui-dropdown-006-border:oklch(0.9 0.006 260);
--vibeui-dropdown-006-hover:oklch(0.96 0.004 260);
--vibeui-dropdown-006-accent:oklch(0.55 0.19 300);
--vibeui-dropdown-006-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
}
[data-vibeui-block="dropdown-006"]{
display:inline-flex;flex-direction:column;gap:0.4375rem;
box-sizing:border-box;padding:0.625rem 0.75rem;
background:var(--vibeui-dropdown-006-bg);color:var(--vibeui-dropdown-006-fg);
border:1px solid var(--vibeui-dropdown-006-border);border-radius:0.875rem;
font-family:var(--vibeui-dropdown-006-font);
}
[data-vibeui-block="dropdown-006"] [data-part="caption"]{
font-size:0.75rem;color:var(--vibeui-dropdown-006-muted);
}
[data-vibeui-block="dropdown-006"] [data-part="trigger"]{
appearance:none;cursor:pointer;
display:inline-flex;align-items:center;gap:0.4375rem;
height:2.125rem;padding:0 0.875rem;
border:1px solid var(--vibeui-dropdown-006-border);border-radius:0.625rem;
background:var(--vibeui-dropdown-006-bg);color:inherit;
font:inherit;font-size:0.8125rem;font-weight:600;
anchor-name:--vibeui-dropdown-006-anchor;
transition:background-color .16s ease;
}
[data-vibeui-block="dropdown-006"] [data-part="trigger"]:hover{background:var(--vibeui-dropdown-006-hover)}
[data-vibeui-block="dropdown-006"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-dropdown-006-accent);outline-offset:2px}
[data-vibeui-block="dropdown-006"] [data-part="menu"]{
position:fixed;padding:0.3125rem;min-width:13rem;box-sizing:border-box;
background:var(--vibeui-dropdown-006-bg);color:var(--vibeui-dropdown-006-fg);
border:1px solid var(--vibeui-dropdown-006-border);border-radius:0.75rem;
box-shadow:0 18px 40px -22px oklch(0.2 0.03 260 / 45%);
font-family:var(--vibeui-dropdown-006-font);
opacity:0;transform:translateY(-0.25rem);
transition:opacity .14s ease,transform .14s ease,display .14s allow-discrete,overlay .14s allow-discrete;
}
[data-vibeui-block="dropdown-006"] [data-part="menu"]:popover-open{opacity:1;transform:none}
@starting-style{[data-vibeui-block="dropdown-006"] [data-part="menu"]:popover-open{opacity:0;transform:translateY(-0.25rem)}}
@supports (anchor-name: --a){
[data-vibeui-block="dropdown-006"] [data-part="menu"]{
position-anchor:--vibeui-dropdown-006-anchor;
position-area:bottom span-right;margin:0.375rem 0 0;
position-try-fallbacks:flip-block,flip-inline;
}
}
[data-vibeui-block="dropdown-006"] [data-part="item"]{
display:flex;align-items:center;gap:0.75rem;
width:100%;box-sizing:border-box;
appearance:none;border:0;background:none;cursor:pointer;
padding:0.4375rem 0.5rem;border-radius:0.5rem;
font:inherit;font-size:0.8125rem;color:inherit;text-align:left;
transition:background-color .14s ease;
}
[data-vibeui-block="dropdown-006"] [data-part="item"]:hover,
[data-vibeui-block="dropdown-006"] [data-part="item"][aria-expanded="true"]{background:var(--vibeui-dropdown-006-hover)}
[data-vibeui-block="dropdown-006"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-dropdown-006-accent);outline-offset:-2px}
[data-vibeui-block="dropdown-006"] [data-part="arrow"]{
margin-left:auto;width:0.3125rem;height:0.3125rem;flex:none;
border-top:1.5px solid var(--vibeui-dropdown-006-muted);
border-right:1.5px solid var(--vibeui-dropdown-006-muted);
transform:rotate(45deg);
}
[data-vibeui-block="dropdown-006"] [data-part="nest"]{position:relative}
/* Подменю живёт внутри popover обычным слоем: второй popover закрыл бы первый. */
[data-vibeui-block="dropdown-006"] [data-part="sub"]{
position:absolute;top:-0.3125rem;left:calc(100% + 0.25rem);z-index:1;
padding:0.3125rem;min-width:10.5rem;box-sizing:border-box;
background:var(--vibeui-dropdown-006-bg);
border:1px solid var(--vibeui-dropdown-006-border);border-radius:0.75rem;
box-shadow:0 18px 40px -22px oklch(0.2 0.03 260 / 45%);
}
[data-vibeui-block="dropdown-006"][data-side="left"] [data-part="sub"]{left:auto;right:calc(100% + 0.25rem)}
[data-vibeui-block="dropdown-006"] [data-part="rule"]{
height:1px;margin:0.3125rem 0.25rem;background:var(--vibeui-dropdown-006-border);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="dropdown-006"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_FOLDERS = ["Входящие", "Проекты 2026", "Архив", "Черновики"]

function stepFocus(scope: HTMLElement | null, delta: number) {
  if (!scope) {
    return
  }

  const items = Array.from(
    scope.querySelectorAll<HTMLElement>('[data-part="item"]'),
  ).filter((item) => item.offsetParent !== null)

  if (items.length === 0) {
    return
  }

  const from = items.indexOf(document.activeElement as HTMLElement)
  items[(from + delta + items.length) % items.length].focus()
}

/**
 * Меню с вложенным подменю: наведение, стрелка вправо внутрь, влево — назад.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Dropdown006({
  trigger = "Письмо",
  side = "right",
  submenuLabel = "Переместить в",
  folders = DEFAULT_FOLDERS,
  accent,
  className,
  style,
  ...props
}: Dropdown006Props) {
  const id = useId()
  const menu = useRef<HTMLDivElement>(null)
  const parent = useRef<HTMLButtonElement>(null)
  const sub = useRef<HTMLDivElement>(null)
  const [open, setOpen] = useState(false)

  const enterSub = () => {
    setOpen(true)
    requestAnimationFrame(() =>
      sub.current?.querySelector<HTMLElement>('[data-part="item"]')?.focus(),
    )
  }

  const leaveSub = () => {
    setOpen(false)
    parent.current?.focus()
  }

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

  return (
    <>
      <style href="vibeui-dropdown-006" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="dropdown-006"
        data-side={side}
        className={className}
        style={palette}
      >
        <span data-part="caption">Отчёт за март.pdf</span>
        <button
          type="button"
          data-part="trigger"
          popoverTarget={`${id}-menu`}
          aria-haspopup="menu"
          onKeyDown={(event) => {
            if (event.key === "ArrowDown") {
              event.preventDefault()
              const node = menu.current

              if (node) {
                if (!node.matches(":popover-open")) {
                  node.showPopover()
                }

                requestAnimationFrame(() =>
                  node
                    .querySelector<HTMLElement>('[data-part="item"]')
                    ?.focus(),
                )
              }
            }
          }}
        >
          {trigger}
        </button>
        <div
          id={`${id}-menu`}
          ref={menu}
          popover="auto"
          role="menu"
          aria-label={trigger}
          data-part="menu"
          onToggle={() => setOpen(false)}
          onKeyDown={(event: KeyboardEvent<HTMLDivElement>) => {
            if (event.key === "ArrowDown") {
              event.preventDefault()
              stepFocus(menu.current, 1)
            } else if (event.key === "ArrowUp") {
              event.preventDefault()
              stepFocus(menu.current, -1)
            }
          }}
        >
          <button
            type="button"
            role="menuitem"
            data-part="item"
            onClick={() => menu.current?.hidePopover()}
          >
            Ответить
          </button>
          <button
            type="button"
            role="menuitem"
            data-part="item"
            onClick={() => menu.current?.hidePopover()}
          >
            Переслать
          </button>
          <div data-part="rule" role="separator" />
          <div
            data-part="nest"
            onMouseEnter={() => setOpen(true)}
            onMouseLeave={() => setOpen(false)}
          >
            <button
              type="button"
              ref={parent}
              role="menuitem"
              data-part="item"
              aria-haspopup="menu"
              aria-expanded={open}
              onClick={() => (open ? setOpen(false) : enterSub())}
              onKeyDown={(event) => {
                if (event.key === "ArrowRight") {
                  event.preventDefault()
                  enterSub()
                }
              }}
            >
              {submenuLabel}
              <span data-part="arrow" aria-hidden="true" />
            </button>
            {open ? (
              <div
                ref={sub}
                data-part="sub"
                role="menu"
                aria-label={submenuLabel}
                onKeyDown={(event) => {
                  if (event.key === "ArrowLeft") {
                    event.preventDefault()
                    leaveSub()
                  }
                }}
              >
                {folders.map((folder) => (
                  <button
                    key={folder}
                    type="button"
                    role="menuitem"
                    data-part="item"
                    onClick={() => {
                      setOpen(false)
                      menu.current?.hidePopover()
                    }}
                  >
                    {folder}
                  </button>
                ))}
              </div>
            ) : null}
          </div>
          <button
            type="button"
            role="menuitem"
            data-part="item"
            onClick={() => menu.current?.hidePopover()}
          >
            В архив
          </button>
        </div>
      </div>
    </>
  )
}