Select
A select with its own listbox, that opens like every other menu and answers to type-ahead.
Try the arrow keys, or type a letter.
The idea
Its own listbox rather than the browser's, so it opens with the same squash-and-stretch and wears the same glass as every other menu in the package. It is still a real combobox: aria-activedescendant points at the option under the keyboard, so focus stays on the button and a screen reader follows along. Typing a letter jumps to the next option starting with it — the one native behaviour custom selects most often forget.
Composes DropMenu.
Install
npm i mihirmodi-ui motionThe tokens, the theme script and the routing provider are a one-time job — see Setup. Or skip the package entirely and take the source at the foot of this page.
Usage
import { Select } from "mihirmodi-ui";
<label htmlFor="region">Region</label>
<Select
id="region"
value={region}
onChange={setRegion}
options={[
{ value: "eu", label: "Europe", hint: "EUR" },
{ value: "uk", label: "United Kingdom", hint: "GBP" },
]}
/>Props
| Prop | Type | Notes |
|---|---|---|
| value / onChangestring | null / (v) => void | string | null / (v) => void | Controlled. |
| options(string | { value, label, hint? })[] | (string | { value, label, hint? })[] | `hint` sits on the right of the row — a currency, a shortcut. |
| placeholderstring | string= "Choose…" | Shown while nothing is picked. |
| id / aria-labelstring | string | Pair `id` with a `<label for>`, or give it an `aria-label`. |
| disabledboolean | boolean | Dims it and ignores keys. |
Source
Exactly what the package ships — this is read out of the source at build time, not a copy. Take it and adapt it if you would rather not add a dependency.
"use client";
import { useEffect, useId, useRef, useState, type ReactNode } from "react";
import { DropMenu } from "../DropMenu.js";
import { CheckIcon, ChevronDownIcon } from "../../lib/glyph.js";
export type SelectOption = { value: string; label: string; hint?: ReactNode };
export type SelectProps = {
value: string | null;
onChange: (value: string) => void;
/** Strings, or `{ value, label, hint? }`. */
options: (string | SelectOption)[];
placeholder?: string;
disabled?: boolean;
id?: string;
/** The accessible name, when there is no visible `<label for>`. */
"aria-label"?: string;
className?: string;
};
/**
* A select with its own listbox rather than the browser's, so it opens with the
* same squash-and-stretch and wears the same glass as every other menu here.
*
* It is a real combobox for assistive tech — `aria-activedescendant` points at the
* option under the keyboard, so focus stays on the button and a screen reader
* still follows along. Up, Down, Home and End move; Enter or Space picks; Escape
* closes; and typing a letter jumps to the next option that starts with it, the
* one native behaviour a custom select most often forgets.
*/
export function Select({ value, onChange, options, placeholder = "Choose…", disabled, id, className = "", "aria-label": ariaLabel }: SelectProps) {
const listId = useId();
const [open, setOpen] = useState(false);
const [active, setActive] = useState(-1);
const btn = useRef<HTMLButtonElement>(null);
const list = useRef<HTMLUListElement>(null);
const root = useRef<HTMLDivElement>(null);
const items = options.map((o) => (typeof o === "string" ? { value: o, label: o } : o));
const idx = items.findIndex((o) => o.value === value);
const current = idx >= 0 ? items[idx] : null;
const show = () => {
setActive(idx < 0 ? 0 : idx);
setOpen(true);
};
const hide = (refocus = true) => {
setOpen(false);
if (refocus) btn.current?.focus();
};
const pick = (i: number) => {
onChange(items[i].value);
hide();
};
useEffect(() => {
if (!open || active < 0) return;
list.current?.querySelectorAll('[role="option"]')[active]?.scrollIntoView({ block: "nearest" });
}, [open, active]);
useEffect(() => {
if (!open) return;
const onDown = (e: PointerEvent) => {
if (!root.current?.contains(e.target as Node)) hide(false);
};
window.addEventListener("pointerdown", onDown);
return () => window.removeEventListener("pointerdown", onDown);
}, [open]);
const onKey = (e: React.KeyboardEvent) => {
if (disabled) return;
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
e.preventDefault();
show();
}
return;
}
switch (e.key) {
case "ArrowDown":
e.preventDefault();
setActive((a) => Math.min(items.length - 1, a + 1));
break;
case "ArrowUp":
e.preventDefault();
setActive((a) => Math.max(0, a - 1));
break;
case "Home":
e.preventDefault();
setActive(0);
break;
case "End":
e.preventDefault();
setActive(items.length - 1);
break;
case "Enter":
case " ":
e.preventDefault();
if (active >= 0) pick(active);
break;
case "Escape":
e.preventDefault();
hide();
break;
case "Tab":
hide(false);
break;
default:
if (e.key.length === 1 && /\S/.test(e.key)) {
const k = e.key.toLowerCase();
const j = items.findIndex((o, i) => i > active && o.label.toLowerCase().startsWith(k));
const first = j >= 0 ? j : items.findIndex((o) => o.label.toLowerCase().startsWith(k));
if (first >= 0) setActive(first);
}
}
};
return (
<div ref={root} className={`relative ${className}`} onKeyDown={onKey}>
<button
ref={btn}
id={id}
type="button"
role="combobox"
disabled={disabled}
aria-label={ariaLabel}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open && active >= 0 ? `${listId}-${active}` : undefined}
onClick={() => (open ? hide() : show())}
className={`flex h-10 w-full items-center justify-between gap-3 rounded-[10px] border-[0.5px] bg-background-100 px-3 text-left text-[14px] transition-[border-color,box-shadow] duration-150 ease-geist disabled:cursor-not-allowed disabled:opacity-50 ${
open ? "border-gray-alpha-600 shadow-[0_0_0_3px_var(--color-gray-alpha-200)]" : "border-gray-alpha-400 hover:border-gray-alpha-600"
}`}
>
<span className={`truncate ${current ? "text-gray-1000" : "text-faint"}`}>{current ? current.label : placeholder}</span>
<span className={`shrink-0 text-quiet transition-transform duration-200 ease-geist ${open ? "rotate-180" : ""}`}>
<ChevronDownIcon className="h-3.5 w-3.5" />
</span>
</button>
<DropMenu open={open} placement="down" origin="origin-top-left" className="absolute left-0 top-full z-50 mt-1 min-w-full">
<ul
ref={list}
id={listId}
role="listbox"
aria-labelledby={id}
className="glass-panel dock-shadow max-h-64 w-max min-w-full max-w-[320px] overflow-y-auto rounded-[12px] border-[0.5px] border-gray-alpha-400 p-1"
>
{items.map((o, i) => (
<li
key={o.value}
id={`${listId}-${i}`}
role="option"
aria-selected={i === idx}
onMouseEnter={() => setActive(i)}
onMouseDown={(e) => e.preventDefault()}
onClick={() => pick(i)}
className={`flex h-8 cursor-pointer items-center gap-2.5 rounded-[8px] px-2.5 text-[13px] text-gray-1000 transition-colors duration-150 ${i === active ? "bg-gray-alpha-200" : ""}`}
>
<span className={`flex w-3.5 shrink-0 justify-center ${i === idx ? "text-gray-1000" : "text-transparent"}`}>
<CheckIcon className="h-3 w-3" />
</span>
<span className="min-w-0 flex-1 truncate">{o.label}</span>
{o.hint && <span className="shrink-0 text-[11px] text-faint">{o.hint}</span>}
</li>
))}
</ul>
</DropMenu>
</div>
);
}
"use client";
import { AnimatePresence, LazyMotion, domAnimation, m, useReducedMotion } from "motion/react";
import type { CSSProperties, ReactNode } from "react";
/* Two springs of deliberately different character — the effect comes from letting
them disagree. The grow axis is light and underdamped, so it sails past its
final size; the cross axis is stiffer, heavier-damped and slower, so it arrives
late. Through the overshoot the panel is briefly taller and narrower than it
ends up, then it settles: squash and stretch out of physics rather than out of
a hand-authored timeline. */
const GROW = { type: "spring", stiffness: 620, damping: 19, mass: 0.85 } as const;
const CROSS = { type: "spring", stiffness: 380, damping: 30 } as const;
/* Closing is quick and flat. A bounce on the way out reads as indecision — the
panel looks like it is not sure it wants to go. Open and close are not mirror
images of each other, and should not be. */
const CLOSE = { duration: 0.13, ease: "easeIn" } as const;
export type DropPlacement = "up" | "down";
export type DropMenuProps = {
open: boolean;
/** Which way it grows. `up` for a bottom dock, `down` for a header. */
placement?: DropPlacement;
/** `transform-origin` utility. Defaults to the edge the menu grows from. */
origin?: string;
id?: string;
className?: string;
style?: CSSProperties;
onMouseEnter?: () => void;
onMouseLeave?: () => void;
children: ReactNode;
};
/**
* The open/close motion for a floating menu that grows out of a trigger sitting
* against one edge — a drop-up over a dock, a sheet over a tab bar, a popover
* under a header.
*
* It animates a wrapper and nothing else, so what you put inside is yours: give
* the child the glass, the border and the padding. That split is deliberate — a
* `backdrop-filter` on an element that is itself being transformed is weakened
* or dropped entirely by most engines, so the blur must live on a child that is
* holding still.
*
* The panel mounts only while open, which keeps a closed menu out of the tab
* order and off the paint. The wrapper is always present, so a trigger's
* `aria-controls` still resolves to something. Under reduced motion the elastic
* scaling drops out and a plain fade remains.
*
* You own `open` — this is not a popover with its own state. Escape handling,
* click-outside and hover intent belong to the trigger, which knows what it is.
*/
export function DropMenu({
open,
placement = "up",
origin,
id,
className = "",
style,
onMouseEnter,
onMouseLeave,
children,
}: DropMenuProps) {
const reduce = useReducedMotion();
// Start nudged back toward the trigger, so it reads as coming out of it.
const away = placement === "up" ? 4 : -4;
const anchor = origin ?? (placement === "up" ? "origin-bottom" : "origin-top");
return (
<div
id={id}
style={style}
onMouseEnter={onMouseEnter}
onMouseLeave={onMouseLeave}
// Empty and inert when closed, so it can never sit over the page as a trap.
className={`${className} ${open ? "" : "pointer-events-none"}`}
>
<LazyMotion features={domAnimation} strict>
<AnimatePresence>
{open && (
<m.div
className={anchor}
initial={reduce ? { opacity: 0 } : { opacity: 0, scaleY: 0.82, scaleX: 0.94, y: away }}
animate={reduce ? { opacity: 1 } : { opacity: 1, scaleY: 1, scaleX: 1, y: 0 }}
exit={
reduce
? { opacity: 0, transition: CLOSE }
: { opacity: 0, scaleY: 0.9, scaleX: 0.97, y: away, transition: CLOSE }
}
transition={
reduce
? { duration: 0.15 }
: { scaleY: GROW, y: GROW, scaleX: CROSS, opacity: { duration: 0.14, ease: "easeOut" } }
}
>
{children}
</m.div>
)}
</AnimatePresence>
</LazyMotion>
</div>
);
}