Svelte 5 component · fractalsvelte-ui · Overlays
DropdownMenu
A contextual action menu supporting list items, separators, icon prefixes, and shortcut badges. Implements keyboard arrow navigation and closes automatically upon item activation.
Install
DropdownMenu is a Svelte 5 dropdown menu component in @fractaldesign/fractalsvelte-ui. It works in SvelteKit and in any Svelte 5 app.
pnpm add @fractaldesign/fractalsvelte-uiImport
import { DropdownMenu } from '@fractaldesign/fractalsvelte-ui';Usage
<DropdownMenu open={false} placement={'bottom-start'} closeOnSelect={true}>Click me</DropdownMenu>Props
Also accepts every attribute of HTMLAttributes (without children), spread onto
the root element.
| Name | Type | Default |
|---|---|---|
open bindable | boolean Bindable visibility state | false |
placement | Placement 'top' | 'top-start' | 'top-end' | 'bottom' | 'bottom-start' | 'bottom-end' | 'left' | 'left-start' | 'left-end' | 'right' | 'right-start' | 'right-end' Position placement of menu relative to trigger | 'bottom-start' |
closeOnSelect | boolean Automatically close menu upon item selection | true |
items | DropdownMenuItem[] Array of menu item configurations | [] |
class | string Additional CSS classes for menu panel | '' |
Snippets
| Name | Type | Default |
|---|---|---|
trigger | Snippet<[{ open: boolean; toggle: () => void }]> Trigger element snippet | — |
children | Snippet<[{ close: () => void }]> Custom menu body content replacing items | — |
Source
Show the DropdownMenu.svelte source
<script lang="ts">
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { Placement } from '../../types';
export interface DropdownMenuItem {
/** Unique identifier */
id?: string;
/** Display label text */
label?: string;
/** Optional icon identifier or text */
icon?: string;
/** Visual keyboard shortcut hint */
shortcut?: string;
/** Highlights item with destructive/danger styling */
danger?: boolean;
/** Disables item selection */
disabled?: boolean;
/** Renders a divider line instead of an item */
separator?: boolean;
/** Action callback when item is activated */
onclick?: () => void;
}
interface Props extends Omit<HTMLAttributes<HTMLDivElement>, 'children'> {
/** Bindable visibility state */
open?: boolean;
/** Position placement of menu relative to trigger */
placement?: Placement;
/** Automatically close menu upon item selection */
closeOnSelect?: boolean;
/** Array of menu item configurations */
items?: DropdownMenuItem[];
/** Additional CSS classes for menu panel */
class?: string;
/** Trigger element snippet */
trigger?: Snippet<[{ open: boolean; toggle: () => void }]>;
/** Custom menu body content replacing items */
children?: Snippet<[{ close: () => void }]>;
}
let {
open = $bindable(false),
placement = 'bottom-start',
closeOnSelect = true,
items = [],
class: className = '',
trigger,
children,
...restProps
}: Props = $props();
let containerEl: HTMLDivElement | undefined = $state();
let menuEl: HTMLDivElement | undefined = $state();
let focusedIndex = $state(-1);
function toggle() {
open = !open;
if (open) {
focusedIndex = -1;
}
}
function close() {
open = false;
focusedIndex = -1;
}
function handleItemClick(item: DropdownMenuItem) {
if (item.disabled || item.separator) return;
item.onclick?.();
if (closeOnSelect) {
close();
}
}
function handleKeyDown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
event.preventDefault();
close();
return;
}
if (items.length > 0) {
const selectableItems = items.filter((i) => !i.separator && !i.disabled);
if (selectableItems.length === 0) return;
if (event.key === 'ArrowDown') {
event.preventDefault();
focusedIndex = (focusedIndex + 1) % items.length;
while (items[focusedIndex]?.separator || items[focusedIndex]?.disabled) {
focusedIndex = (focusedIndex + 1) % items.length;
}
} else if (event.key === 'ArrowUp') {
event.preventDefault();
focusedIndex = (focusedIndex - 1 + items.length) % items.length;
while (items[focusedIndex]?.separator || items[focusedIndex]?.disabled) {
focusedIndex = (focusedIndex - 1 + items.length) % items.length;
}
} else if ((event.key === 'Enter' || event.key === ' ') && focusedIndex >= 0) {
event.preventDefault();
const item = items[focusedIndex];
if (item) handleItemClick(item);
}
}
}
$effect(() => {
if (!open || typeof document === 'undefined') return;
function handleClickOutside(event: MouseEvent) {
const target = event.target as Node;
if (containerEl && !containerEl.contains(target) && (!menuEl || !menuEl.contains(target))) {
close();
}
}
document.addEventListener('mousedown', handleClickOutside, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside, true);
};
});
</script>
<svelte:window onkeydown={handleKeyDown} />
<div
bind:this={containerEl}
class="f-dropdown-container"
>
{#if trigger}
{@render trigger({ open, toggle })}
{/if}
{#if open}
<div
bind:this={menuEl}
role="menu"
tabindex="-1"
class="f-dropdown-panel {className}"
data-placement={placement}
{...restProps}
>
{#if children}
{@render children({ close })}
{:else}
{#each items as item, index}
{#if item.separator}
<div role="separator" class="f-dropdown-separator"></div>
{:else}
<button
type="button"
role="menuitem"
class="f-dropdown-item"
data-danger={item.danger ? 'true' : undefined}
data-focused={focusedIndex === index ? 'true' : undefined}
disabled={item.disabled}
onclick={() => handleItemClick(item)}
>
{#if item.icon}
<span class="f-dropdown-item-icon">{item.icon}</span>
{/if}
<span class="f-dropdown-item-label">{item.label}</span>
{#if item.shortcut}
<span class="f-dropdown-item-shortcut">{item.shortcut}</span>
{/if}
</button>
{/if}
{/each}
{/if}
</div>
{/if}
</div>
<style>
@layer fractalsvelte-ui.components {
.f-dropdown-container {
position: relative;
display: inline-block;
}
.f-dropdown-panel {
position: absolute;
z-index: var(--f-z-dropdown, 1000);
min-width: 180px;
background: var(--f-surface);
color: var(--f-text);
border: 1px solid var(--f-border);
border-radius: var(--f-radius-lg);
box-shadow: var(--f-shadow-lg);
padding: var(--f-space-3xs);
box-sizing: border-box;
outline: none;
animation: f-dropdown-appear var(--f-duration-fast) var(--f-ease);
}
/* Placements */
.f-dropdown-panel[data-placement='bottom-start'] {
top: calc(100% + 4px);
left: 0;
}
.f-dropdown-panel[data-placement='bottom-end'] {
top: calc(100% + 4px);
right: 0;
}
.f-dropdown-panel[data-placement='bottom'] {
top: calc(100% + 4px);
left: 50%;
transform: translateX(-50%);
}
.f-dropdown-panel[data-placement='top-start'] {
bottom: calc(100% + 4px);
left: 0;
}
.f-dropdown-panel[data-placement='top-end'] {
bottom: calc(100% + 4px);
right: 0;
}
.f-dropdown-separator {
height: 1px;
margin: var(--f-space-3xs) 0;
background: var(--f-border-subtle);
}
.f-dropdown-item {
display: flex;
align-items: center;
width: 100%;
padding: var(--f-space-xs) var(--f-space-sm);
border: none;
border-radius: var(--f-radius-sm);
background: transparent;
color: var(--f-text);
font-family: inherit;
font-size: var(--f-text-sm);
text-align: left;
cursor: pointer;
user-select: none;
box-sizing: border-box;
gap: var(--f-space-sm);
transition: background var(--f-duration-fast) var(--f-ease),
color var(--f-duration-fast) var(--f-ease);
}
.f-dropdown-item:hover:not(:disabled),
.f-dropdown-item[data-focused='true'] {
background: var(--f-hover);
}
.f-dropdown-item:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.f-dropdown-item[data-danger='true'] {
color: var(--f-danger);
}
.f-dropdown-item[data-danger='true']:hover:not(:disabled),
.f-dropdown-item[data-danger='true'][data-focused='true'] {
background: var(--f-danger-bg);
color: var(--f-danger);
}
.f-dropdown-item-icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: var(--f-text-sm);
}
.f-dropdown-item-label {
flex: 1;
min-width: 0;
}
.f-dropdown-item-shortcut {
font-size: var(--f-text-xs);
color: var(--f-text-muted);
font-family: var(--f-font-mono);
margin-left: auto;
}
@keyframes f-dropdown-appear {
from {
opacity: 0;
transform: scale(0.96) translateY(-4px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
}
</style>Related components
Popover A floating contextual container anchored to a trigger element with configurable directional placements and…Toast A floating alert notification featuring an auto-dismiss timer progress bar, intent icons, action buttons,…ToastStack A fixed notification stack container positioning toasts at viewport corners with smooth insertion and…Tooltip A transient informational tooltip triggered on hover or focus with configurable delay thresholds.AlertDialog A high-priority confirmation dialog designed for destructive or critical actions.CenterMorphModal A smooth expanding modal dialog that morphs directly from its originating trigger button with backdrop blur,…