## 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}
)}
)}
);
}
```