A custom single-select built on the select-only combobox pattern: the listbox scales and fades out of the trigger, flips above it when there is no room, and draws a check next to the selected option. Supports typeahead, full keyboard navigation and disabled options.
Preview
Customize
8 propsInstallation
Copies the component source and its CSS Module into your project.
npx motionfoundry add selectWrites components/ui/select.tsx and components/styles/select.module.css.
Usage
Updates live as you change the controls — only props that differ from the defaults are included.
1import { Select } from "@motionfoundry/ui";2 3export function Example() {4 return (5 <Select />6 );7}Props
Accessibility
Implements the WAI-ARIA select-only combobox pattern: the trigger is role="combobox" with aria-haspopup="listbox", aria-expanded, aria-controls and aria-activedescendant; the popup is role="listbox" with role="option" items carrying aria-selected/aria-disabled. Focus never leaves the trigger. Arrow keys, Home/End and PageUp/PageDown move the active option, Enter/Space select, Alt+ArrowUp selects and closes, Escape closes, Tab selects and moves on, and printable characters typeahead to matching options. Outside clicks close the listbox. The visible label is associated via aria-labelledby. Under prefers-reduced-motion the popup only fades.
Toggle prefers-reduced-motion in your OS or browser devtools (Rendering → Emulate CSS media feature) to verify the reduced-motion state.
Source
1"use client";2 3import * as React from "react";4import { createPortal } from "react-dom";5import { AnimatePresence, motion, useReducedMotion } from "motion/react";6import styles from "../styles/select.module.css";7 8export interface SelectOption {9 /** Unique value */10 value: string;11 /** Visible label (defaults to the value) */12 label?: string;13 /** Disable this option */14 disabled?: boolean;15}16 17export interface SelectProps {18 /** Options as plain strings or option objects */19 options?: Array<string | SelectOption>;20 /** Controlled selected value */21 value?: string;22 /** Initial selected value when uncontrolled */23 defaultValue?: string;24 /** Called with the newly selected value */25 onValueChange?: (value: string) => void;26 /** Controlled open state of the listbox */27 open?: boolean;28 /** Initial open state when uncontrolled */29 defaultOpen?: boolean;30 /** Called when the listbox requests to open or close */31 onOpenChange?: (open: boolean) => void;32 /** Visible field label (accessible name) */33 label?: string;34 /** Text shown when nothing is selected */35 placeholder?: string;36 /** Preferred side of the popover (flips when there is no room) */37 side?: "bottom" | "top";38 /** Size preset */39 size?: "sm" | "md" | "lg";40 /** Trigger width in pixels */41 width?: number;42 /** Accent color for the check mark and focus ring */43 accentColor?: string;44 /** Disable the select */45 disabled?: boolean;46 /** Optional className merged onto the root */47 className?: string;48 /** Optional inline styles merged onto the root */49 style?: React.CSSProperties;50}51 52const DEFAULT_OPTIONS = ["Apple", "Banana", "Blueberry", "Cherry", "Grapes", "Mango", "Pineapple", "Strawberry"];53const THEME_TOKENS = ["--mf-bg", "--mf-fg", "--mf-surface", "--mf-surface-raised", "--mf-muted", "--mf-subtle", "--mf-border", "--mf-border-strong", "--mf-accent"];54const useIsoLayoutEffect = typeof window !== "undefined" ? React.useLayoutEffect : React.useEffect;55const LIST_MAX = 264;56 57function readThemeVars(el: Element | null): Record<string, string> {58 const out: Record<string, string> = {};59 if (!el) return out;60 const cs = getComputedStyle(el);61 for (const t of THEME_TOKENS) {62 const v = cs.getPropertyValue(t).trim();63 if (v) out[t] = v;64 }65 return out;66}67 68export function Select({69 options = DEFAULT_OPTIONS,70 value: valueProp,71 defaultValue,72 onValueChange,73 open: openProp,74 defaultOpen = false,75 onOpenChange,76 label = "Favorite fruit",77 placeholder = "Select a fruit…",78 side = "bottom",79 size = "md",80 width = 240,81 accentColor = "#6366f1",82 disabled = false,83 className = "",84 style,85}: SelectProps) {86 const reduce = useReducedMotion();87 const id = React.useId();88 const items = React.useMemo(() => options.map((o) => (typeof o === "string" ? { value: o, label: o } : { ...o, label: o.label ?? o.value })), [options]);89 const [innerValue, setInnerValue] = React.useState(defaultValue);90 const [innerOpen, setInnerOpen] = React.useState(defaultOpen);91 const value = valueProp !== undefined ? valueProp : innerValue;92 const open = !disabled && (openProp !== undefined ? openProp : innerOpen);93 const [active, setActive] = React.useState(-1);94 const [mounted, setMounted] = React.useState(false);95 const [pos, setPos] = React.useState<React.CSSProperties>({});96 const [placed, setPlaced] = React.useState<"bottom" | "top">(side);97 const [themeVars, setThemeVars] = React.useState<Record<string, string>>({});98 const triggerRef = React.useRef<HTMLButtonElement>(null);99 const listRef = React.useRef<HTMLUListElement>(null);100 const typed = React.useRef({ text: "", time: 0 });101 const selectedIndex = items.findIndex((o) => o.value === value);102 const selected = items[selectedIndex];103 104 React.useEffect(() => setMounted(true), []);105 106 const setOpen = React.useCallback(107 (next: boolean) => {108 if (openProp === undefined) setInnerOpen(next);109 onOpenChange?.(next);110 },111 [openProp, onOpenChange],112 );113 114 const commit = (i: number) => {115 const o = items[i];116 if (!o || o.disabled) return;117 if (valueProp === undefined) setInnerValue(o.value);118 if (o.value !== value) onValueChange?.(o.value);119 };120 121 const step = (from: number, dir: 1 | -1, count = 1) => {122 let result = from;123 let moved = 0;124 for (let i = from + dir; i >= 0 && i < items.length; i += dir) {125 if (items[i].disabled) continue;126 result = i;127 if (++moved >= count) break;128 }129 return result;130 };131 const edge = (dir: 1 | -1) => step(dir === 1 ? -1 : items.length, dir);132 133 // Measured placement with viewport collision flip; follows scroll/resize.134 const place = React.useCallback(() => {135 const t = triggerRef.current;136 if (!t) return;137 const r = t.getBoundingClientRect();138 const h = Math.min(LIST_MAX, items.length * 36 + 10);139 const below = window.innerHeight - r.bottom;140 const above = r.top;141 const s = side === "bottom" ? (below < h + 12 && above > below ? "top" : "bottom") : above < h + 12 && below > above ? "bottom" : "top";142 setPlaced(s);143 setPos(s === "bottom" ? { left: r.left, width: r.width, top: r.bottom + 6 } : { left: r.left, width: r.width, bottom: window.innerHeight - r.top + 6 });144 }, [items.length, side]);145 146 useIsoLayoutEffect(() => {147 if (!open) return;148 setThemeVars(readThemeVars(triggerRef.current));149 place();150 setActive((a) => (a >= 0 ? a : selectedIndex >= 0 ? selectedIndex : edge(1)));151 const onDown = (e: PointerEvent) => {152 const target = e.target as Node;153 if (!triggerRef.current?.contains(target) && !listRef.current?.contains(target)) setOpen(false);154 };155 window.addEventListener("resize", place);156 window.addEventListener("scroll", place, true);157 document.addEventListener("pointerdown", onDown);158 return () => {159 window.removeEventListener("resize", place);160 window.removeEventListener("scroll", place, true);161 document.removeEventListener("pointerdown", onDown);162 };163 }, [open, place, setOpen]);164 165 React.useEffect(() => {166 if (!open) setActive(-1);167 else listRef.current?.querySelector<HTMLElement>(`[data-index="${active}"]`)?.scrollIntoView({ block: "nearest" });168 }, [open, active]);169 170 const typeahead = (ch: string) => {171 const now = performance.now();172 const t = typed.current;173 t.text = now - t.time > 600 ? ch : t.text + ch;174 t.time = now;175 const q = t.text.toLowerCase();176 const start = active >= 0 ? active : selectedIndex;177 const n = items.length;178 const ordered = items.map((_, k) => (((start + (t.text.length === 1 ? 1 : 0) + k) % n) + n) % n);179 const hit = ordered.find((k) => !items[k].disabled && items[k].label.toLowerCase().startsWith(q));180 if (hit !== undefined) setActive(hit);181 };182 183 const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>) => {184 const k = e.key;185 if (!open) {186 if (["ArrowDown", "ArrowUp", "Enter", " "].includes(k)) {187 e.preventDefault();188 setOpen(true);189 } else if (k === "Home" || k === "End") {190 e.preventDefault();191 setActive(edge(k === "Home" ? 1 : -1));192 setOpen(true);193 } else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {194 setOpen(true);195 typeahead(k);196 }197 return;198 }199 if (k === "ArrowDown" || k === "ArrowUp") {200 e.preventDefault();201 if (e.altKey && k === "ArrowUp") {202 commit(active);203 setOpen(false);204 } else setActive((a) => step(a, k === "ArrowDown" ? 1 : -1));205 } else if (k === "PageDown" || k === "PageUp") {206 e.preventDefault();207 setActive((a) => step(a, k === "PageDown" ? 1 : -1, 6));208 } else if (k === "Home" || k === "End") {209 e.preventDefault();210 setActive(edge(k === "Home" ? 1 : -1));211 } else if (k === "Enter" || (k === " " && performance.now() - typed.current.time > 600)) {212 e.preventDefault();213 commit(active);214 setOpen(false);215 } else if (k === "Escape") {216 e.preventDefault();217 setOpen(false);218 } else if (k === "Tab") {219 commit(active);220 setOpen(false);221 } else if (k.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {222 e.preventDefault();223 typeahead(k);224 }225 };226 227 const listId = `${id}-list`;228 const optId = (i: number) => `${id}-opt-${i}`;229 const fromTop = placed === "bottom";230 231 return (232 <div className={`${styles.root} ${styles[size] ?? ""} ${className}`.trim()} style={{ "--mf-sel-accent": accentColor, "--mf-sel-w": `${width}px`, ...style } as React.CSSProperties}>233 {label && (234 <label id={`${id}-label`} htmlFor={`${id}-trigger`} className={styles.label}>235 {label}236 </label>237 )}238 <button239 ref={triggerRef}240 id={`${id}-trigger`}241 type="button"242 role="combobox"243 aria-haspopup="listbox"244 aria-expanded={open}245 aria-controls={listId}246 aria-labelledby={label ? `${id}-label ${id}-trigger` : undefined}247 aria-activedescendant={open && active >= 0 ? optId(active) : undefined}248 disabled={disabled}249 className={styles.trigger}250 data-placeholder={selected ? undefined : ""}251 onClick={() => setOpen(!open)}252 onKeyDown={onKeyDown}253 >254 <span className={styles.value}>{selected ? selected.label : placeholder}</span>255 <motion.svg aria-hidden="true" className={styles.chevron} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" animate={{ rotate: open ? 180 : 0 }} transition={{ duration: reduce ? 0 : 0.2 }}>256 <path d="m6 9 6 6 6-6" />257 </motion.svg>258 </button>259 {mounted &&260 createPortal(261 <AnimatePresence>262 {open && (263 <motion.ul264 ref={listRef}265 id={listId}266 role="listbox"267 aria-labelledby={label ? `${id}-label` : undefined}268 tabIndex={-1}269 className={`${styles.list} ${styles[size] ?? ""}`.trim()}270 style={{ ...themeVars, ...pos, "--mf-sel-accent": accentColor, transformOrigin: fromTop ? "top center" : "bottom center" } as React.CSSProperties}271 initial={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.96, y: fromTop ? -6 : 6 }}272 animate={reduce ? { opacity: 1 } : { opacity: 1, scale: 1, y: 0 }}273 exit={reduce ? { opacity: 0 } : { opacity: 0, scale: 0.97, y: fromTop ? -4 : 4 }}274 transition={{ duration: reduce ? 0.08 : 0.16, ease: [0.16, 1, 0.3, 1] }}275 onPointerDown={(e) => e.preventDefault()}276 >277 {items.map((o, i) => {278 const isSel = o.value === value;279 return (280 <li281 key={o.value}282 id={optId(i)}283 role="option"284 aria-selected={isSel}285 aria-disabled={o.disabled || undefined}286 data-index={i}287 data-active={i === active ? "" : undefined}288 className={styles.option}289 onPointerMove={() => !o.disabled && active !== i && setActive(i)}290 onClick={() => {291 if (o.disabled) return;292 commit(i);293 setOpen(false);294 triggerRef.current?.focus();295 }}296 >297 <span className={styles.optionLabel}>{o.label}</span>298 {isSel && (299 <svg aria-hidden="true" className={styles.check} width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4" strokeLinecap="round" strokeLinejoin="round">300 <motion.path d="M5 12.5l4.5 4.5L19 7.5" initial={reduce ? false : { pathLength: 0 }} animate={{ pathLength: 1 }} transition={{ duration: 0.25, delay: 0.06 }} />301 </svg>302 )}303 </li>304 );305 })}306 </motion.ul>307 )}308 </AnimatePresence>,309 document.body,310 )}311 </div>312 );313}