← All components

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

terminal1 line
npm i mihirmodi-ui motion

The 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

Select — usage12 lines
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

PropNotes
value / onChangestring | null / (v) => voidControlled.
options(string | { value, label, hint? })[]`hint` sits on the right of the row — a currency, a shortcut.
placeholderstringShown while nothing is picked.
id / aria-labelstringPair `id` with a `<label for>`, or give it an `aria-label`.
disabledbooleanDims 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.

packages/ui/src/components/inputs/Select.tsx171 lines
"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>
  );
}
packages/ui/src/components/DropMenu.tsx105 lines
"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>
  );
}