UIForms and Inputsmotion/react

Select

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 props
240

Installation

Copies the component source and its CSS Module into your project.

npx motionfoundry add select

Writes 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.

example.tsx
1import { Select } from "@motionfoundry/ui";2 3export function Example() {4  return (5    <Select />6  );7}

Props

PropTypeDefault
options

Options as plain strings or option objects.

Array<string | { value: string; label?: string; disabled?: boolean }>8 fruits
value

Controlled selected value.

stringundefined
defaultValue

Initial selected value when uncontrolled.

stringundefined
onValueChange

Called with the newly selected value.

(value: string) => voidundefined
open

Controlled open state of the listbox.

booleanundefined
defaultOpen

Initial open state when uncontrolled.

booleanfalse
onOpenChange

Called when the listbox requests to open or close.

(open: boolean) => voidundefined
label

Visible field label, used as the accessible name.

string'Favorite fruit'
placeholder

Text shown when nothing is selected.

string'Select a fruit…'
side

Preferred side of the popover; flips when the viewport has no room.

'bottom' | 'top''bottom'
size

Trigger and option height / type size preset.

'sm' | 'md' | 'lg''md'
width

Trigger width in pixels; the popover matches it.

number240
accentColor

Color of the check mark and the open-state ring.

string'#6366f1'
disabled

Disable the select.

booleanfalse
className

Additional class merged onto the root element.

string''
style

Inline styles merged onto the root element.

React.CSSPropertiesundefined

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

components/ui/select.tsx
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}