overlaysclient
dropdown menu
a menu of actions under a trigger, with shortcuts, dividers and a red one for deleting.
installation
new to blurrow? this sets up your project in one go: the package, the styles and the font. already done it? skip straight to usage. the installation page has the details.
bash
npx @blurrow/ui init›rather own the code? copy the source instead
let the cli copy it, along with anything it needs:
bash
npx @blurrow/ui add dropdown-menuor by hand: drop these files into components/ui and import from there. dropdown menu leans on Kbd.tsx, so those come along too.
components/ui/DropdownMenu.tsxtsx
1"use client";2
3import React, { useState, useRef, useEffect, useId, useCallback, ReactNode } from "react";4import { Kbd } from "./Kbd";5
6type DropdownMenuPosition =7 | "bottom-start"8 | "bottom-end"9 | "top-start"10 | "top-end";11
12export interface DropdownMenuItem {13 key: string;14 label: string;15 icon?: ReactNode;16 shortcut?: string;17 danger?: boolean;18 disabled?: boolean;19 divider?: boolean;20}21
22interface DropdownMenuProps {23 trigger: ReactNode;24 items: DropdownMenuItem[];25 position?: DropdownMenuPosition;26 onSelect?: (key: string) => void;27 className?: string;28}29
30const positionStyles: Record<DropdownMenuPosition, string> = {31 "bottom-start": "top-full left-0 mt-2 origin-top-left",32 "bottom-end": "top-full right-0 mt-2 origin-top-right",33 "top-start": "bottom-full left-0 mb-2 origin-bottom-left",34 "top-end": "bottom-full right-0 mb-2 origin-bottom-right",35};36
37const enterFrom: Record<DropdownMenuPosition, string> = {38 "bottom-start": "-translate-y-1.5",39 "bottom-end": "-translate-y-1.5",40 "top-start": "translate-y-1.5",41 "top-end": "translate-y-1.5",42};43
44export function DropdownMenu({45 trigger,46 items,47 position = "bottom-end",48 onSelect,49 className = "",50}: DropdownMenuProps) {51 const [open, setOpen] = useState(false);52 const [visible, setVisible] = useState(false);53 const [mounted, setMounted] = useState(false);54 // Where the menu actually opens: the requested side unless that runs off-screen55 const [placement, setPlacement] = useState(position);56 const [shift, setShift] = useState(0);57 const ref = useRef<HTMLDivElement>(null);58 const menuRef = useRef<HTMLDivElement>(null);59 const uid = useId();60
61 const enabledItems = () =>62 Array.from(menuRef.current?.querySelectorAll<HTMLElement>('[role="menuitem"]:not([disabled])') ?? []);63
64 const focusItem = (index: number) => {65 const list = enabledItems();66 if (list.length === 0) return;67 list[(index + list.length) % list.length].focus();68 };69
70 /* Measured as soon as the menu is attached, before it fades in.71 offsetWidth/Height ignore the enter transform, so the numbers are the72 menu's settled size. */73 const attachMenu = useCallback((menu: HTMLDivElement | null) => {74 menuRef.current = menu;75 const anchor = ref.current;76 if (!menu || !anchor) return;77 const gap = 8;78 const a = anchor.getBoundingClientRect();79 const w = menu.offsetWidth;80 const h = menu.offsetHeight;81 const vw = document.documentElement.clientWidth;82 const vh = window.innerHeight;83
84 let [v, side] = position.split("-") as ["top" | "bottom", "start" | "end"];85 const fitsStart = a.left + w <= vw - gap;86 const fitsEnd = a.right - w >= gap;87 if (side === "end" && !fitsEnd && fitsStart) side = "start";88 else if (side === "start" && !fitsStart && fitsEnd) side = "end";89
90 const fitsBelow = a.bottom + gap + h <= vh - gap;91 const fitsAbove = a.top - gap - h >= gap;92 if (v === "bottom" && !fitsBelow && fitsAbove) v = "top";93 else if (v === "top" && !fitsAbove && fitsBelow) v = "bottom";94
95 // Neither side fits (narrow screens): slide it back inside the viewport96 const left = side === "start" ? a.left : a.right - w;97 const nudge = Math.max(gap - left, 0) - Math.max(left + w - (vw - gap), 0);98
99 setPlacement(`${v}-${side}` as DropdownMenuPosition);100 setShift(nudge);101 }, [position]);102
103 useEffect(() => {104 if (open) {105 setMounted(true);106 requestAnimationFrame(() =>107 requestAnimationFrame(() => {108 setVisible(true);109 // Land on the first item so arrow keys work straight away110 focusItem(0);111 })112 );113 } else {114 setVisible(false);115 const t = setTimeout(() => setMounted(false), 180);116 return () => clearTimeout(t);117 }118 }, [open]);119
120 useEffect(() => {121 function onOutside(e: MouseEvent) {122 if (ref.current && !ref.current.contains(e.target as Node)) {123 setOpen(false);124 }125 }126 document.addEventListener("mousedown", onOutside);127 return () => document.removeEventListener("mousedown", onOutside);128 }, []);129
130 const returnFocus = () => {131 ref.current?.querySelector<HTMLElement>("[data-menu-trigger] button, [data-menu-trigger] a")?.focus();132 };133
134 const onMenuKeyDown = (e: React.KeyboardEvent) => {135 const list = enabledItems();136 const at = list.indexOf(document.activeElement as HTMLElement);137 switch (e.key) {138 case "ArrowDown":139 e.preventDefault();140 focusItem(at + 1);141 break;142 case "ArrowUp":143 e.preventDefault();144 focusItem(at - 1);145 break;146 case "Home":147 e.preventDefault();148 focusItem(0);149 break;150 case "End":151 e.preventDefault();152 focusItem(list.length - 1);153 break;154 case "Escape":155 e.preventDefault();156 setOpen(false);157 returnFocus();158 break;159 case "Tab":160 setOpen(false);161 break;162 }163 };164
165 function handleSelect(item: DropdownMenuItem) {166 if (item.disabled) return;167 onSelect?.(item.key);168 setOpen(false);169 returnFocus();170 }171
172 return (173 <div ref={ref} className={["relative inline-flex", className].join(" ")}>174 <div175 data-menu-trigger176 onClick={() => {177 if (!open) {178 // Start each opening from the requested side; attachMenu flips if needed179 setPlacement(position);180 setShift(0);181 }182 setOpen(!open);183 }}184 className="inline-flex cursor-pointer"185 >186 {React.isValidElement(trigger)187 ? React.cloneElement(trigger as React.ReactElement<Record<string, unknown>>, {188 "aria-haspopup": "menu",189 "aria-expanded": open,190 "aria-controls": open ? `${uid}-menu` : undefined,191 })192 : trigger}193 </div>194 {mounted && (195 <div196 ref={attachMenu}197 id={`${uid}-menu`}198 role="menu"199 onKeyDown={onMenuKeyDown}200 className={[201 "absolute z-50 min-w-[200px] rounded-[16px] p-1.5",202 "transition-[opacity,transform,scale,translate] duration-200 ease-[var(--ease-spring)]",203 visible204 ? "opacity-100 scale-100 translate-y-0"205 : ["opacity-0 scale-[0.96]", enterFrom[placement]].join(" "),206 positionStyles[placement],207 ].join(" ")}208 style={{ background: "var(--panel-bezel)", boxShadow: "var(--overlay-shadow)", marginLeft: shift || undefined }}209 >210 <div211 className="bg-white rounded-[11px] p-1 flex flex-col"212 style={{ boxShadow: "var(--panel-face-shadow)" }}213 >214 {items.map((item) => {215 if (item.divider) {216 return <div key={item.key} role="separator" className="h-px bg-neutral-100 my-1 mx-2" />;217 }218
219 return (220 <button221 key={item.key}222 type="button"223 role="menuitem"224 tabIndex={-1}225 onClick={() => handleSelect(item)}226 onMouseEnter={(e) => e.currentTarget.focus()}227 disabled={item.disabled}228 className={[229 "group/item w-full flex items-center gap-2.5 px-2.5 py-2 rounded-[8px] text-[13px] font-medium cursor-pointer outline-none",230 "transition-[background,color] duration-100",231 "disabled:opacity-35 disabled:pointer-events-none",232 item.danger233 ? "text-red-600 focus:bg-red-50"234 : "text-neutral-700 focus:text-neutral-900 focus:bg-neutral-100/80",235 ].join(" ")}236 >237 {item.icon && (238 <span239 className={[240 "shrink-0 w-4 h-4 flex items-center justify-center transition-colors duration-100",241 item.danger ? "text-red-500" : "text-neutral-400 group-focus/item:text-neutral-700",242 ].join(" ")}243 >244 {item.icon}245 </span>246 )}247 <span className="flex-1 text-left">{item.label}</span>248 {item.shortcut && <Kbd className="shrink-0">{item.shortcut}</Kbd>}249 </button>250 );251 })}252 </div>253 </div>254 )}255 </div>256 );257}components/ui/Kbd.tsxtsx
1import { ReactNode } from "react";2
3export function Kbd({4 children,5 className = "",6}: {7 children: ReactNode;8 className?: string;9}) {10 return (11 <kbd12 className={`inline-flex items-center justify-center min-w-[22px] h-[22px] px-1.5 rounded-md text-[11px] font-semibold text-neutral-500 ${className}`}13 style={{14 /* A keycap: lit top, solid 1.5px skirt below so it reads as pressable */15 background: "var(--face-light)",16 boxShadow:17 "0 0 3px rgba(17,17,17,0.136), 0 1.5px 1px rgba(17,17,17,0.12), 0 2px 3px -1px rgba(17,17,17,0.1), inset 0 2px 3px -2px rgba(255,255,255,1)",18 textShadow: "0 1px 0 rgba(255,255,255,0.9)",19 }}20 >21 {children}22 </kbd>23 );24}usage
tsx
import { DropdownMenu } from "@blurrow/ui";tsx
<DropdownMenu trigger={<Button variant="secondary">options</Button>} items={items} onSelect={handle} />examples
with icons
props
straight from the source, so they're always up to date.
propsts
type DropdownMenuPosition = | "bottom-start" | "bottom-end" | "top-start" | "top-end";
interface DropdownMenuItem { key: string; label: string; icon?: ReactNode; shortcut?: string; danger?: boolean; disabled?: boolean; divider?: boolean;}
interface DropdownMenuProps { trigger: ReactNode; items: DropdownMenuItem[]; position?: DropdownMenuPosition; onSelect?: (key: string) => void; className?: string;}