## Dropdown — Overlay Active highlight travels between items. Docs: https://www.interior.dev/docs/dropdown Reference: https://www.interior.dev/reference/dropdown License: https://github.com/ddoemonn/interior/blob/main/LICENSE (MIT) ### Install Requires a React project. Install `motion`; the styled example uses Tailwind CSS utilities. The source file is copied into your project. `bunx shadcn@latest add https://www.interior.dev/r/dropdown.json` Or run `bun add motion` and copy the source below. ### Usage ```tsx "use client"; import { useState } from "react"; import { Dropdown, type DropdownItem } from "@/components/interior/dropdown"; const VISIBILITY: DropdownItem[] = [ { value: "private", label: "Only me", hint: "default" }, { value: "team", label: "Anyone at Acme" }, { value: "link", label: "Anyone with the link" }, { value: "public", label: "Public on the web", disabled: true }, ]; export function ShareRow({ docId }: { docId: string }) { const [visibility, setVisibility] = useState("private"); return (

Who can see this

{ setVisibility(next); void fetch(`/api/docs/${docId}`, { method: "PATCH", body: JSON.stringify({ visibility: next }), }); }} />
); } ``` ### Props - `items` (`DropdownItem[]`) Options, each { value, label, hint?, disabled? }. Disabled options are rendered but skipped by every form of navigation. - `value` (`string`) Controlled selection. Omit for uncontrolled, in which case the component keeps its own. - `defaultValue` (`string`) Initial selection when uncontrolled. Nothing is selected without it, and the placeholder holds the trigger's width. - `onChange` (`(value: string) => void`) Fires once per commit, after the menu has closed and focus is back on the trigger. - `label` (`string`) — default: `"Options"` Accessible name for both the trigger and the listbox. It is read with the current value, never in place of it. - `placeholder` (`string`) — default: `"Select an option"` Shown until something is selected. It is measured with the real labels, so adopting a selection cannot resize the trigger. - `disabled` (`boolean`) — default: `false` Blocks opening from pointer and keyboard alike. - `emptyLabel` (`string`) — default: `"Nothing to choose"` Rendered as inert text when items is empty, so the listbox never opens onto nothing. - `className` (`string`) — default: `""` Appended last on the root. The root is width-full and unsized, so the caller owns the measurement. ### Behavior notes - The active highlight is one element carrying a layoutId, so moving down the list is a single travel rather than one row's background fading out while another fades in — hold ArrowDown and there is never a frame with two rows lit or none. - Every label lives stacked in one grid cell inside the trigger, so committing to a longer option cannot widen the button or push the row beside it sideways. - The list is capped at 216px and scrolls inside itself, so a hundred options never animate a panel toward an unbounded height, and keyboard navigation reveals the active row with block: "nearest" instead of yanking the list to center it. - Disabled options are skipped by the arrow keys, by Home and End, and by typeahead, and refuse their own click, so the highlight cannot park somewhere Enter does nothing. - Under prefers-reduced-motion the highlight jumps to its new row and the panel arrives without blur or scale: the selection still reads, only the trip is skipped, and nothing is hidden. - Focus is handled rather than assumed — opening moves focus into the listbox, aria-activedescendant names the active option so a screen reader hears the landing and not the journey, and Escape, Tab, a click outside or a window blur all close it without leaving focus stranded on a removed node; useDropdown exports the same behaviour for a different surface. ### Source (`components/interior/dropdown.tsx`) ```tsx "use client"; import { useCallback, useEffect, useId, useRef, useState } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; const EASE = [0.23, 1, 0.32, 1] as const; const EXIT = [0.4, 0, 1, 1] as const; const CELL = { type: "spring", stiffness: 520, damping: 34, mass: 0.45 } as const; const NUDGE = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const NONE = { duration: 0 } as const; const SLIDE = { type: "spring", stiffness: 700, damping: 46, mass: 0.5 } as const; const ROW_H = 32; const OPEN = { type: "spring", stiffness: 620, damping: 38, mass: 0.6 } as const; export type DropdownItem = { value: string; label: string; hint?: string; disabled?: boolean; }; export type UseDropdownOptions = { items: DropdownItem[]; value?: string; defaultValue?: string; onChange?: (value: string) => void; disabled?: boolean; typeaheadDelay?: number; }; export function useDropdown({ items, value, defaultValue, onChange, disabled = false, typeaheadDelay = 600, }: UseDropdownOptions) { const uid = useId(); const listId = `${uid}-list`; const itemId = useCallback((i: number) => `${uid}-opt-${i}`, [uid]); const [uncontrolled, setUncontrolled] = useState( defaultValue ?? null, ); const selectedValue = value !== undefined ? value : uncontrolled; const selectedIndex = items.findIndex((it) => it.value === selectedValue); const [open, setOpen] = useState(false); const [activeIndex, setActiveIndex] = useState(-1); const rootRef = useRef(null); const triggerRef = useRef(null); const listRef = useRef(null); const itemRefs = useRef<(HTMLLIElement | null)[]>([]); const viaKey = useRef(false); const buffer = useRef(""); const bufferTimer = useRef | null>(null); const emit = useRef(onChange); emit.current = onChange; const step = useCallback( (from: number, dir: 1 | -1) => { const n = items.length; if (n === 0) return -1; let i = from; for (let k = 0; k < n; k++) { i = (i + dir + n) % n; if (!items[i].disabled) return i; } return from; }, [items], ); const edge = useCallback( (dir: 1 | -1) => step(dir === 1 ? -1 : items.length, dir), [step, items.length], ); const openMenu = useCallback( (index?: number) => { if (disabled || items.length === 0) return; const usable = selectedIndex >= 0 && !items[selectedIndex].disabled; viaKey.current = true; setActiveIndex(index ?? (usable ? selectedIndex : edge(1))); setOpen(true); }, [disabled, items, selectedIndex, edge], ); const close = useCallback((restoreFocus = true) => { buffer.current = ""; setOpen(false); setActiveIndex(-1); if (restoreFocus) triggerRef.current?.focus(); }, []); const select = useCallback( (index: number) => { const item = items[index]; if (!item || item.disabled) return; if (value === undefined) setUncontrolled(item.value); emit.current?.(item.value); close(); }, [items, value, close], ); const typeahead = useCallback( (char: string) => { if (bufferTimer.current) clearTimeout(bufferTimer.current); buffer.current += char.toLowerCase(); bufferTimer.current = setTimeout(() => { buffer.current = ""; }, typeaheadDelay); const q = buffer.current; const n = items.length; const from = activeIndex < 0 ? 0 : activeIndex; const start = q.length > 1 ? from : from + 1; for (let k = 0; k < n; k++) { const i = (start + k) % n; const it = items[i]; if (!it.disabled && it.label.toLowerCase().startsWith(q)) { viaKey.current = true; setActiveIndex(i); return; } } }, [items, activeIndex, typeaheadDelay], ); useEffect(() => { if (open) listRef.current?.focus(); }, [open]); useEffect(() => { if (!open) return; const onDown = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) close(false); }; const onWindowBlur = () => close(false); document.addEventListener("pointerdown", onDown, true); window.addEventListener("blur", onWindowBlur); return () => { document.removeEventListener("pointerdown", onDown, true); window.removeEventListener("blur", onWindowBlur); }; }, [open, close]); useEffect(() => { if (!open || activeIndex < 0 || !viaKey.current) return; viaKey.current = false; itemRefs.current[activeIndex]?.scrollIntoView({ block: "nearest" }); }, [open, activeIndex]); useEffect( () => () => { if (bufferTimer.current) clearTimeout(bufferTimer.current); }, [], ); const triggerProps = { ref: triggerRef, type: "button" as const, disabled, "aria-haspopup": "listbox" as const, "aria-expanded": open, "aria-controls": open ? listId : undefined, onClick: () => (open ? close() : openMenu()), onKeyDown: (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "Enter" || e.key === " ") { e.preventDefault(); openMenu(); } else if (e.key === "ArrowUp") { e.preventDefault(); openMenu(edge(-1)); } }, }; const listProps = { ref: listRef, id: listId, role: "listbox" as const, tabIndex: -1, "aria-activedescendant": activeIndex >= 0 ? itemId(activeIndex) : undefined, onKeyDown: (e: React.KeyboardEvent) => { if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); const dir = e.key === "ArrowDown" ? 1 : -1; viaKey.current = true; setActiveIndex((i) => step(i, dir)); } else if (e.key === "Home" || e.key === "End") { e.preventDefault(); viaKey.current = true; setActiveIndex(edge(e.key === "Home" ? 1 : -1)); } else if (e.key === "Enter" || e.key === " ") { e.preventDefault(); select(activeIndex); } else if (e.key === "Escape") { e.preventDefault(); close(); } else if (e.key === "Tab") { e.preventDefault(); close(); } else if ( e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey ) { e.preventDefault(); typeahead(e.key); } }, }; const getItemProps = useCallback( (index: number) => ({ id: itemId(index), role: "option" as const, "aria-selected": index === selectedIndex, "aria-disabled": items[index]?.disabled ? (true as const) : undefined, ref: (el: HTMLLIElement | null) => { itemRefs.current[index] = el; }, onPointerMove: () => { if (items[index]?.disabled) return; viaKey.current = false; setActiveIndex(index); }, onClick: () => select(index), }), [itemId, items, selectedIndex, select], ); return { open, openMenu, close, select, activeIndex, selectedIndex, selectedItem: selectedIndex >= 0 ? items[selectedIndex] : null, itemId, rootRef, triggerProps, listProps, getItemProps, }; } export type DropdownProps = { items: DropdownItem[]; value?: string; defaultValue?: string; onChange?: (value: string) => void; label?: string; placeholder?: string; disabled?: boolean; emptyLabel?: string; className?: string; }; export function Dropdown({ items, value, defaultValue, onChange, label = "Options", placeholder = "Select an option", disabled = false, emptyLabel = "Nothing to choose", className = "", }: DropdownProps) { const reduced = useReducedMotion(); const { open, activeIndex, selectedIndex, selectedItem, rootRef, triggerProps, listProps, getItemProps, } = useDropdown({ items, value, defaultValue, onChange, disabled }); const cell = reduced ? NONE : CELL; return (
{open && (
    {items.map((item, i) => { const active = i === activeIndex && !item.disabled; const picked = i === selectedIndex; return (
  • {item.label} {item.hint ? ( {item.hint} ) : null}
  • ); })} {items.length === 0 && (
  • {emptyLabel}
  • )}
)}
); } ```