Menu

Shortcut Menu

A menu with an icon on the left and a keyboard shortcut on the right, the shortcut column set in a monospace face so it can be read top to bottom.

  • menu
  • dropdown
  • shortcuts
  • icons

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-003?lang=en

⌘K
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-003" (Shortcut Menu) 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-003.json

Registry item: https://vibeui.ru/r/dropdown-003.json
Installs to: components/vibeui/dropdown-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 menu with an icon on the left and a keyboard shortcut on the right, the shortcut column set in a monospace face so it can be read top to bottom.

A menu where every item carries an icon and a shortcut, so the command list doubles as a cheat sheet. One file, zero dependencies, icons drawn as inline SVG.

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

<Dropdown003
  trigger="Edit"
  keysLabel="⌘K"
  items={[{ label: "Duplicate", keys: "⌘D", icon: "copy" }]}
/>

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-003-* palette: the component must not depend on the host theme
- inline SVG instead of an icon package — otherwise the component loses its main property, self-sufficiency
- the monospace face on the shortcuts: a proportional one destroys the key column
- the three-column grid inside an item: without it captions of different length shift the keys around
- the kbd tag around a shortcut — it is key markup, not a decorative span
- the separator before the destructive item: sitting next to "duplicate" it catches every mouse slip

## 6. You may change
- the button caption through trigger
- the hint beside the button through keysLabel
- the items, their icons and keys through items
- the button and focus colour through accent

## 7. Rules
- The ⌘ and ⇧ glyphs are Mac ones: on Windows substitute Ctrl and Shift or the hint lies.
- The table holds five icons; a new one is added as a path in PATHS, never as an import.
- The component does not bind document-level key handlers: showing a shortcut and running it are separate jobs.
- 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-003.json
https://vibeui.ru/r/dropdown-003.json

Один файл, ноль зависимостей, палитра --vibeui-dropdown-003-*. Клиентский из-за навигации стрелками. Иконки нарисованы инлайновым SVG по таблице путей — пакет иконок сделал бы компонент зависимым. Пункт — грид из трёх колонок (иконка, подпись, сочетание), поэтому подписи и клавиши стоят ровными колонками при любой длине текста. Сочетания размечены тегом kbd и моноширинным шрифтом. Открытие и слой — HTML popover, положение — anchor-name плюс position-area под @supports. Опасный пункт отделён линией и покрашен только текстом.

Component source

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

"use client"

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

export type Dropdown003Item = {
  label: string
  keys: string
  icon: "pencil" | "copy" | "share" | "archive" | "trash"
  danger?: boolean
}

export type Dropdown003Props = Omit<
  ComponentPropsWithoutRef<"div">,
  "children"
> & {
  trigger?: string
  keysLabel?: string
  items?: Dropdown003Item[]
  accent?: string
}

// Идея компонента: меню, из которого пользователь уходит к клавиатуре. Слева
// иконка — она держит строку узнаваемой при беглом взгляде, справа сочетание
// клавиш моноширинным начертанием: их читают колонкой, и пропорциональный
// шрифт эту колонку разваливает. Иконки нарисованы инлайновым SVG, потому что
// пакет иконок сделал бы компонент зависимым.
const STYLES = `
:where([data-vibeui-block="dropdown-003"]){
--vibeui-dropdown-003-bg:oklch(1 0 0);
--vibeui-dropdown-003-fg:oklch(0.25 0.015 275);
--vibeui-dropdown-003-muted:oklch(0.56 0.014 275);
--vibeui-dropdown-003-border:oklch(0.9 0.006 275);
--vibeui-dropdown-003-hover:oklch(0.96 0.005 275);
--vibeui-dropdown-003-accent:oklch(0.55 0.18 285);
--vibeui-dropdown-003-danger:oklch(0.56 0.19 25);
--vibeui-dropdown-003-font:ui-sans-serif,system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
--vibeui-dropdown-003-mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace;
}
[data-vibeui-block="dropdown-003"]{
display:inline-flex;align-items:center;gap:0.625rem;
box-sizing:border-box;padding:0.4375rem;
background:var(--vibeui-dropdown-003-bg);color:var(--vibeui-dropdown-003-fg);
border:1px solid var(--vibeui-dropdown-003-border);border-radius:0.875rem;
font-family:var(--vibeui-dropdown-003-font);
}
[data-vibeui-block="dropdown-003"] [data-part="trigger"]{
appearance:none;cursor:pointer;
display:inline-flex;align-items:center;gap:0.5rem;
height:2.125rem;padding:0 0.875rem;
border:0;border-radius:0.625rem;
background:var(--vibeui-dropdown-003-accent);color:oklch(0.99 0 0);
font:inherit;font-size:0.8125rem;font-weight:600;
anchor-name:--vibeui-dropdown-003-anchor;
transition:filter .16s ease;
}
[data-vibeui-block="dropdown-003"] [data-part="trigger"]:hover{filter:brightness(1.08)}
[data-vibeui-block="dropdown-003"] [data-part="trigger"]:focus-visible{outline:2px solid var(--vibeui-dropdown-003-accent);outline-offset:2px}
[data-vibeui-block="dropdown-003"] [data-part="hint"]{
padding-right:0.375rem;font-size:0.75rem;color:var(--vibeui-dropdown-003-muted);
}
[data-vibeui-block="dropdown-003"] [data-part="menu"]{
position:fixed;padding:0.3125rem;min-width:15rem;box-sizing:border-box;
background:var(--vibeui-dropdown-003-bg);color:var(--vibeui-dropdown-003-fg);
border:1px solid var(--vibeui-dropdown-003-border);border-radius:0.75rem;
box-shadow:0 20px 44px -24px oklch(0.2 0.03 275 / 50%);
font-family:var(--vibeui-dropdown-003-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-003"] [data-part="menu"]:popover-open{opacity:1;transform:none}
@starting-style{[data-vibeui-block="dropdown-003"] [data-part="menu"]:popover-open{opacity:0;transform:translateY(-0.25rem)}}
@supports (anchor-name: --a){
[data-vibeui-block="dropdown-003"] [data-part="menu"]{
position-anchor:--vibeui-dropdown-003-anchor;
position-area:bottom span-right;margin:0.375rem 0 0;
position-try-fallbacks:flip-block,flip-inline;
}
}
[data-vibeui-block="dropdown-003"] [data-part="item"]{
display:grid;grid-template-columns:1rem 1fr auto;align-items:center;gap:0.625rem;
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-003"] [data-part="item"]:hover{background:var(--vibeui-dropdown-003-hover)}
[data-vibeui-block="dropdown-003"] [data-part="item"]:focus-visible{outline:2px solid var(--vibeui-dropdown-003-accent);outline-offset:-2px}
[data-vibeui-block="dropdown-003"] [data-part="item"][data-danger="true"]{color:var(--vibeui-dropdown-003-danger)}
[data-vibeui-block="dropdown-003"] [data-part="item"] svg{width:1rem;height:1rem;display:block;opacity:.75}
/* Сочетания читают колонкой, поэтому моноширинный шрифт и одна ширина. */
[data-vibeui-block="dropdown-003"] [data-part="keys"]{
font-family:var(--vibeui-dropdown-003-mono);font-size:0.6875rem;
color:var(--vibeui-dropdown-003-muted);letter-spacing:0.02em;
}
[data-vibeui-block="dropdown-003"] [data-part="rule"]{
height:1px;margin:0.3125rem 0.25rem;background:var(--vibeui-dropdown-003-border);
}
@media (prefers-reduced-motion:reduce){[data-vibeui-block="dropdown-003"] *{animation:none!important;transition:none!important}}
`

