A menu activated by a button, representing a set of actions or displaying a list of options for user selection. Built on top of Popover and Listbox components with keyboard navigation support.
import { Dropdown } from 'frontile';
A feature-rich action menu showcasing icons, descriptions, shortcuts, dividers, and color intents.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
import {
ViewIcon,
EditIcon,
DuplicateIcon,
ShareIcon,
DownloadIcon,
ArchiveIcon,
DeleteIcon
} from 'site/components/icons';
export default class BasicDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action triggered:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>
Project Actions
</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item
@key='view'
@description='Open in read-only mode'
@shortcut='mod+o'
>
<:start><ViewIcon /></:start>
<:default>View Details</:default>
</Item>
<Item
@key='edit'
@description='Make changes to project'
@shortcut='mod+e'
>
<:start><EditIcon /></:start>
<:default>Edit Project</:default>
</Item>
<Item
@key='duplicate'
@description='Create a copy'
@shortcut='mod+d'
@withDivider={{true}}
>
<:start><DuplicateIcon /></:start>
<:default>Duplicate</:default>
</Item>
<Item
@key='share'
@color='primary'
@description='Invite team members'
@shortcut='mod+shift+s'
>
<:start><ShareIcon /></:start>
<:default>Share</:default>
</Item>
<Item @key='export' @color='success' @description='Download as file'>
<:start><DownloadIcon /></:start>
<:default>Export</:default>
</Item>
<Item
@key='archive'
@color='warning'
@description='Move to archived projects'
@withDivider={{true}}
>
<:start><ArchiveIcon /></:start>
<:default>Archive Project</:default>
</Item>
<Item
@key='delete'
@color='danger'
@description='Permanently delete'
@class='text-danger'
@shortcut='mod+backspace'
>
<:start><DeleteIcon /></:start>
<:default>Delete Project</:default>
</Item>
</d.Menu>
</Dropdown>
</template>
}
Add descriptions and keyboard shortcuts to menu items for better UX.
Note: The
@shortcutargument is for display purposes only. You'll need to implement actual keyboard shortcut handling in your application using a library or custom implementation.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DropdownWithDetails extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>Account</d.Trigger>
<d.Menu @onAction={{this.onAction}} @color='primary' as |Item|>
<Item @key='profile' @description='View and edit your profile'>
My Profile
</Item>
<Item
@key='settings'
@description='Manage preferences'
@shortcut='mod+,'
>
Settings
</Item>
<Item @key='billing' @description='View billing details'>
Billing
</Item>
<Item
@key='team'
@description='Manage team members'
@withDivider={{true}}
>
Team
</Item>
<Item @key='logout' @color='danger' @class='text-danger'>
Log Out
</Item>
</d.Menu>
</Dropdown>
</template>
}
Enable single or multiple selection mode for choosing options.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SelectableDropdown extends Component {
@tracked selectedKeys = ['bold'];
@action
handleSelectionChange(keys: Set<string>) {
this.selectedKeys = Array.from(keys);
// eslint-disable-next-line
console.log('Selected:', this.selectedKeys);
}
<template>
<div class='demo-stack items-center'>
<Dropdown @closeOnItemSelect={{false}} as |d|>
<d.Trigger @size='sm'>Text Formatting</d.Trigger>
<d.Menu
@selectionMode='multiple'
@selectedKeys={{this.selectedKeys}}
@onSelectionChange={{this.handleSelectionChange}}
as |Item|
>
<Item @key='bold'>Bold</Item>
<Item @key='italic'>Italic</Item>
<Item @key='underline'>Underline</Item>
<Item @key='strikethrough'>Strikethrough</Item>
</d.Menu>
</Dropdown>
<div class='text-sm text-neutral-soft'>
Selected:
{{this.selectedKeys}}
</div>
</div>
</template>
}
Customize the trigger button appearance.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class TriggerStyles extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<div class='flex gap-2 flex-wrap'>
<Dropdown as |d|>
<d.Trigger @color='neutral' @size='sm'>Default</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>Primary</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @color='secondary' @size='sm'>Secondary</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @color='success' @size='sm'>Success</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @color='warning' @size='sm'>Warning</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
<Dropdown as |d|>
<d.Trigger @color='danger' @size='sm'>Danger</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
</d.Menu>
</Dropdown>
</div>
</template>
}
Control where the menu appears relative to the trigger.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Dropdown, ButtonGroup } from 'frontile';
export default class MenuPositioning extends Component {
@tracked placement = 'bottom-start';
placements = [
'top',
'top-start',
'top-end',
'bottom',
'bottom-start',
'bottom-end',
'left',
'right'
];
@action
setPlacement(placement: string) {
this.placement = placement;
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
isSelected = (p: string) => {
return p === this.placement;
};
<template>
<div class='demo-stack items-center'>
<div class='flex gap-2 flex-wrap'>
<ButtonGroup @size='xs' @color='primary' as |g|>
{{#each this.placements as |p|}}
<g.ToggleButton
@isSelected={{this.isSelected p}}
@onChange={{fn this.setPlacement p}}
>
{{p}}
</g.ToggleButton>
{{/each}}
</ButtonGroup>
</div>
<div class='flex justify-center items-center h-32'>
<Dropdown @placement={{this.placement}} as |d|>
<d.Trigger @color='primary' @size='sm'>
Menu ({{this.placement}})
</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</div>
</div>
</template>
}
Disable specific menu items.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DisabledItems extends Component {
disabledKeys = ['share', 'delete'];
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>File Actions</d.Trigger>
<d.Menu
@onAction={{this.onAction}}
@disabledKeys={{this.disabledKeys}}
as |Item|
>
<Item @key='open'>Open</Item>
<Item @key='rename'>Rename</Item>
<Item @key='share'>Share (Coming Soon)</Item>
<Item @key='download'>Download</Item>
<Item @key='delete' @color='danger' @class='text-danger'>
Delete (Unavailable)
</Item>
</d.Menu>
</Dropdown>
</template>
}
Prevent the menu from closing when items are selected.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class KeepOpenDropdown extends Component {
@tracked filters = ['recent'];
@action
handleSelectionChange(keys: Set<string>) {
this.filters = Array.from(keys);
// eslint-disable-next-line
console.log('Filters:', this.filters);
}
<template>
<div class='demo-stack items-center'>
<Dropdown @closeOnItemSelect={{false}} as |d|>
<d.Trigger @size='sm'>Filter Options</d.Trigger>
<d.Menu
@selectionMode='multiple'
@selectedKeys={{this.filters}}
@onSelectionChange={{this.handleSelectionChange}}
as |Item|
>
<Item @key='recent'>Recent</Item>
<Item @key='starred'>Starred</Item>
<Item @key='shared'>Shared with me</Item>
<Item @key='archived'>Archived</Item>
</d.Menu>
</Dropdown>
<div class='text-sm text-neutral-soft'>
Active filters:
{{this.filters}}
</div>
</div>
</template>
}
Organize menu items into logical groups.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SectionedDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>More Options</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='new-file'>New File</Item>
<Item @key='new-folder'>New Folder</Item>
<Item @key='upload' @withDivider={{true}}>Upload</Item>
<Item @key='copy'>Copy</Item>
<Item @key='move'>Move</Item>
<Item @key='rename' @withDivider={{true}}>Rename</Item>
<Item @key='export'>Export</Item>
<Item @key='share'>Share</Item>
<Item @key='delete' @color='danger' @class='text-danger'>
Delete
</Item>
</d.Menu>
</Dropdown>
</template>
}
Use individual click handlers for specific items.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class CustomHandlers extends Component {
@action
handleEdit() {
alert('Edit clicked');
}
@action
handleDelete() {
if (confirm('Are you sure you want to delete?')) {
alert('Deleted!');
}
}
@action
handleDownload() {
alert('Downloading...');
}
<template>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>Actions</d.Trigger>
<d.Menu as |Item|>
<Item @key='view'>View Details</Item>
<Item @key='edit' @onClick={{this.handleEdit}}>Edit</Item>
<Item @key='download' @onClick={{this.handleDownload}}>
Download
</Item>
<Item
@key='delete'
@color='danger'
@class='text-danger'
@onClick={{this.handleDelete}}
@withDivider={{true}}
>
Delete
</Item>
</d.Menu>
</Dropdown>
</template>
}
Control the backdrop appearance behind the dropdown menu.
import Component from '@glimmer/component';
import { tracked } from '@glimmer/tracking';
import { action } from '@ember/object';
import { fn } from '@ember/helper';
import { Dropdown, ButtonGroup } from 'frontile';
export default class DropdownBackdrop extends Component {
@tracked backdrop = 'none';
@action
setBackdrop(type: string) {
this.backdrop = type;
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
isActiveBackdrop = (type: string) => {
return this.backdrop === type;
};
<template>
<div class='demo-stack items-center'>
<ButtonGroup @size='xs' @color='primary' as |g|>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'none'}}
@onChange={{fn this.setBackdrop 'none'}}
>
No Backdrop
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'faded'}}
@onChange={{fn this.setBackdrop 'faded'}}
>
Faded
</g.ToggleButton>
<g.ToggleButton
@isSelected={{this.isActiveBackdrop 'blur'}}
@onChange={{fn this.setBackdrop 'blur'}}
>
Blur
</g.ToggleButton>
</ButtonGroup>
<Dropdown as |d|>
<d.Trigger @color='primary' @size='sm'>
Open Menu ({{this.backdrop}})
</d.Trigger>
<d.Menu
@backdrop={{this.backdrop}}
@onAction={{this.onAction}}
as |Item|
>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</div>
</template>
}
Execute a callback when the dropdown closes.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class DropdownWithCallback extends Component {
@action
handleDidClose() {
// eslint-disable-next-line
console.log('Dropdown closed');
}
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action:', key);
}
<template>
<Dropdown @didClose={{this.handleDidClose}} as |d|>
<d.Trigger @color='primary' @size='sm'>Dropdown</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item|>
<Item @key='option1'>Option 1</Item>
<Item @key='option2'>Option 2</Item>
<Item @key='option3'>Option 3</Item>
</d.Menu>
</Dropdown>
</template>
}
Group related actions behind a nested menu. Sub is a second, optional block
param yielded by d.Menu alongside Item — existing as |Item| code keeps
working unchanged, and you only reach for Sub where a nested menu is
actually needed. Submenus open on hover, on click, or with →, and
close with ← or Esc.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SubmenuDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action triggered:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @variant='outline' @size='sm'>Options</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item Sub|>
<Item @key='edit' @shortcut='mod+e'>Edit</Item>
<Item @key='duplicate' @shortcut='mod+d' @withDivider={{true}}>
Duplicate
</Item>
<Sub as |s|>
<s.Trigger>More</s.Trigger>
<s.Menu as |Item|>
<Item @key='move-project'>Move to project…</Item>
<Item @key='move-folder' @withDivider={{true}}>Move to folder…</Item>
<Item @key='advanced'>Advanced options…</Item>
</s.Menu>
</Sub>
<Item @key='share'>Share</Item>
<Item @key='delete' @color='danger' @shortcut='mod+backspace'>
Delete
</Item>
</d.Menu>
</Dropdown>
</template>
}
Submenus nest to any depth: a Sub's Menu yields Sub again.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class NestedSubmenuDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action triggered:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @variant='outline' @size='sm'>Share</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item Sub|>
<Item @key='copy-link'>Copy Link</Item>
<Item @key='facebook'>Facebook</Item>
<Sub as |s|>
<s.Trigger>Other</s.Trigger>
<s.Menu as |Item Sub|>
<Item @key='whatsapp'>WhatsApp</Item>
<Item @key='telegram'>Telegram</Item>
<Sub as |s|>
<s.Trigger>Email</s.Trigger>
<s.Menu as |Item|>
<Item @key='work-email'>Work email</Item>
<Item @key='personal-email'>Personal email</Item>
</s.Menu>
</Sub>
</s.Menu>
</Sub>
</d.Menu>
</Dropdown>
</template>
}
Two submenu rows next to each other are mutually exclusive: moving from one onto the other closes the first as the second opens, so only one level is ever on screen.
import Component from '@glimmer/component';
import { action } from '@ember/object';
import { Dropdown } from 'frontile';
export default class SiblingSubmenuDropdown extends Component {
@action
onAction(key: string) {
// eslint-disable-next-line
console.log('Action triggered:', key);
}
<template>
<Dropdown as |d|>
<d.Trigger @variant='outline' @size='sm'>Organize</d.Trigger>
<d.Menu @onAction={{this.onAction}} as |Item Sub|>
<Item @key='rename'>Rename</Item>
<Item @key='duplicate' @withDivider={{true}}>Duplicate</Item>
<Sub as |s|>
<s.Trigger>Move to</s.Trigger>
<s.Menu as |Item|>
<Item @key='move-inbox'>Inbox</Item>
<Item @key='move-archive'>Archive</Item>
<Item @key='move-trash'>Trash</Item>
</s.Menu>
</Sub>
<Sub as |s|>
<s.Trigger>Share with</s.Trigger>
<s.Menu as |Item|>
<Item @key='share-team'>Team</Item>
<Item @key='share-guests'>Guests</Item>
<Item @key='share-link'>Anyone with the link</Item>
</s.Menu>
</Sub>
</d.Menu>
</Dropdown>
</template>
}
Dropdown yields the pieces you assemble the menu from:
| Yielded | Purpose |
|---|---|
d.Trigger |
Button that opens the menu |
d.Menu |
The menu itself; yields an Item for each entry and, as a second block param, Sub for a nested menu |
Item |
A single menu entry |
Sub |
A nested menu, yielded alongside Item; yields its own s.Trigger and s.Menu |
A Sub's s.Menu yields Item and Sub again, the same as the root d.Menu, so menus can
nest to any depth.
The arguments declared once on the root d.Menu (@onAction, @selectionMode,
@selectedKeys, @disabledKeys, @allowEmpty, @onSelectionChange, @variant,
@intent, @shortcutVariant, @closeOnItemSelect, @disableTransitions,
@transitionDuration) apply at every depth, so a nested s.Menu only needs its own items.
Passing one of these same arguments to a nested s.Menu instead of the root has no effect —
the root's value always wins, silently — so declare them once, on d.Menu.
s.Trigger needs no @key — Sub generates one, and a sub-trigger never fires
@onAction. Sub positions its menu with @placement, @offsetOptions, @flipOptions,
@shiftOptions, @middleware and @strategy, defaulting to @placement="right-start".
Dropdown is a Popover wrapping a Listbox with @type="menu", and inherits from both.
The trigger — a real <button>, so it is focusable and activates on Enter and Space —
carries aria-haspopup="true", aria-controls pointing at the menu, and aria-expanded
kept in sync. The menu itself is role="menu" and its items carry aria-labelledby, plus
aria-disabled="true" for keys in @disabledKeys. Their role depends on whether the menu
selects, as below.
Menu items never carry aria-selected — it is invalid on a menu item, which conveys state
through aria-checked instead. When the menu selects, its rows take the matching checkable
role and report their state: menuitemcheckbox with @selectionMode="multiple",
menuitemradio with "single", each with aria-checked kept in sync. A menu that does not
select keeps plain menuitem rows and no checked state, and a row that opens a submenu stays
a plain menuitem whatever the level selects — activating it opens a menu rather than
toggling a value.
Menu items share the Listbox's roving tabindex: exactly one item carries tabindex="0" —
the active one, falling back to the first selected item and then to the first item that is not
disabled — and every other item is -1.
Which keys are handled depends on where focus is:
| Focus is on the trigger | Behavior |
|---|---|
Enter / Space |
Opens the menu, on key release |
ArrowDown / ArrowUp |
Opens the menu |
| any letter | Opens the menu (not with Cmd/Ctrl/Alt held) |
Escape |
Closes |
Tab |
Closes and moves on, without pulling focus back |
Once open, focus moves into the menu and the list takes over:
| Focus is in the menu | Behavior |
|---|---|
ArrowDown / ArrowUp |
Move the active item, within the open level |
ArrowRight |
Opens the active row's submenu and moves to its first item |
ArrowLeft |
Closes the current submenu and returns focus to its trigger; does nothing at the root level |
Home / PageUp, End / PageDown |
First / last item, within the open level |
Enter, Space |
Runs the active item's action, or opens its submenu |
| any single character | Type-ahead to a matching item, scoped to the open level |
Escape |
Closes the innermost open menu — at the root, focus returns to the trigger |
Because the content is inside an Overlay with a focus trap, Tab from within the menu cycles
inside it rather than leaving.
Opening the menu from the keyboard — Enter, Space, ArrowDown or ArrowUp on the
trigger — activates its first row, per the WAI-ARIA menu button pattern, so the keyboard is
immediately useful. Opening it with a pointer activates nothing, since moving the highlight
somewhere the user never pointed is exactly what that pattern avoids. Pass an explicit
@autoActivateMode to override either behaviour.
A submenu also opens on hovering its trigger, after a short delay, and stays open while the pointer travels toward it; moving onto a sibling row closes it. Only one submenu per level is ever open: opening one closes whichever sibling was open, so moving between two adjacent submenu rows swaps levels instead of stacking them. Opening a submenu by hover or click highlights nothing inside it — only opening it with the keyboard highlights its first row.
A submenu inherits its selection settings from the root menu, so @selectionMode,
@selectedKeys, @disabledKeys, @allowEmpty, @onAction, @onSelectionChange and
@closeOnItemSelect written once at the top apply at every depth. A submenu may also declare
any of those seven itself, which wins for that level and the levels below it — so a
navigation menu can hold a multi-select submenu without the root pretending to select. A
submenu that declares nothing keeps inheriting.
Only those seven are a submenu's to override. Appearance and timing — @variant, @color,
@shortcutVariant, @disableTransitions and @transitionDuration — stay the root's for the
whole chain, so one menu cannot end up looking like two.
Element: HTMLUListElement
| Name | Type | Default | Description |
|---|---|---|---|
closeOnItemSelect
|
boolean
|
true
|
Whether the dropdown should close upon selecting an item. |
didClose
|
function
|
- | Callback when closing has finished, including any exit transition. |
flipOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean | 'alignment'; fallbackPlacements?: Placement[]; fallbackStrategy?: 'bestFit' | 'initialPlacement'; fallbackAxisSideDirection?: 'start' | ... 1 more ... | 'none'; ... 4 more ...; boundary?: Boundary; }
|
- | Options for the floating-ui flip middleware, which moves the content to the opposite side when it would overflow the viewport. |
middleware
|
Array
|
- |
Additional floating-ui middleware, for positioning behavior beyond what
placement, offsetOptions, flipOptions, and shiftOptions cover.
|
offsetOptions
|
enum
|
5
|
|
placement
|
enum
|
'bottom-start'
|
Placement of the menu when open |
shiftOptions
|
{ padding?: Padding; mainAxis?: boolean; crossAxis?: boolean; rootBoundary?: RootBoundary; elementContext?: ElementContext; altBoundary?: boolean; limiter?: { ...; }; boundary?: Boundary; }
|
- | Options for the floating-ui shift middleware, which nudges the content along its axis to keep it in view. |
strategy
|
enum
|
'absolute'
|
| Name | Type | Default | Description |
|---|---|---|---|
default
*
|
Array
|
- |