Standard Dropmenu
Action menu for tasks like edit, duplicate, share, or delete. Use buttons for items that trigger actions on the current page
<div class="nds-dropmenu">
<button class="nds-btn nds-secondary-outline nds-dropmenu-trigger">
<i class="nds-icon nds-hgi-menu-01" aria-hidden="true"></i>
<span class="nds-label">Actions</span>
</button>
<div class="nds-dropmenu-menu" hidden>
<div class="nds-dropmenu-scroll">
<button class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-edit-02"></i>
<span class="nds-label">Edit</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item">
<i class="nds-icon nds-hgi-copy-01"></i>
<span class="nds-label">Duplicate</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item">
<i class="nds-icon nds-hgi-share-01" aria-hidden="true"></i>
<span class="nds-label">Share</span>
</button>
<hr class="nds-divider">
<button class="nds-btn nds-subtle nds-dropmenu-item nds-destructive">
<i class="hgi hgi-stroke hgi-delete-02"></i>
<span class="nds-label">Delete</span>
</button>
</div>
</div>
</div>
Navigation Dropmenu
Menu items that navigate to other pages. Use anchor elements when each item is a link rather than an action
<div class="nds-dropmenu">
<button class="nds-btn nds-secondary-outline nds-dropmenu-trigger">
<span class="nds-label">Account</span>
<i class="hgi hgi-stroke hgi-user-circle"></i>
</button>
<div class="nds-dropmenu-menu" hidden>
<div class="nds-dropmenu-scroll">
<a href="#" class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-user-account"></i>
<span class="nds-label">Profile</span>
</a>
<a href="#" class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-settings-01"></i>
<span class="nds-label">Settings</span>
</a>
<a href="#" class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-help-circle"></i>
<span class="nds-label">Help</span>
</a>
<hr class="nds-divider">
<a href="#" class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-logout-01"></i>
<span class="nds-label">Sign out</span>
</a>
</div>
</div>
</div>
Scrollable Menu with No Auto-Close
Use nds-dropmenu-scroll for scrollable content areas and data-no-auto-close to keep the menu open when interacting with form controls
<div class="nds-dropmenu">
<button class="nds-btn nds-neutral nds-menu-btn nds-dropmenu-trigger">
<i class="hgi hgi-stroke hgi-settings-02"></i>
<span class="nds-label">Settings</span>
</button>
<div class="nds-dropmenu-menu" style="min-width: 220px;" hidden>
<div class="nds-dropmenu-scroll">
<!-- data-no-auto-close keeps menu open on interaction -->
<fieldset class="nds-dropmenu-group nds-form-group nds-check-group"
data-no-auto-close>
<legend class="nds-label">Notifications</legend>
<div class="nds-form-container nds-switch-container">
<div class="nds-form-header">
<label for="setting-email">
<span class="nds-label">Email</span>
</label>
</div>
<div class="nds-form-control">
<div class="nds-switch">
<input type="checkbox" id="setting-email"
class="nds-switch-input" checked>
<div class="nds-switch-track">
<div class="nds-switch-thumb"></div>
</div>
</div>
</div>
</div>
<!-- more switches... -->
</fieldset>
<hr class="nds-divider">
<fieldset class="nds-dropmenu-group nds-form-group nds-check-group"
data-no-auto-close>
<legend class="nds-label">Display</legend>
<div class="nds-form-container nds-check-container">
<div class="nds-form-header">
<label for="setting-compact">
<span class="nds-label">Compact view</span>
</label>
</div>
<div class="nds-form-control">
<input type="checkbox" id="setting-compact" class="nds-check">
</div>
</div>
<!-- more checkboxes... -->
</fieldset>
</div>
<div class="nds-dropmenu-footer">
<hr class="nds-divider">
<div class="nds-dropmenu-action">
<button class="nds-btn nds-secondary nds-dropmenu-item"
type="button" data-no-auto-close>
<span class="nds-label">Reset</span>
</button>
<button class="nds-btn nds-primary nds-dropmenu-item"
type="button">
<span class="nds-label">Save</span>
</button>
</div>
</div>
</div>
</div>
Dropmenu Inside Table
Row-level action menus inside tables need data-portal on the wrapper. The menu then moves to <body> on open (viewport-anchored, position: fixed) so it escapes the table cell's overflow clipping and stacking context, and it follows the trigger as the page scrolls — no auto-close on scroll. Without data-portal the default in-place mode would clip behind the row border or hide under an adjacent z-indexed cell.
| Name | Role | Status | |
|---|---|---|---|
| Nora Al-Zahrani | Administrator | Active |
|
| Yousef Al-Harbi | Editor | Away |
|
| Layla Al-Qahtani | Viewer | Offline |
|
<table class="nds-table">
<thead>
<tr>
<th>Name</th>
<th>Role</th>
<th>Status</th>
<th></th>
</tr>
</thead>
<tbody>
<tr>
<td>Nora Al-Zahrani</td>
<td>Administrator</td>
<td>
<span class="nds-tag nds-sm" data-status="success">
<span class="nds-label">Active</span>
</span>
</td>
<td>
<!-- data-portal: escape the table cell's clipping/stacking context
so the menu can extend beyond the row when opened. -->
<div class="nds-dropmenu" data-portal>
<button class="nds-btn nds-sm nds-subtle nds-dropmenu-trigger"
aria-label="Row actions">
<i class="hgi hgi-stroke hgi-more-horizontal-circle-01"></i>
</button>
<div class="nds-dropmenu-menu" hidden>
<div class="nds-dropmenu-scroll">
<button class="nds-btn nds-subtle nds-dropmenu-item">
<i class="hgi hgi-stroke hgi-edit-02"></i>
<span class="nds-label">Edit</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item">
<i class="nds-icon nds-hgi-copy-01"></i>
<span class="nds-label">Duplicate</span>
</button>
<hr class="nds-divider">
<button class="nds-btn nds-subtle nds-dropmenu-item
nds-destructive">
<i class="hgi hgi-stroke hgi-delete-02"></i>
<span class="nds-label">Delete</span>
</button>
</div>
</div>
</div>
</td>
</tr>
<!-- more rows... -->
</tbody>
</table>
Select Mode
Add data-select-name to turn a dropmenu into a value picker. Each item needs data-value; the chosen value is written to a hidden input so the selection ships with a form, and the trigger label updates to reflect the current selection. Add nds-center to the .nds-dropmenu-menu to center-align every item's label — handy for compact pickers whose labels are short (numbers, currencies, states). To re-slice a live list, point the picker at a Pagination container with data-per-page-target (see Pagination · Per-page Picker).
<!-- data-select-name: field name for the hidden input
data-select-value: pre-select "10" on init
nds-center on the menu: each item label sits centered
nds-menu-btn: caret appears via the button's own ::after -->
<div class="nds-dropmenu"
data-select-name="perPage"
data-select-value="10">
<button class="nds-btn nds-secondary-outline nds-md nds-menu-btn nds-dropmenu-trigger" type="button">
<span class="nds-label">10</span>
</button>
<div class="nds-dropmenu-menu nds-center" hidden>
<div class="nds-dropmenu-scroll">
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="5"><span class="nds-label">5</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="10"><span class="nds-label">10</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="25"><span class="nds-label">25</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="50"><span class="nds-label">50</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="100"><span class="nds-label">100</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="250"><span class="nds-label">250</span></button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="500"><span class="nds-label">500</span></button>
</div>
</div>
</div>
// nds:dropmenu:selected fires on the wrapper when an item is chosen.
// detail: { dropmenu, item, value } — here we surface it as a toast.
document.getElementById('dropmenu-select-demo')
.addEventListener('nds:dropmenu:selected', (e) => {
NDS.Alert.create({
variant: 'success',
title: 'Per-page updated',
description: `Now showing ${e.detail.value} items per page.`,
display: 'toast',
position: 'top',
duration: 3000
});
});
// The hidden input also fires a native `change` event so the selection
// integrates with standard form libraries and submit handlers.
Searchable
Add data-search to inject a search box at the top of the menu. Match is case- and diacritic-insensitive, so "cafe" matches "café" and "مطار" matches "المَطار". Set a threshold with data-search="50" to skip injection when the menu holds fewer than N filterable items. Per-item data-search-value="…" appends extra tokens (English aliases next to an Arabic label, IDs, keywords). Opt an item OUT with data-search-item="false" to keep it always visible below the empty-state message. Override the "No results" text via data-search-empty="…"
<!-- data-search: always-on. Use data-search="50" to only show the box
when the menu holds ≥ 50 items. Combines with SELECT MODE — the
chosen item ships as `country` in the form, its Arabic label mirrors
to the trigger. data-search-value adds English aliases so "Saudi"
matches the Arabic row. -->
<div class="nds-dropmenu" data-search
data-select-name="country" data-select-value="SA">
<button class="nds-btn nds-secondary-outline nds-menu-btn nds-dropmenu-trigger" type="button">
<span class="nds-label">Choose a country</span>
</button>
<div class="nds-dropmenu-menu" hidden>
<div class="nds-dropmenu-scroll">
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="SA" data-search-value="Saudi Arabia KSA">
<span class="nds-label">المَملَكة العَربيَّة السَعوديَّة</span>
</button>
<button class="nds-btn nds-subtle nds-dropmenu-item" data-value="AE" data-search-value="United Arab Emirates UAE">
<span class="nds-label">الإمارات العربية المتحدة</span>
</button>
<!-- … -->
</div>
</div>
</div>
Delayed-open (Lazy Load)
Add data-delay="<ms>" to defer menu content until first open. On the first trigger click the button shows a loading state for the specified delay, fires nds:dropmenu:prepare so you can populate the menu, then opens. Subsequent opens skip the delay.
<!-- data-delay="1200": show loading state for 1200ms on first open,
fire nds:dropmenu:prepare, then open. One-shot; removed after first use. -->
<div class="nds-dropmenu" id="my-lazy-dropmenu" data-delay="1200">
<button class="nds-btn nds-secondary-outline nds-dropmenu-trigger">
<i class="hgi hgi-stroke hgi-list-view" aria-hidden="true"></i>
<span class="nds-label">Load items</span>
</button>
<div class="nds-dropmenu-menu" hidden>
<div class="nds-dropmenu-scroll">
<!-- Populated by nds:dropmenu:prepare handler -->
</div>
</div>
</div>
// nds:dropmenu:prepare fires after the loading state, before the menu opens.
// Build and insert items into the menu here.
// detail: { dropmenu, trigger, menu, isOpen }
document.getElementById('my-lazy-dropmenu').addEventListener('nds:dropmenu:prepare', (e) => {
const scroll = e.detail.menu.querySelector('.nds-dropmenu-scroll');
if (scroll.children.length) return; // already populated (fast double-click guard)
const items = ['Dashboard', 'Reports', 'Analytics', 'Settings'];
items.forEach(label => {
const btn = document.createElement('button');
btn.className = 'nds-btn nds-subtle nds-dropmenu-item';
btn.innerHTML = `<span class="nds-label">${label}</span>`;
scroll.appendChild(btn);
});
});
Built-in Features
Dropmenus initialize automatically on page load and can be dynamically reinitialized using NDS.Dropmenu.reinit() for content added after initial load.
Menus stay fully visible regardless of where the trigger sits on the page, flipping direction when near screen edges.
Full keyboard support with arrow keys, Home, End, Tab, Escape, and special handling for input fields inside menus using Alt+Arrow combinations.
ARIA roles and states are applied automatically, and animations respect the user's reduced-motion preference.
Menu items with data-no-auto-close stay open on click, letting users interact with checkboxes, inputs, and filter controls without interruption.
Programmatic control with instance methods open(), close(), toggle(), and custom events for state changes.
Usage Guidelines
Best Practices
- Use dropmenus for action lists when screen space is limited and actions don't need to be immediately visible
- Use for contextual actions that apply to a specific item, like row-level edit, delete, or share
- Use for secondary navigation that doesn't need permanent visibility, like account or settings links
- Use with
data-no-auto-closefor filter panels where users select multiple options before closing - Group related actions together and use
<hr class="nds-divider">to separate action groups - Keep menus focused with 3-8 items. If you need more, consider restructuring into multiple menus or a different pattern
- Icons are optional but recommended for faster visual scanning when actions have clear iconic representations
- Don't use for primary navigation that should always be visible. Use the Header or Side Nav instead
- Don't use for a single toggle action. A Switch or standalone Button is simpler
- Don't use for complex multi-step forms. Use a Modal or Drawer for more space
Modifier Classes
| Class | Description |
|---|---|
nds-destructive |
Applies destructive (red) styling to a menu item. Place destructive actions last in the menu for visual separation. |
nds-dropmenu-footer |
Sticky footer area that stays visible while the menu content scrolls. Place outside .nds-dropmenu-scroll. |
nds-dropmenu-action |
Action bar inside the footer for buttons like Clear and Apply. Buttons lay out side-by-side and share the row equally. |
nds-dropmenu-group |
Groups form controls or related items inside the menu with consistent spacing. |
Data Attributes
| Attribute | Element | Description |
|---|---|---|
data-no-auto-close |
.nds-dropmenu-item |
Prevents the menu from closing when that item is clicked. Use for checkboxes, inputs, and filter controls that need multiple interactions. |
data-portal |
.nds-dropmenu |
Moves the menu to <body> on open. Use only when the menu must escape an ancestor stacking context (a card or modal with z-index, a transformed wrapper). The portaled menu uses position: fixed and tracks its trigger on scroll (rAF-throttled) so it stays anchored without closing. |
data-anchor-cursor |
.nds-dropmenu |
Anchors the menu horizontally to the click position, opening it under the cursor like a context menu. Useful for wide triggers where the default trigger-centered placement feels disconnected from the click. Applies to mouse clicks only; keyboard and programmatic opens fall back to trigger-center. |
data-select-name="<name>" |
.nds-dropmenu |
Turns the dropmenu into a value picker. A hidden <input name="..."> is appended so the selection ships with the enclosing form. Each selectable item must carry data-value. Fires nds:dropmenu:selected on selection. |
data-select-value="<value>" |
.nds-dropmenu |
Pre-selects the item whose data-value matches. Takes priority over any data-state~="selected" pre-rendered on an item. Only meaningful when data-select-name is also set. |
data-required |
.nds-dropmenu |
Adds required to the hidden input created by data-select-name, making the field participate in native form validation. |
data-value="<value>" |
.nds-dropmenu-item |
The submitted value for this item when the dropmenu is in select mode (data-select-name). Items without data-value do not trigger selection. |
data-trigger-label="<text>" |
.nds-dropmenu-item |
Custom short label written to the trigger button when this item is selected. Falls back to the item's .nds-label text. Useful when the menu row is descriptive ("Saudi Arabia (+966)") but the trigger slot needs something compact ("+966"). |
data-dropmenu-primary |
.nds-dropmenu-item |
Marks a menu item as the primary action. When the user presses Enter on a non-actionable menu element (not a button or anchor), the first non-disabled item with this attribute is clicked. Use to provide a default action on Enter inside embedded form controls. |
data-dropmenu-no-click |
.nds-dropmenu |
Disables the trigger click handler so the dropmenu can only be opened programmatically (via instance.open() or instance.toggle()). Used by components like the date-picker that control open timing themselves. |
data-dropmenu-no-keys |
.nds-dropmenu |
Disables the built-in keyboard navigation (ArrowDown/Up, Home, End, Tab) on the trigger and menu. Escape-to-close remains active. Use when a parent component owns its own keyboard handling (for example, a 2D grid like the date-picker's day cells). |
data-delay="<ms>" |
.nds-dropmenu |
Enables delayed first-open (lazy-load) mode. On first trigger click the trigger shows a loading state for <ms> milliseconds, fires nds:dropmenu:prepare so a consumer can populate the menu, then opens. One-shot: the attribute is removed after the first open so subsequent opens are immediate. |
Positioning & Portal
By default the menu uses position: absolute anchored to its wrapper. It scrolls with the trigger like a native <select> — no close-on-scroll, no DOM reparenting. Initial placement is viewport-aware: the menu flips above the trigger when space below is tight, and clamps horizontally so it never overflows the viewport.
Add data-portal to the wrapper only when the menu needs to escape an ancestor stacking context (a card or modal with z-index, a transformed or filtered wrapper). Portaled menus use position: fixed at <body> level and follow the trigger on scroll (rAF-throttled) instead of closing.
CSS Custom Properties
| Property | Default | Description |
|---|---|---|
--menu-padding |
8px | Inner padding of the menu container. Set on the .nds-dropmenu wrapper; JS snapshots the value onto the menu before portaling so it survives a DOM move to <body>. |
--dropmenu-width |
min-content |
Explicit width of the menu. By default the menu sizes to its content. Set to a fixed value (e.g. 240px) to lock the width regardless of content. |
--dropmenu-min-width |
max-content |
Minimum width of the menu. Defaults to max-content so the menu never wraps its widest item. Override inline or via CSS to enforce a fixed floor. |
--dropmenu-max-width |
calc(100vw - 16px) |
Maximum width of the menu. Defaults to near-full-viewport to prevent overflow on narrow screens. Override to constrain wide menus. |
--dropmenu-slide |
8px | Distance the menu slides during open/close animation. The direction reverses automatically when the menu flips above the trigger. |
JavaScript API
// ── Auto-initialization ────────────────────────────────────
// Dropmenus initialize automatically on page load
// ── Reinitialization ───────────────────────────────────────
// Call after dynamically adding new dropmenus to the page
NDS.Dropmenu.reinit();
// ── Manual creation ────────────────────────────────────────
const element = document.querySelector('.nds-dropmenu');
const instance = NDS.Dropmenu.create(element);
// ── Static helpers ─────────────────────────────────────────
// Walk up from any descendant (including portaled menus) to the wrapper
const wrapper = NDS.Dropmenu.from(someChildElement);
// Get the menu element for a wrapper (portal-aware)
const menu = NDS.Dropmenu.menuOf(wrapper);
// ── Instance methods ───────────────────────────────────────
// Access instance via element.ndsDropmenu
const dropmenu = document.querySelector('.nds-dropmenu');
const instance = dropmenu.ndsDropmenu;
instance.open(); // Open the menu
instance.close(); // Close the menu
instance.toggle(); // Toggle open/closed state
instance.destroy(); // Release listeners and undo init — the wrapper survives, so references stay valid
// ── Events ─────────────────────────────────────────────────
// Listen for state changes
document.addEventListener('nds:dropmenu:opened', (e) => {
// e.detail: { dropmenu, trigger, menu, isOpen }
console.log('Menu opened:', e.detail.dropmenu);
});
document.addEventListener('nds:dropmenu:closed', (e) => {
// e.detail: { dropmenu, trigger, menu, isOpen }
console.log('Menu closed:', e.detail.dropmenu);
});
// Fired when a select-mode item is chosen (data-select-name dropmenu)
document.addEventListener('nds:dropmenu:selected', (e) => {
// e.detail: { dropmenu, item, value }
console.log('Selected value:', e.detail.value, 'Item:', e.detail.item);
});
// Fired during delayed-first-open (data-delay) after the loading state,
// before the menu opens. Populate menu content here.
document.querySelector('.nds-dropmenu').addEventListener('nds:dropmenu:prepare', (e) => {
// e.detail: { dropmenu, trigger, menu, isOpen }
const menu = e.detail.menu;
// Build and insert items into menu now
});
// ── Keyboard navigation ────────────────────────────────────
// Trigger button:
// Enter/Space: toggle menu
// ArrowDown: open and focus first item
// ArrowUp: open and focus last item
// All three prefer the item marked data-state="active" (the menu's
// current value, e.g. pagination's current page) when one exists
//
// Inside menu:
// ArrowDown/Up: navigate items
// Home/End: jump to first/last item
// Tab/Shift+Tab: navigate focusable elements
// Escape: close menu and return focus to trigger
//
// Inside input fields:
// Alt+ArrowDown/Up: navigate menu items
// Ctrl+Home/End: jump to first/last item