const DEFAULT_ITEMS: Dropdown003Item[] = [
  { label: "Переименовать", keys: "F2", icon: "pencil" },
  { label: "Дублировать", keys: "⌘D", icon: "copy" },
  { label: "Поделиться", keys: "⇧⌘S", icon: "share" },
  { label: "В архив", keys: "⌘E", icon: "archive" },
  { label: "Удалить", keys: "⌘⌫", icon: "trash", danger: true },
]

const PATHS: Record<Dropdown003Item["icon"], string> = {
  pencil: "M4 16l-1 5 5-1L20 7.5 17.5 5 4 16z",
  copy: "M9 9h11v11H9zM4 4h11v3M4 4v11h3",
  share: "M12 4v11M12 4l-4 4M12 4l4 4M5 15v5h14v-5",
  archive: "M3 5h18v4H3zM5 9v11h14V9M10 13h4",
  trash: "M4 7h16M9 7V4h6v3M6 7l1 14h10l1-14M10 11v7M14 11v7",
}

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

  const items = Array.from(
    menu.querySelectorAll<HTMLElement>('[data-part="item"]'),
  )

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

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

/**
 * Меню с иконками слева и сочетаниями клавиш справа, на HTML popover.
 * Один файл, ноль зависимостей, собственная палитра.
 */
export function Dropdown003({
  trigger = "Правка",
  keysLabel = "⌘K",
  items = DEFAULT_ITEMS,
  accent,
  className,
  style,
  ...props
}: Dropdown003Props) {
  const id = useId()
  const menu = useRef<HTMLDivElement>(null)

  const openAndFocus = () => {
    const node = menu.current

    if (!node) {
      return
    }

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

    requestAnimationFrame(() =>
      node.querySelector<HTMLElement>('[data-part="item"]')?.focus(),
    )
  }

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

  return (
    <>
      <style href="vibeui-dropdown-003" precedence="medium">
        {STYLES}
      </style>
      <div
        {...props}
        data-vibeui-block="dropdown-003"
        className={className}
        style={palette}
      >
        <button
          type="button"
          data-part="trigger"
          popoverTarget={`${id}-menu`}
          aria-haspopup="menu"
          onKeyDown={(event) => {
            if (event.key === "ArrowDown") {
              event.preventDefault()
              openAndFocus()
            }
          }}
        >
          {trigger}
        </button>
        <span data-part="hint">{keysLabel}</span>
        <div
          id={`${id}-menu`}
          ref={menu}
          popover="auto"
          role="menu"
          aria-label={trigger}
          data-part="menu"
          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)
            }
          }}
        >
          {items.map((item) => (
            <div key={item.label}>
              {item.danger ? <div data-part="rule" role="separator" /> : null}
              <button
                type="button"
                role="menuitem"
                data-part="item"
                data-danger={item.danger || undefined}
                onClick={() => menu.current?.hidePopover()}
              >
                <svg viewBox="0 0 24 24" fill="none" aria-hidden="true">
                  <path
                    d={PATHS[item.icon]}
                    stroke="currentColor"
                    strokeWidth="1.6"
                    strokeLinecap="round"
                    strokeLinejoin="round"
                  />
                </svg>
                <span>{item.label}</span>
                <kbd data-part="keys">{item.keys}</kbd>
              </button>
            </div>
          ))}
        </div>
      </div>
    </>
  )
}