Dropdown Menu
dropdown-menuuiFloating menu launched from a trigger button — for account switchers, row actions, editor menus, and any short list of commands. Supports labels, icons, separators, keyboard shortcuts, checkbox/radio items, and nested submenus. Hand-rolled on Svelte 5 runes (no headless dependency); ARIA + keyboard navigation handled.
Installation
$pnpm dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dropdown-menu.json$npx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dropdown-menu.json$yarn dlx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dropdown-menu.json$bunx shadcn-svelte@latest add https://uipkge.dev/r/svelte/dropdown-menu.jsonManifest:
https://uipkge.dev/r/svelte/dropdown-menu.jsonInstalls to:src/lib/components/ui/dropdown-menu/Examples
Loading interactive previews…
Loading interactive previews…
Props
| Name | Type / Values | Default | Required |
|---|---|---|---|
side | 'bottom''left''right''top' | — | optional |
openControlled open state. Use `bind:open` for two-way binding. | boolean | — | optional |
onOpenChangeCalled whenever the open state changes. | (open: boolean) => void | — | optional |
children | Snippet | — | optional |
Schema
Type aliases from this item's source — use them to shape the data you pass in.
DropdownMenuContextValueinterface DropdownMenuContextValue {
readonly ids: { trigger: string; content: string }
isOpen: () => boolean
setOpen: (open: boolean) => void
closeAndFocusTrigger: () => void
}DropdownMenuRadioContextValueinterface DropdownMenuRadioContextValue {
getValue: () => string
setValue: (value: string) => void
}DropdownMenuSubContextValueinterface DropdownMenuSubContextValue {
isSubOpen: () => boolean
setSubOpen: (open: boolean) => void
/** Close after a grace period so the pointer can travel between the
* trigger and the panel. Any setSubOpen call cancels the pending close. */
scheduleClose: (delay?: number) => void
readonly ids: { trigger: string; content: string }
}npm dependencies
Used by
Files installed (19)
src/lib/components/ui/dropdown-menu/DropdownMenu.svelte2.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface DropdownMenuProps { /** Controlled open state. Use `bind:open` for two-way binding. */ open?: boolean /** Called whenever the open state changes. */ onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { focusMenuItem, setMenuContext } from './dropdown-menu-context' let { open = $bindable(false), onOpenChange, children }: DropdownMenuProps = $props() const uid = $props.id() const ids = { trigger: `${uid}-trigger`, content: `${uid}-content` } function setOpen(next: boolean) { if (open === next) return open = next onOpenChange?.(next) } function closeAndFocusTrigger() { setOpen(false) document.getElementById(ids.trigger)?.focus() } setMenuContext({ ids, isOpen: () => open, setOpen, closeAndFocusTrigger, }) $effect(() => { if (!open) return function onPointerDown(e: PointerEvent) { const target = e.target as Node | null if (!target || !(target instanceof Element)) return // Clicks inside the trigger, content, or any sub-content keep the menu open. if (target.closest(`#${CSS.escape(ids.trigger)}, #${CSS.escape(ids.content)}, [data-slot="dropdown-menu-sub-content"]`)) return setOpen(false) } function onKeyDown(e: KeyboardEvent) { if (e.key === 'Escape') closeAndFocusTrigger() // Tabbing away dismisses; focus continues naturally. if (e.key === 'Tab') setOpen(false) // Arrowing from a focused trigger jumps into the menu (covers both the // default button and `child` triggers like Button). if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && document.activeElement?.id === ids.trigger) { e.preventDefault() focusMenuItem(ids.content, e.key === 'ArrowDown' ? 'first' : 'last') } } // Capture phase so a trigger toggle click (bubble) doesn't instantly re-close. document.addEventListener('pointerdown', onPointerDown, true) document.addEventListener('keydown', onKeyDown) return () => { document.removeEventListener('pointerdown', onPointerDown, true) document.removeEventListener('keydown', onKeyDown) } }) </script> {@render children?.()}src/lib/components/ui/dropdown-menu/DropdownMenuCheckboxItem.svelte2.2 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuCheckboxItemProps extends HTMLAttributes<HTMLDivElement> { /** Controlled checked state. Use `bind:checked` for two-way binding. */ checked?: boolean /** Called with the next checked value. Toggling keeps the menu open. */ onCheckedChange?: (checked: boolean) => void disabled?: boolean /** Custom indicator icon (default: Check). */ indicatorIcon?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Check } from '@lucide/svelte' import { cn } from '$lib/utils' let { class: className, checked = $bindable(false), onCheckedChange, disabled = false, indicatorIcon, children, ref = $bindable(null), onclick, ...restProps }: DropdownMenuCheckboxItemProps = $props() function toggle(e: Event) { if (disabled) return const next = !checked checked = next onCheckedChange?.(next) } </script> <div bind:this={ref} role="menuitemcheckbox" tabindex={disabled ? undefined : -1} aria-checked={checked} aria-disabled={disabled || undefined} data-uipkge data-slot="dropdown-menu-checkbox-item" data-state={checked ? 'checked' : 'unchecked'} data-disabled={disabled ? '' : undefined} class={cn( 'focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', className, )} onclick={(e) => { toggle(e) onclick?.(e) }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggle(e) } }} {...restProps} > <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> {#if checked} {#if indicatorIcon} {@render indicatorIcon()} {:else} <Check class="size-4" aria-hidden="true" /> {/if} {/if} </span> {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuContent.svelte4.2 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuContentProps extends HTMLAttributes<HTMLDivElement> { side?: 'top' | 'right' | 'bottom' | 'left' align?: 'start' | 'center' | 'end' sideOffset?: number alignOffset?: number ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { focusMenuItem, getMenuContext } from './dropdown-menu-context' import { dropdownMenuContentVariants } from './dropdown-menu-content.variants' let { class: className, side = 'bottom', align = 'start', sideOffset = 4, alignOffset = 0, children, ref = $bindable(null), onkeydown, ...restProps }: DropdownMenuContentProps = $props() const menu = getMenuContext() const open = $derived(menu.isOpen()) // Positioned `fixed` from the trigger rect (no portal needed). Hidden until // the first measurement so the menu never flashes at the origin. let style = $state('position: fixed; top: 0; left: 0; visibility: hidden;') function position() { const trigger = document.getElementById(menu.ids.trigger) const content = document.getElementById(menu.ids.content) if (!trigger || !content) return const t = trigger.getBoundingClientRect() const w = content.offsetWidth const h = content.offsetHeight let top = 0 let left = 0 if (side === 'bottom' || side === 'top') { top = side === 'bottom' ? t.bottom + sideOffset : t.top - h - sideOffset left = align === 'start' ? t.left + alignOffset : align === 'end' ? t.right - w - alignOffset : t.left + t.width / 2 - w / 2 } else { left = side === 'right' ? t.right + sideOffset : t.left - w - sideOffset top = align === 'start' ? t.top + alignOffset : align === 'end' ? t.bottom - h - alignOffset : t.top + t.height / 2 - h / 2 } left = Math.max(8, Math.min(left, window.innerWidth - w - 8)) top = Math.max(8, Math.min(top, window.innerHeight - 16)) const origin = side === 'bottom' ? `top ${align === 'start' ? 'left' : align === 'end' ? 'right' : 'center'}` : side === 'top' ? `bottom ${align === 'start' ? 'left' : align === 'end' ? 'right' : 'center'}` : side === 'right' ? 'center left' : 'center right' // The variants file intentionally keeps the twin's custom-property names // (see its comment); resolve them here from the measured placement. style = `position: fixed; top: ${top}px; left: ${left}px; ` + `--reka-dropdown-menu-content-available-height: ${Math.max(80, window.innerHeight - top - 8)}px; ` + `--reka-dropdown-menu-content-transform-origin: ${origin};` } $effect(() => { if (!open) return position() window.addEventListener('resize', position) // Capture phase: an ancestor scroll re-seats the menu under the trigger. window.addEventListener('scroll', position, true) return () => { window.removeEventListener('resize', position) window.removeEventListener('scroll', position, true) } }) function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault() focusMenuItem(menu.ids.content, 'next') } else if (e.key === 'ArrowUp') { e.preventDefault() focusMenuItem(menu.ids.content, 'prev') } else if (e.key === 'Home') { e.preventDefault() focusMenuItem(menu.ids.content, 'first') } else if (e.key === 'End') { e.preventDefault() focusMenuItem(menu.ids.content, 'last') } } </script> {#if open} <!-- outline-none replicates reka's content outline reset. --> <div bind:this={ref} id={menu.ids.content} role="menu" aria-orientation="vertical" tabindex="-1" data-uipkge data-slot="dropdown-menu-content" data-state="open" data-side={side} data-align={align} class={cn(dropdownMenuContentVariants({ side }), 'outline-none', className)} {style} onkeydown={(e) => { handleKeyDown(e) onkeydown?.(e) }} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/dropdown-menu/dropdown-menu-content.variants.ts1.5 kB
import type { VariantProps } from 'class-variance-authority' import { cva } from 'class-variance-authority' /** * Variant definitions live in their own file (rather than the package * `index.ts`) so consuming components can import without creating a circular * dependency through the index. * * Class strings are kept 1:1 with the Vue twin, including the two * `--reka-dropdown-menu-*` custom properties: DropdownMenuContent sets them * inline from its measured position (max-height clamps to the available * viewport space; transform-origin follows the placement side), so the shared * animation utilities resolve exactly as they do on reka-ui. */ export const dropdownMenuContentVariants = cva( 'z-50 max-h-(--reka-dropdown-menu-content-available-height) min-w-32 origin-(--reka-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95', { variants: { side: { bottom: 'data-[side=bottom]:slide-in-from-top-2', left: 'data-[side=left]:slide-in-from-right-2', right: 'data-[side=right]:slide-in-from-left-2', top: 'data-[side=top]:slide-in-from-bottom-2', }, }, }, ) export type DropdownMenuContentVariants = VariantProps<typeof dropdownMenuContentVariants>src/lib/components/ui/dropdown-menu/dropdown-menu-context.ts2.7 kB
import { getContext, setContext } from 'svelte' /** * Hand-rolled menu state (no headless dependency installed in the Svelte * registry yet). If bits-ui (the shadcn-svelte standard) is ever adopted, * these parts should be rebuilt on top of it. */ export interface DropdownMenuContextValue { readonly ids: { trigger: string; content: string } isOpen: () => boolean setOpen: (open: boolean) => void closeAndFocusTrigger: () => void } const MENU_KEY = Symbol('uipkge:dropdown-menu') export function setMenuContext(value: DropdownMenuContextValue): void { setContext(MENU_KEY, value) } export function getMenuContext(): DropdownMenuContextValue { return getContext<DropdownMenuContextValue>(MENU_KEY) } export interface DropdownMenuRadioContextValue { getValue: () => string setValue: (value: string) => void } const RADIO_KEY = Symbol('uipkge:dropdown-menu-radio') export function setRadioContext(value: DropdownMenuRadioContextValue): void { setContext(RADIO_KEY, value) } export function getRadioContext(): DropdownMenuRadioContextValue { return getContext<DropdownMenuRadioContextValue>(RADIO_KEY) } export interface DropdownMenuSubContextValue { isSubOpen: () => boolean setSubOpen: (open: boolean) => void /** Close after a grace period so the pointer can travel between the * trigger and the panel. Any setSubOpen call cancels the pending close. */ scheduleClose: (delay?: number) => void readonly ids: { trigger: string; content: string } } const SUB_KEY = Symbol('uipkge:dropdown-menu-sub') export function setSubContext(value: DropdownMenuSubContextValue): void { setContext(SUB_KEY, value) } export function getSubContext(): DropdownMenuSubContextValue { return getContext<DropdownMenuSubContextValue>(SUB_KEY) } const ITEM_SELECTOR = '[role="menuitem"]:not([data-disabled]), [role="menuitemcheckbox"]:not([data-disabled]), [role="menuitemradio"]:not([data-disabled])' /** Arrow-key / Home / End focus movement scoped to one menu container. */ export function focusMenuItem(containerId: string, where: 'first' | 'last' | 'next' | 'prev'): void { const container = document.getElementById(containerId) if (!container) return const items = [...container.querySelectorAll(ITEM_SELECTOR)].filter( (el) => el instanceof HTMLElement, ) as HTMLElement[] if (items.length === 0) { container.focus() return } const current = items.indexOf(document.activeElement as HTMLElement) const next = where === 'first' ? items[0] : where === 'last' ? items[items.length - 1] : where === 'next' ? items[(current + 1) % items.length] : items[(current - 1 + items.length) % items.length] next?.focus() }src/lib/components/ui/dropdown-menu/DropdownMenuGroup.svelte0.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuGroupProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> let { children, ref = $bindable(null), ...restProps }: DropdownMenuGroupProps = $props() </script> <div bind:this={ref} role="group" data-uipkge data-slot="dropdown-menu-group" {...restProps}> {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuItem.svelte2.1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuItemProps extends HTMLAttributes<HTMLDivElement> { disabled?: boolean inset?: boolean variant?: 'default' | 'destructive' /** Called on selection. Selecting an item closes the menu. */ onSelect?: (e: Event) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { getMenuContext } from './dropdown-menu-context' let { class: className, disabled = false, inset = false, variant = 'default', onSelect, children, ref = $bindable(null), onclick, ...restProps }: DropdownMenuItemProps = $props() const menu = getMenuContext() function select(e: Event) { if (disabled) return onSelect?.(e) menu.closeAndFocusTrigger() } </script> <div bind:this={ref} role="menuitem" tabindex={disabled ? undefined : -1} aria-disabled={disabled || undefined} data-uipkge data-slot="dropdown-menu-item" data-inset={inset ? '' : undefined} data-variant={variant} data-disabled={disabled ? '' : undefined} class={cn( 'focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', className, )} onclick={(e) => { select(e) onclick?.(e) }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() select(e) } }} {...restProps} > {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuLabel.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuLabelProps extends HTMLAttributes<HTMLDivElement> { inset?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, inset = false, children, ref = $bindable(null), ...restProps }: DropdownMenuLabelProps = $props() </script> <div bind:this={ref} data-uipkge data-slot="dropdown-menu-label" data-inset={inset ? '' : undefined} class={cn('px-2 py-1.5 text-sm font-medium data-[inset]:pl-8', className)} {...restProps} > {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuRadioGroup.svelte1 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuRadioGroupProps extends HTMLAttributes<HTMLDivElement> { /** Controlled selected value. Use `bind:value` for two-way binding. */ value?: string /** Called with the next value. Selecting keeps the menu open. */ onValueChange?: (value: string) => void ref?: HTMLDivElement | null } </script> <script lang="ts"> import { setRadioContext } from './dropdown-menu-context' let { value = $bindable(''), onValueChange, children, ref = $bindable(null), ...restProps }: DropdownMenuRadioGroupProps = $props() setRadioContext({ getValue: () => value, setValue: (next: string) => { if (value === next) return value = next onValueChange?.(next) }, }) </script> <div bind:this={ref} role="group" data-uipkge data-slot="dropdown-menu-radio-group" {...restProps}> {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuRadioItem.svelte2.1 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuRadioItemProps extends HTMLAttributes<HTMLDivElement> { value: string disabled?: boolean /** Custom indicator icon (default: filled Circle). */ indicatorIcon?: Snippet ref?: HTMLDivElement | null } </script> <script lang="ts"> import { Circle } from '@lucide/svelte' import { cn } from '$lib/utils' import { getRadioContext } from './dropdown-menu-context' let { class: className, value, disabled = false, indicatorIcon, children, ref = $bindable(null), onclick, ...restProps }: DropdownMenuRadioItemProps = $props() const group = getRadioContext() const checked = $derived(group.getValue() === value) function select() { if (disabled) return group.setValue(value) } </script> <div bind:this={ref} role="menuitemradio" tabindex={disabled ? undefined : -1} aria-checked={checked} aria-disabled={disabled || undefined} data-uipkge data-slot="dropdown-menu-radio-item" data-state={checked ? 'checked' : 'unchecked'} data-disabled={disabled ? '' : undefined} class={cn( 'focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', className, )} onclick={(e) => { select() onclick?.(e) }} onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() select() } }} {...restProps} > <span class="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center"> {#if checked} {#if indicatorIcon} {@render indicatorIcon()} {:else} <Circle class="size-2 fill-current" aria-hidden="true" /> {/if} {/if} </span> {@render children?.()} </div>src/lib/components/ui/dropdown-menu/DropdownMenuSeparator.svelte0.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuSeparatorProps extends HTMLAttributes<HTMLDivElement> { ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, ref = $bindable(null), ...restProps }: DropdownMenuSeparatorProps = $props() </script> <div bind:this={ref} role="separator" data-uipkge data-slot="dropdown-menu-separator" class={cn('bg-border -mx-1 my-1 h-px', className)} {...restProps} ></div>src/lib/components/ui/dropdown-menu/DropdownMenuShortcut.svelte0.6 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuShortcutProps extends HTMLAttributes<HTMLElement> { ref?: HTMLElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' let { class: className, children, ref = $bindable(null), ...restProps }: DropdownMenuShortcutProps = $props() </script> <span bind:this={ref} data-uipkge data-slot="dropdown-menu-shortcut" class={cn('text-muted-foreground ml-auto text-xs tracking-widest', className)} {...restProps} > {@render children?.()} </span>src/lib/components/ui/dropdown-menu/DropdownMenuSub.svelte1.3 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface DropdownMenuSubProps { /** Controlled sub-menu open state. Use `bind:open` for two-way binding. */ open?: boolean /** Called whenever the sub-menu open state changes. */ onOpenChange?: (open: boolean) => void children?: Snippet } </script> <script lang="ts"> import { setSubContext } from './dropdown-menu-context' let { open = $bindable(false), onOpenChange, children }: DropdownMenuSubProps = $props() const uid = $props.id() const ids = { trigger: `${uid}-trigger`, content: `${uid}-content` } let closeTimer: ReturnType<typeof setTimeout> | null = null function clearTimer() { if (closeTimer) { clearTimeout(closeTimer) closeTimer = null } } function setSubOpen(next: boolean) { // Any explicit set wins over a pending scheduled close (e.g. entering // the panel while the trigger's grace timer is still running). clearTimer() if (open === next) return open = next onOpenChange?.(next) } function scheduleClose(delay = 120) { clearTimer() closeTimer = setTimeout(() => { closeTimer = null setSubOpen(false) }, delay) } setSubContext({ isSubOpen: () => open, setSubOpen, scheduleClose, ids }) </script> {@render children?.()}src/lib/components/ui/dropdown-menu/DropdownMenuSubContent.svelte3.4 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuSubContentProps extends HTMLAttributes<HTMLDivElement> { sideOffset?: number alignOffset?: number ref?: HTMLDivElement | null } </script> <script lang="ts"> import { cn } from '$lib/utils' import { focusMenuItem, getSubContext } from './dropdown-menu-context' let { class: className, sideOffset = 8, alignOffset = -4, children, ref = $bindable(null), onkeydown, ...restProps }: DropdownMenuSubContentProps = $props() const sub = getSubContext() const subOpen = $derived(sub.isSubOpen()) let style = $state('position: fixed; top: 0; left: 0; visibility: hidden;') function position() { const trigger = document.getElementById(sub.ids.trigger) const content = document.getElementById(sub.ids.content) if (!trigger || !content) return const t = trigger.getBoundingClientRect() const w = content.offsetWidth // Cascade to the right; flip left when there is no room. let left = t.right + sideOffset if (left + w > window.innerWidth - 8) left = t.left - w - sideOffset left = Math.max(8, left) const top = Math.max(8, Math.min(t.top + alignOffset, window.innerHeight - 16)) style = `position: fixed; top: ${top}px; left: ${left}px; ` + `--reka-dropdown-menu-content-available-height: ${Math.max(80, window.innerHeight - top - 8)}px; ` + `--reka-dropdown-menu-content-transform-origin: center left;` } $effect(() => { if (!subOpen) return position() window.addEventListener('resize', position) window.addEventListener('scroll', position, true) return () => { window.removeEventListener('resize', position) window.removeEventListener('scroll', position, true) } }) function handleKeyDown(e: KeyboardEvent) { if (e.key === 'ArrowDown') { e.preventDefault() focusMenuItem(sub.ids.content, 'next') } else if (e.key === 'ArrowUp') { e.preventDefault() focusMenuItem(sub.ids.content, 'prev') } else if (e.key === 'ArrowLeft') { e.preventDefault() sub.setSubOpen(false) document.getElementById(sub.ids.trigger)?.focus() } else if (e.key === 'Home') { e.preventDefault() focusMenuItem(sub.ids.content, 'first') } else if (e.key === 'End') { e.preventDefault() focusMenuItem(sub.ids.content, 'last') } } </script> {#if subOpen} <div bind:this={ref} id={sub.ids.content} role="menu" aria-orientation="vertical" tabindex="-1" data-uipkge data-slot="dropdown-menu-sub-content" data-state="open" data-side="right" class={cn( 'bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--reka-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg outline-none', className, )} {style} onkeydown={(e) => { handleKeyDown(e) onkeydown?.(e) }} onmouseenter={() => sub.setSubOpen(true)} onmouseleave={() => sub.scheduleClose()} {...restProps} > {@render children?.()} </div> {/if}src/lib/components/ui/dropdown-menu/DropdownMenuSubTrigger.svelte2.5 kB
<script lang="ts" module> import type { HTMLAttributes } from 'svelte/elements' export interface DropdownMenuSubTriggerProps extends HTMLAttributes<HTMLDivElement> { disabled?: boolean inset?: boolean ref?: HTMLDivElement | null } </script> <script lang="ts"> import { ChevronRight } from '@lucide/svelte' import { cn } from '$lib/utils' import { focusMenuItem, getSubContext } from './dropdown-menu-context' let { class: className, disabled = false, inset = false, children, ref = $bindable(null), onclick, ...restProps }: DropdownMenuSubTriggerProps = $props() const sub = getSubContext() const subOpen = $derived(sub.isSubOpen()) function open() { if (disabled) return sub.setSubOpen(true) } function scheduleClose() { if (disabled) return sub.scheduleClose() } function toggle(e: Event) { if (disabled) return e.stopPropagation() sub.setSubOpen(!sub.isSubOpen()) } </script> <div bind:this={ref} id={sub.ids.trigger} role="menuitem" tabindex={disabled ? undefined : -1} aria-haspopup="menu" aria-expanded={subOpen} aria-controls={sub.ids.content} aria-disabled={disabled || undefined} data-uipkge data-slot="dropdown-menu-sub-trigger" data-state={subOpen ? 'open' : 'closed'} data-inset={inset ? '' : undefined} data-disabled={disabled ? '' : undefined} class={cn( 'focus:bg-accent focus:text-accent-foreground focus-visible:ring-ring data-[state=open]:bg-accent data-[state=open]:text-accent-foreground data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*=\'text-\'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus-visible:ring-2 focus-visible:ring-inset data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*=\'size-\'])]:size-4', className, )} onclick={(e) => { toggle(e) onclick?.(e) }} onkeydown={(e) => { if (e.key === 'ArrowRight' && !subOpen) { e.preventDefault() open() focusMenuItem(sub.ids.content, 'first') } else if (e.key === 'ArrowLeft' && subOpen) { e.preventDefault() sub.setSubOpen(false) } else if (e.key === 'Enter' || e.key === ' ') { e.preventDefault() toggle(e) if (!disabled && sub.isSubOpen()) focusMenuItem(sub.ids.content, 'first') } }} onmouseenter={open} onmouseleave={scheduleClose} onfocus={open} {...restProps} > {@render children?.()} <ChevronRight class="ml-auto size-4" /> </div>src/lib/components/ui/dropdown-menu/DropdownMenuTrigger.svelte1.8 kB
<script lang="ts" module> import type { Snippet } from 'svelte' import type { HTMLButtonAttributes } from 'svelte/elements' export interface DropdownMenuTriggerProps extends HTMLButtonAttributes { /** Render your own trigger element (e.g. a Button) with the trigger's * props — the Svelte counterpart of reka's `as-child`. */ child?: Snippet<[{ props: Record<string, unknown> }]> ref?: HTMLButtonElement | null } </script> <script lang="ts"> import { getMenuContext } from './dropdown-menu-context' let { class: className, disabled, child, children, ref = $bindable(null), onclick, ...restProps }: DropdownMenuTriggerProps = $props() const menu = getMenuContext() const open = $derived(menu.isOpen()) function toggle() { if (disabled) return menu.setOpen(!menu.isOpen()) } // `child` consumers spread these onto their own element; the runtime event // always carries currentTarget, hence the narrow-to-wide call below. function handleChildClick(e: MouseEvent) { toggle() ;(onclick as unknown as ((e: MouseEvent) => void) | undefined)?.(e) } const sharedProps = $derived({ id: menu.ids.trigger, 'aria-haspopup': 'menu' as const, 'aria-expanded': open, 'aria-controls': menu.ids.content, 'data-state': open ? 'open' : 'closed', disabled, ...restProps, }) </script> {#if child} {@render child({ props: { ...sharedProps, 'data-uipkge': '', 'data-slot': 'dropdown-menu-trigger', class: className, onclick: handleChildClick, }, })} {:else} <button bind:this={ref} type="button" data-uipkge data-slot="dropdown-menu-trigger" class={className} {...sharedProps} onclick={(e) => { toggle() onclick?.(e) }} > {@render children?.()} </button> {/if}src/lib/components/ui/dropdown-menu/DropdownMenuPortal.svelte0.4 kB
<script lang="ts" module> import type { Snippet } from 'svelte' export interface DropdownMenuPortalProps { children?: Snippet } </script> <script lang="ts"> import { portal } from './portal' let { children }: DropdownMenuPortalProps = $props() </script> <div use:portal data-uipkge data-slot="dropdown-menu-portal" style:display="contents"> {@render children?.()} </div>src/lib/components/ui/dropdown-menu/portal.ts0.5 kB
import type { Action } from 'svelte/action' /** * Move the node to `document.body` (or a selector/element target) while it is * mounted. Actions only run in the browser, so this is SSR-safe. */ export const portal: Action<HTMLElement, HTMLElement | string | undefined> = (node, target) => { const targetEl = typeof target === 'string' ? document.querySelector(target) : (target ?? document.body) ;(targetEl ?? document.body).appendChild(node) return { destroy() { node.remove() }, } }src/lib/components/ui/dropdown-menu/index.ts1.7 kB
export { default as DropdownMenu, type DropdownMenuProps } from './DropdownMenu.svelte' export { default as DropdownMenuCheckboxItem, type DropdownMenuCheckboxItemProps, } from './DropdownMenuCheckboxItem.svelte' export { default as DropdownMenuContent, type DropdownMenuContentProps } from './DropdownMenuContent.svelte' export { default as DropdownMenuGroup, type DropdownMenuGroupProps } from './DropdownMenuGroup.svelte' export { default as DropdownMenuItem, type DropdownMenuItemProps } from './DropdownMenuItem.svelte' export { default as DropdownMenuLabel, type DropdownMenuLabelProps } from './DropdownMenuLabel.svelte' export { default as DropdownMenuPortal, type DropdownMenuPortalProps } from './DropdownMenuPortal.svelte' export { default as DropdownMenuRadioGroup, type DropdownMenuRadioGroupProps } from './DropdownMenuRadioGroup.svelte' export { default as DropdownMenuRadioItem, type DropdownMenuRadioItemProps } from './DropdownMenuRadioItem.svelte' export { default as DropdownMenuSeparator, type DropdownMenuSeparatorProps } from './DropdownMenuSeparator.svelte' export { default as DropdownMenuShortcut, type DropdownMenuShortcutProps } from './DropdownMenuShortcut.svelte' export { default as DropdownMenuSub, type DropdownMenuSubProps } from './DropdownMenuSub.svelte' export { default as DropdownMenuSubContent, type DropdownMenuSubContentProps } from './DropdownMenuSubContent.svelte' export { default as DropdownMenuSubTrigger, type DropdownMenuSubTriggerProps } from './DropdownMenuSubTrigger.svelte' export { default as DropdownMenuTrigger, type DropdownMenuTriggerProps } from './DropdownMenuTrigger.svelte' export { dropdownMenuContentVariants, type DropdownMenuContentVariants } from './dropdown-menu-content.variants'
Raw manifest:https://uipkge.dev/r/svelte/dropdown-menu.json