GrDropdownMenu

  • core
  • overlays
  • API · 16 props
  • ⌨ keys

A ready-made actions menu for a button, link or context invocation.

import { GrDropdownMenu } from '@feugene/granularity/components/GrDropdownMenu'

Overview

When to take it

  • actions over an object — the ”⋯” of a table row, of a card, of a file: the typical menu is assembled with the items prop;
  • there are many items and they are heterogeneous — groups, headings, separators and columns are already there;
  • some of the items are unavailable — a disabled item stays in the keyboard walk and is announced rather than disappearing;
  • markup is needed without assembling it by hand — the layer, the roles and the keyboard arrive from GrDropdown as a whole.

When to take something else

NeedTake
The items are non-standard, you write the markupGrDropdown
There is a form or a filter insideGrPopover
There are two or three actions and they fit in a rowGrButtonGroup
Navigation across the sections of the applicationGrSidebar
A search across the commands of the whole applicationGrCommandPalette

Playground controls come from the API, not from hand

Stage

Loading…

0 changed· state is in the URL

Examples 4 each with a live demo and its code

  • Actions over a table row

    A menu described by items: icons, shortcuts, dividers, a danger item and a submenu from children — it opens the next level and never comes in select. A disabled item stays in the arrow-key order, and on a voided invoice disabled keeps the whole menu closed while the trigger stays focusable.

    InvoiceCustomerStatusAmountActions
    INV-2026-0418Northwind TradersOpen€1,284.50
    INV-2026-0417Contoso LtdPaid€432.00
    INV-2026-0409Fabrikam IncVoid€96.00

    3 invoices · open a row menu with the ⋯ button or Enter

  • An account menu from parts

    Composition for a menu the model cannot describe: a header of your own, GrDropdownMenuList and GrDropdownMenuItem with href, and GrDropdownMenuSub for the theme. Each level is a layer of its own, so Esc closes the submenu, not the whole menu.

    Acme
  • Switches that stay open

    Groups of menuitemradio and menuitemcheckbox items with checked: a screen reader hears the state, not just the label. close-on-content-click="false" lets several settings change in one opening, and the list follows at once.

    Issues 3
    • ACM-212 Webhook retriesDoing
    • ACM-209 EU invoice bucketDone
    • ACM-204 Drop CSV exportTodo
  • A «New» menu in two columns

    GrDropdownMenuColumns keeps two kinds of things apart without a long single list, each column under its GrDropdownMenuGroup title. width takes any CSS length — auto sizes the panel by its content — and border-top on the last list draws a line that stops short of the rounded corners.

    BillingOctober 2026 · 214 invoices

API generated from the package · cannot drift

Props

PropTypedefaultDescription
placementPlacement | undefined"bottom-end"The placement of the panel relative to the trigger; the flip when there is not enough room still applies.
offsetnumber | undefined8The gap between the trigger and the panel, in px.
disabledboolean | undefinedfalseThe menu is opened by nothing; the trigger stays focusable.
openv-modelboolean | undefinedundefinedThe controlled state of the panel (`v-model:open`) — passed into `GrDropdown`. Without the prop the menu runs itself (uncontrolled).
itemsGrDropdownMenuEntry[] | undefinedundefinedA declarative menu: items, groups and separators as an array. The default slot is stronger — it is for a menu that is not assembled from a model.
triggerGrDropdownTrigger | undefined"click"What opens the panel. The click and the keyboard work in any mode.
teleportTostring | HTMLElement | undefinedundefinedA pointed override of the mounting point; by default — the shared portal.
contentClassstring | undefined""Extra classes for the content container.
listClassstring | undefined""Extra classes for the wrapper of the list.
dividersboolean | undefinedfalseSeparators between the items.
widthGrDropdownWidth | undefined"12rem"The width of the panel: a number is pixels, a string is a CSS length, `auto` is by the content.
openDelaynumber | undefined120The delay before opening on hover, in ms.
closeDelaynumber | undefined160The delay before closing after the cursor leaves, in ms.
closeOnContentClickboolean | undefinedtrueClose on a click inside the content.

Slots

