Dropdown Menu
A list of actions that opens from a button.
Our own menu builder on Melt UI Popover
My account
Profile ⇧⌘P
Billing
Team
Settings
Status bar
Log out
Last action: none. Status bar: on.
<script lang="ts">
import * as DropdownMenu from '#lib/components/ui/dropdown-menu/index.js';
let showStatusBar = $state(true);
let lastAction = $state('none');
</script>
<DropdownMenu.Root>
<DropdownMenu.Trigger>Open menu</DropdownMenu.Trigger>
<DropdownMenu.Content style="--dui-dropdown-menu-min-width: 14rem">
<DropdownMenu.Label>My account</DropdownMenu.Label>
<DropdownMenu.Group>
<DropdownMenu.Item onSelect={() => (lastAction = 'profile')}>
Profile <DropdownMenu.Shortcut>⇧⌘P</DropdownMenu.Shortcut>
</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (lastAction = 'billing')}>Billing</DropdownMenu.Item>
<DropdownMenu.Item disabled>Team</DropdownMenu.Item>
<DropdownMenu.Item onSelect={() => (lastAction = 'settings')}>Settings</DropdownMenu.Item>
</DropdownMenu.Group>
<DropdownMenu.Separator />
<DropdownMenu.CheckboxItem bind:checked={showStatusBar}>Status bar</DropdownMenu.CheckboxItem>
<DropdownMenu.Separator />
<DropdownMenu.Item variant="destructive" onSelect={() => (lastAction = 'logout')}>
Log out
</DropdownMenu.Item>
</DropdownMenu.Content>
</DropdownMenu.Root>
<p>Last action: {lastAction}. Status bar: {showStatusBar ? 'on' : 'off'}.</p>
<style>
p {
margin: var(--dui-space-3) 0 0;
color: var(--dui-color-muted-foreground);
font-size: var(--dui-text-sm);
}
</style>
Installation
npx distill-ui add dropdown-menuIt also copies Button, which Dropdown Menu uses.
It installs @floating-ui/domandmelt if you don't have them yet.
- Set up the tokens once, if you haven't: see manual installation.
- Install the packages it needs:
npm install @floating-ui/dom@^1.6.0 melt@0.44.0 - Copy Button too, which it uses.
- Copy these files into your project:
import { getContext, setContext } from 'svelte';
import type { Menu } from './menu.svelte.js';
type DropdownMenuContext = { menu: Menu };
const key = Symbol('distill-dropdown-menu');
export const setDropdownMenuContext = (ctx: DropdownMenuContext) => setContext(key, ctx);
export const getDropdownMenuContext = () => getContext<DropdownMenuContext>(key);
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getDropdownMenuContext } from './context.js';
let {
checked = $bindable(false),
disabled = false,
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & { checked?: boolean; disabled?: boolean } = $props();
const { menu } = getDropdownMenuContext();
</script>
<!-- Stays open on select, so several options can be toggled in a row. -->
<div
{...restProps}
{...menu.getItem({ disabled, closeOnSelect: false, onSelect: () => (checked = !checked) })}
role="menuitemcheckbox"
aria-checked={checked}
class={['item', className]}
>
<svg class="check" viewBox="0 0 24 24" aria-hidden="true" data-checked={checked || undefined}>
<path d="M20 6 9 17l-5-5" />
</svg>
{@render children?.()}
</div>
<style>
.item {
position: relative;
display: flex;
align-items: center;
gap: var(--dui-space-2);
padding: var(--dui-space-1-5) var(--dui-space-2) var(--dui-space-1-5) var(--dui-space-8);
border-radius: var(--dui-radius-sm);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
cursor: default;
outline: none;
user-select: none;
&:focus {
background: var(--dui-color-accent);
color: var(--dui-color-accent-foreground);
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
}
.check {
position: absolute;
left: var(--dui-space-2);
width: 1rem;
height: 1rem;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
visibility: hidden;
&[data-checked] {
visibility: visible;
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getDropdownMenuContext } from './context.js';
let {
class: className,
children,
style,
...restProps
}: HTMLAttributes<HTMLDivElement> = $props();
const { menu } = getDropdownMenuContext();
</script>
<!-- Uses the native popover API (top layer), so no portal is needed. -->
<div
{...restProps}
{...menu.content}
style="{menu.content.style}; {style ?? ''}"
class={['content', className]}
>
{@render children?.()}
</div>
<!--
Options you can set from a parent or on the dropdown menu content itself:
--dui-dropdown-menu-min-width, --dui-dropdown-menu-radius, --dui-dropdown-menu-bg, --dui-dropdown-menu-fg
-->
<style>
.content {
min-width: var(--dui-dropdown-menu-min-width, 8rem);
max-height: var(--melt-popover-available-height);
margin: 0;
padding: var(--dui-space-1);
overflow-x: hidden;
overflow-y: auto;
border: 0;
border-radius: var(--dui-dropdown-menu-radius, var(--dui-radius-md));
background: var(--dui-dropdown-menu-bg, var(--dui-color-popover));
color: var(--dui-dropdown-menu-fg, var(--dui-color-popover-foreground));
box-shadow:
0 0 0 1px color-mix(in oklch, var(--dui-color-foreground) 10%, transparent),
var(--dui-shadow-md);
outline: none;
transition:
opacity var(--dui-duration-fast) var(--dui-ease-out),
scale var(--dui-duration-fast) var(--dui-ease-out);
@starting-style {
opacity: 0;
scale: 0.95;
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div {...restProps} role="group" class={['group', className]}>
{@render children?.()}
</div>
<style>
.group {
display: block;
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
import { getDropdownMenuContext } from './context.js';
let {
disabled = false,
variant = 'default',
inset = false,
onSelect,
class: className,
children,
...restProps
}: HTMLAttributes<HTMLDivElement> & {
disabled?: boolean;
variant?: 'default' | 'destructive';
/** Indent to line up with items that have a check mark or icon. */
inset?: boolean;
onSelect?: () => void;
} = $props();
const { menu } = getDropdownMenuContext();
</script>
<div
{...restProps}
{...menu.getItem({ disabled, onSelect })}
role="menuitem"
class={['item', className]}
data-variant={variant}
data-inset={inset || undefined}
>
{@render children?.()}
</div>
<style>
.item {
position: relative;
display: flex;
align-items: center;
gap: var(--dui-space-2);
padding: var(--dui-space-1-5) var(--dui-space-2);
border-radius: var(--dui-radius-sm);
font-size: var(--dui-text-sm);
line-height: var(--dui-text-sm-line-height);
cursor: default;
outline: none;
user-select: none;
&:focus {
background: var(--dui-color-accent);
color: var(--dui-color-accent-foreground);
}
&[data-disabled] {
pointer-events: none;
opacity: 0.5;
}
&[data-inset] {
padding-inline-start: var(--dui-space-8);
}
& :global(svg) {
flex-shrink: 0;
width: 1rem;
height: 1rem;
pointer-events: none;
}
}
.item[data-variant='destructive'] {
color: color-mix(in oklch, var(--dui-color-destructive) 80%, var(--dui-color-foreground));
&:focus {
background: color-mix(in oklch, var(--dui-color-destructive) 10%, transparent);
color: color-mix(in oklch, var(--dui-color-destructive) 80%, var(--dui-color-foreground));
}
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLDivElement> = $props();
</script>
<div {...restProps} class={['label', className]}>
{@render children?.()}
</div>
<style>
.label {
padding: var(--dui-space-1-5) var(--dui-space-2);
color: var(--dui-color-muted-foreground);
font-size: var(--dui-text-xs);
line-height: var(--dui-text-xs-line-height);
font-weight: var(--dui-font-weight-medium);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let {
class: className,
...restProps
}: Omit<HTMLAttributes<HTMLDivElement>, 'children'> = $props();
</script>
<div {...restProps} role="separator" class={['separator', className]}></div>
<style>
.separator {
height: 1px;
margin: var(--dui-space-1) calc(var(--dui-space-1) * -1);
background: var(--dui-color-border);
}
</style>
<script lang="ts">
import type { HTMLAttributes } from 'svelte/elements';
let { class: className, children, ...restProps }: HTMLAttributes<HTMLSpanElement> = $props();
</script>
<span {...restProps} class={['shortcut', className]}>
{@render children?.()}
</span>
<style>
.shortcut {
margin-inline-start: auto;
/* Pulled toward the foreground so it passes AA contrast on the highlighted item. */
color: color-mix(in oklch, var(--dui-color-muted-foreground) 70%, var(--dui-color-foreground));
font-size: var(--dui-text-xs);
letter-spacing: 0.1em;
}
</style>
<script lang="ts">
import type { HTMLButtonAttributes } from 'svelte/elements';
import { Button, type ButtonSize, type ButtonVariant } from '../button/index.js';
import { getDropdownMenuContext } from './context.js';
let {
variant = 'outline',
children,
...restProps
}: HTMLButtonAttributes & { variant?: ButtonVariant; size?: ButtonSize } = $props();
const { menu } = getDropdownMenuContext();
</script>
<Button {...restProps} {...menu.trigger} {variant}>
{@render children?.()}
</Button>
<script lang="ts">
import type { Placement } from '@floating-ui/dom';
import type { Snippet } from 'svelte';
import { setDropdownMenuContext } from './context.js';
import { Menu } from './menu.svelte.js';
let {
open = $bindable(false),
placement = 'bottom-start',
children
}: { open?: boolean; placement?: Placement; children?: Snippet } = $props();
const menu = new Menu({
open: () => open,
onOpenChange: (v) => (open = v),
floatingConfig: () => ({ computePosition: { placement }, offset: { mainAxis: 4 } })
});
setDropdownMenuContext({ menu });
</script>
{@render children?.()}
import CheckboxItem from './dropdown-menu-checkbox-item.svelte';
import Content from './dropdown-menu-content.svelte';
import Group from './dropdown-menu-group.svelte';
import Item from './dropdown-menu-item.svelte';
import Label from './dropdown-menu-label.svelte';
import Separator from './dropdown-menu-separator.svelte';
import Shortcut from './dropdown-menu-shortcut.svelte';
import Trigger from './dropdown-menu-trigger.svelte';
import Root from './dropdown-menu.svelte';
export { Root, Trigger, Content, Item, CheckboxItem, Label, Separator, Group, Shortcut };
import { BasePopover, type PopoverProps } from 'melt/builders';
/*
* Melt has no menu builder, so this adds one on top of Melt's popover, which already handles
* positioning, Escape, outside clicks and returning focus to the trigger. This file adds the
* WAI-ARIA menu button pattern: roles, arrow keys, Home/End and typeahead.
* https://www.w3.org/WAI/ARIA/apg/patterns/menu-button/
*/
type MenuProps = Pick<PopoverProps, 'open' | 'onOpenChange' | 'floatingConfig'>;
const ITEM_SELECTOR = '[role^="menuitem"]:not([aria-disabled="true"])';
export class Menu extends BasePopover {
#triggerId: string;
#typeahead = '';
#typeaheadTimer: ReturnType<typeof setTimeout> | undefined;
constructor(props: MenuProps = {}) {
// We move focus ourselves when the menu opens (first or last item), so tell Melt not to.
super({ ...props, focus: { onOpen: () => null } });
this.#triggerId = `${this.ids.popover}-trigger`;
}
get trigger() {
const invoker = this.getInvoker();
return {
...invoker,
id: this.#triggerId,
'aria-haspopup': 'menu' as const,
'aria-expanded': this.open,
'aria-controls': this.ids.popover,
'data-open': this.open ? '' : undefined,
onclick: (e: MouseEvent) => {
invoker.onclick(e);
if (this.open) this.#focusItem('first');
},
onkeydown: (e: KeyboardEvent & { currentTarget: HTMLElement }) => {
if (!['ArrowDown', 'ArrowUp'].includes(e.key)) return;
e.preventDefault();
this.triggerEl = e.currentTarget;
this.open = true;
this.#focusItem(e.key === 'ArrowUp' ? 'last' : 'first');
}
};
}
get content() {
return {
...this.getPopover(),
role: 'menu' as const,
'aria-labelledby': this.#triggerId,
'aria-orientation': 'vertical' as const,
onkeydown: (e: KeyboardEvent & { currentTarget: HTMLElement }) => this.#onKeydown(e)
};
}
/** Props for one item. `onSelect` runs on click, Enter or Space. */
getItem({
disabled = false,
closeOnSelect = true,
onSelect
}: {
disabled?: boolean;
closeOnSelect?: boolean;
onSelect?: () => void;
} = {}) {
return {
tabindex: -1,
'aria-disabled': disabled ? ('true' as const) : undefined,
'data-disabled': disabled ? '' : undefined,
onclick: () => {
if (disabled) return;
onSelect?.();
if (closeOnSelect) this.open = false;
},
onkeydown: (e: KeyboardEvent & { currentTarget: HTMLElement }) => {
if (e.key !== 'Enter' && e.key !== ' ') return;
e.preventDefault();
e.currentTarget.click();
},
// Moving the pointer over an item focuses it, so keyboard and mouse share one highlight.
onpointermove: (e: PointerEvent & { currentTarget: HTMLElement }) => {
if (disabled) return;
e.currentTarget.focus({ preventScroll: true });
},
onpointerleave: (e: PointerEvent & { currentTarget: HTMLElement }) => {
if (document.activeElement === e.currentTarget) this.#contentEl()?.focus();
}
};
}
#contentEl() {
return document.getElementById(this.ids.popover);
}
#items() {
return [...(this.#contentEl()?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])];
}
#focusItem(which: 'first' | 'last') {
// Wait a tick for the popover to open before moving focus into it.
setTimeout(() => {
const items = this.#items();
(which === 'first' ? items[0] : items.at(-1))?.focus();
});
}
#onKeydown(e: KeyboardEvent) {
const items = this.#items();
const index = items.indexOf(document.activeElement as HTMLElement);
const move = (i: number) => {
e.preventDefault();
items[(i + items.length) % items.length]?.focus();
};
if (e.key === 'ArrowDown') return move(index + 1);
if (e.key === 'ArrowUp') return move(index < 0 ? -1 : index - 1);
if (e.key === 'Home') return move(0);
if (e.key === 'End') return move(-1);
if (e.key === 'Tab') return (this.open = false);
// Typeahead: typing letters jumps to the next item that starts with them.
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
clearTimeout(this.#typeaheadTimer);
this.#typeahead += e.key.toLowerCase();
this.#typeaheadTimer = setTimeout(() => (this.#typeahead = ''), 500);
const ordered = [...items.slice(index + 1), ...items.slice(0, index + 1)];
const match = ordered.find((item) =>
item.textContent?.trim().toLowerCase().startsWith(this.#typeahead)
);
match?.focus();
}
}
}
Customizing
Set any of these on the component or on a parent element. See Customizing for how they work.
| Custom property | Default |
|---|---|
--dui-dropdown-menu-min-width | 8rem |
--dui-dropdown-menu-radius | var(--dui-radius-md) |
--dui-dropdown-menu-bg | var(--dui-color-popover) |
--dui-dropdown-menu-fg | var(--dui-color-popover-foreground) |