SlotTypeDescription
default{ close: () => void; }The items of the menu. The slot props are passed from `GrDropdown` as they are.
trigger
{ open: boolean; toggle: () => void; close: () => void; trig{ open: boolean; toggle: () => void; close: () => void; triggerProps: Record<string, unknown>; }
The trigger of the panel. `triggerProps` are obliged to land on the interactive element itself rather than on a wrapper around it: `aria-expanded` and `aria-controls` are read from the node that gets the focus.

Events

EventSignatureDescription
update:open(event: "update:open", value: boolean): void—
select(event: "select", item: GrDropdownMenuAction): void—

Types

Every exported type of the subpath, grouped by the file it lives in. Names, generics and bodies come from the package’s declaration files and cannot drift.

Import
import type { GrDropdownMenuAction, GrDropdownMenuSeparator, GrDropdownMenuSection } from '@feugene/granularity/components/GrDropdownMenu'

Filedist/types/components/GrDropdownMenu/config.d.ts

grDropdownMenuConfigconst
const grDropdownMenuConfig: import("@feugene/granum/contract").GranumComponentDescriptor<"GrDropdownMenu">;

Filedist/types/components/GrDropdownMenu/safelist.d.ts

grDropdownMenuSafelistconst
const grDropdownMenuSafelist: string[];

Filedist/types/components/GrDropdownMenu/menuModel.d.ts

GrDropdownMenuActioninterface
interface GrDropdownMenuAction {
    key: string;
    label: string;
    icon?: Component;
    shortcut?: string;
    disabled?: boolean;
    variant?: GrDropdownMenuItemVariant;
    role?: GrDropdownMenuItemRole;
    checked?: boolean;
    href?: string;
    target?: string;
    rel?: string;
    external?: boolean;
    as?: string | Component;
    align?: GrDropdownMenuItemAlign;
    children?: GrDropdownMenuEntry[];
}
GrDropdownMenuSeparatorinterface
interface GrDropdownMenuSeparator {
    type: 'divider';
    inset?: boolean;
}
GrDropdownMenuSectioninterface
interface GrDropdownMenuSection {
    type: 'group';
    title?: string;
    titleAlign?: GrDropdownMenuHeaderAlign;
    dividers?: boolean;
    uppercase?: boolean;
    items: GrDropdownMenuAction[];
}
GrDropdownMenuEntrytype
type GrDropdownMenuEntry = GrDropdownMenuAction | GrDropdownMenuSeparator | GrDropdownMenuSection;

Filedist/types/components/GrDropdownMenu/GrDropdownMenu.vue.d.ts

GrDropdownMenuPropsinterface
interface GrDropdownMenuProps {
    placement?: UseFloatingPlacement;
    offset?: number;
    width?: GrDropdownWidth;
    trigger?: GrDropdownTrigger;
    openDelay?: number;
    closeDelay?: number;
    disabled?: boolean;
    teleportTo?: string | HTMLElement;
    closeOnContentClick?: boolean;
    contentClass?: string;
    items?: GrDropdownMenuEntry[];
    dividers?: boolean;
    borderTop?: boolean;
    borderBottom?: boolean;
    listClass?: string;
    open?: boolean;
}
GrDropdownMenuEmitsinterface
interface GrDropdownMenuEmits {
    (e: 'select', item: GrDropdownMenuAction): void;
    (e: 'update:open', value: boolean): void;
}

Filedist/types/components/GrDropdownMenu/grDropdownMenuStyles.d.ts

GrDropdownMenuItemAligntype
type GrDropdownMenuItemAlign = 'left' | 'center' | 'right';
GrDropdownMenuItemVarianttype
type GrDropdownMenuItemVariant = 'default' | 'danger';
GrDropdownMenuHeaderAligntype
type GrDropdownMenuHeaderAlign = GrDropdownMenuItemAlign;
GrDropdownMenuColumnAligntype
type GrDropdownMenuColumnAlign = GrDropdownMenuItemAlign;

Filedist/types/components/GrDropdownMenu/GrDropdownMenuColumn.vue.d.ts

GrDropdownMenuColumnPropsinterface
interface GrDropdownMenuColumnProps {
    align?: GrDropdownMenuColumnAlign;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuColumns.vue.d.ts

GrDropdownMenuColumnsPropsinterface
interface GrDropdownMenuColumnsProps {
    cols?: GrDropdownMenuColumnsCount;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuDivider.vue.d.ts

GrDropdownMenuDividerPropsinterface
interface GrDropdownMenuDividerProps {
    inset?: boolean;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuGroup.vue.d.ts

GrDropdownMenuGroupPropsinterface
interface GrDropdownMenuGroupProps {
    title?: string;
    titleAlign?: GrDropdownMenuHeaderAlign;
    dividers?: boolean;
    uppercase?: boolean;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuHeader.vue.d.ts

GrDropdownMenuHeaderPropsinterface
interface GrDropdownMenuHeaderProps {
    title?: string;
    align?: GrDropdownMenuHeaderAlign;
    uppercase?: boolean;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuItem.vue.d.ts

GrDropdownMenuItemRoletype
type GrDropdownMenuItemRole = 'menuitem' | 'menuitemcheckbox' | 'menuitemradio';
GrDropdownMenuItemPropsinterface
interface GrDropdownMenuItemProps {
    as?: string | Component;
    disabled?: boolean;
    href?: string;
    target?: string;
    rel?: string;
    external?: boolean;
    align?: GrDropdownMenuItemAlign;
    variant?: GrDropdownMenuItemVariant;
    role?: GrDropdownMenuItemRole;
    checked?: boolean;
    icon?: Component;
    shortcut?: string;
    expanded?: boolean;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuList.vue.d.ts

GrDropdownMenuListPropsinterface
interface GrDropdownMenuListProps {
    dividers?: boolean;
    borderTop?: boolean;
    borderBottom?: boolean;
}

Filedist/types/components/GrDropdownMenu/GrDropdownMenuSub.vue.d.ts

GrDropdownMenuSubPropsinterface
interface GrDropdownMenuSubProps {
    label?: string;
    icon?: Component;
    disabled?: boolean;
    variant?: GrDropdownMenuItemVariant;
    align?: GrDropdownMenuItemAlign;
    placement?: UseFloatingPlacement;
    offset?: number;
    minWidth?: number | string;
    openDelay?: number;
    closeDelay?: number;
    dividers?: boolean;
    contentClass?: string;
    listClass?: string;
}

Filedist/types/components/GrDropdownMenu/index.d.ts

GrDropdownMenuSubInstancetype
type GrDropdownMenuSubInstance = ComponentExposed<typeof GrDropdownMenuSubComponent>;

Accessibility

APG pattern

menu

Keys

SpaceEscTab

Usage from the library prose

The roles: why they cannot be skipped

The panel of GrDropdown declares role="menu", and that role makes all of its descendants presentational. The roles here are therefore not an ornament but the condition on which the menu exists for a screen reader at all:

  • an item is role="menuitem" (or menuitemcheckbox/menuitemradio);
  • a separator is role="separator";
  • a group is role="group" with a name from its heading through aria-labelledby;
  • the heading of a group is role="presentation";
  • the list, the columns and a column are role="none": a wrapper between menu and menuitem breaks aria-required-children.
The focus and the disabled items

The items are not tabbable (tabindex="-1"): in the menu pattern it is the trigger that stays tabbable, and inside the panel the focus is walked by the arrows — that is disposed of by GrDropdown. Otherwise Tab would walk the items and the menu would be a mere list of buttons.

disabled is dimmed with aria-disabled rather than with the native disabled: the item stays focusable and enters the walk with the arrows, so the user learns that the action exists but is currently unavailable (a recommendation of the WAI-ARIA APG). The click and Enter are intercepted in the process, and the background and the text come from the disabled tokens rather than from opacity.

Toggle items

role="menuitemcheckbox" and role="menuitemradio" require aria-checked — the component sets it in both states, otherwise AT reads the item as an ordinary command. The room for the mark is always taken when the item is toggleable: otherwise the “on” and “off” rows shift horizontally against each other.

vue
<GrDropdownMenuItem role="menuitemcheckbox" :checked="showArchived" @click="toggle">
  Show the archive
</GrDropdownMenuItem>

The icon and the keyboard shortcut are set with the icon / shortcut props or with the #icon / #shortcut slots (a slot is stronger).

A menu from a model

Composition remains for non-standard items: GrDropdownMenuList is the wrapper of the list, GrDropdownMenuGroup and GrDropdownMenuHeader are a section with a heading, GrDropdownMenuItem and GrDropdownMenuDivider are an item and a separator, and GrDropdownMenuColumns with GrDropdownMenuColumn are the layout into columns. All of them arrive from the same subpath as the menu.

But nine menus out of ten are of the same kind — it is simpler to describe them with an array:

vue
<GrDropdownMenu :items="items" @select="onSelect" />
ts
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Rename', shortcut: '⌘R' },
  { type: 'divider' },
  { type: 'group', title: 'View', items: [
    { key: 'compact', label: 'Compact', role: 'menuitemradio', checked: true },
  ] },
  { key: 'delete', label: 'Delete', variant: 'danger' },
]

select is not emitted for a disabled item. An item with href renders as a link. The default slot is stronger than the model: if both have been passed, the slot wins.

The model can do what composition can. An item accepts as — a tag or a router component — and align; a group knows titleAlign, dividers and uppercase. This is not a convenience: without as a link item from the model stays an ordinary <a>, that is, in an SPA the transition goes through a page reload, and there is no way around it — the model can be unfolded into props only inside the component. It was enough for that field to fall behind GrDropdownMenuItem for the consumer to have to rewrite the whole walk over the model for the sake of a single link.

ts
const items: GrDropdownMenuEntry[] = [
  { key: 'profile', label: 'Profile', href: '/profile', as: RouterLink },
]
The second level: submenus

An item with non-empty children becomes an opener: it performs nothing and no select arrives for it — it opens the panel of the next level. In composition the same is given by GrDropdownMenuSub.

ts
const items: GrDropdownMenuEntry[] = [
  { key: 'rename', label: 'Rename' },
  { key: 'export', label: 'Export', children: [
    { key: 'pdf', label: 'PDF' },
    { key: 'csv', label: 'CSV' },
  ] },
]
vue
<GrDropdownMenuSub label="Export">
  <GrDropdownMenuItem @click="save('pdf')">PDF</GrDropdownMenuItem>
</GrDropdownMenuSub>

The depth is not limited, and children accepts the same entries as the root: inside a submenu separators, groups and the next level all make sense.

The levels are layers of their own, not nested markup. Every panel goes into a portal, so in the DOM they lie side by side rather than one inside another. Everything else follows from that: Escape closes the top level rather than the whole menu; a selection in a submenu closes the whole chain; a click on an opener does not reach the parent panel, otherwise closeOnContentClick would close the menu at the very moment the next level opened.

The cursor is given a corridor. The path from an item to its submenu runs diagonally and passes over the neighbouring items. While the cursor is inside the triangle “point of departure → the near edge of the opened panel”, leaving counts as movement towards the target: the panel does not close, and the neighbours do not open their submenus — they will open one as soon as it becomes clear that the cursor was not heading for the panel. Without that a submenu is lost halfway.

The keyboard — ../keyboard.md: → opens, ← returns to the opener, Tab closes the menu as a whole.

href and the toggle roles are incompatible with children: an item cannot be a link and an opener at once — in the dev build that is a warning.

Styling

variant="danger" colours an item with the --gr-danger-text role rather than with a saturated tone: a saturated tone as a text colour does not pass contrast. Disabled is dimmed with a background (--gr-muted) rather than with opacity, and lets through neither a click nor a keyboard activation — a handler on the item itself is stopped with stopImmediatePropagation.

An item is rounded and fitted into the padding of the panel. The background of the highlight lies on the item itself, and the panel of GrDropdown is rounded and does not clip its content: a rectangle across the full width would fill the corner segments cut out by its radius. The item therefore has a radius of its own, one step smaller than that of the panel — the same device as in the options of GrSelect and GrAutocomplete.

Suppressing the padding of the panel with your own p-0 is not allowed for that: both classes land in the same attribute with equal specificity, and the winner is chosen by the order of the rules in the generated CSS rather than by the markup. If a panel without padding is needed, that is a separate channel, not an override with a class.

The borderTop / borderBottom lines do not reach the edges — and that is not an oversight. The list lies inside the padding of the panel: 1 px of border plus 4 px of p-1. The corner of the panel is rounded by 16 px, that is, at a depth of 5 px the arc is still running — a rule across the full width would run not into the vertical edge but into it, and at every corner a wedge where the line and the border converge would be visible. The line is therefore drawn with a pseudo-element inset by 8 px, the same as in GrDropdownMenuDivider :inset. The items stay full width in the process: their width is part of the same fitting of the highlight into the padding of the panel.

The rule is general for rounded surfaces: nothing full width comes closer to the edge of a panel than its radius. The geometry is held by an e2e check (apps/showcase/e2e/geometry.spec.ts) — in jsdom utilities do not turn into CSS, and there is no seeing where the end of a line landed.

None of this concerns the separators between items: dividers and GrDropdownMenuDivider lie far from the corners and read fine across the full width.

All of the classes of the catalogue live in grDropdownMenuStyles.ts and are declared in the safelist: a .ts helper is moved by the bundler into a shared chunk, outside the scan area of the component, and without the safelist an isolated consumer would lose the alignment, the columns and the colours.

Control from the outside: v-model:open

The open prop and the update:open event are passed into the wrapped GrDropdown as they are — the contract is the same as in GrDropdown.