Teseria
All elements

Select

A custom dropdown. The card pops under the trigger on a spring, rows stagger in, a highlight slides between rows, the tick pops and the chosen label swaps in through a blur.

Code

1. Install

Terminal
npm i framer-motion lucide-react

2. Files

Select.tsx277 lines
"use client";
// Select: a custom dropdown. The card pops under the trigger on a spring, rows stagger in, a highlight slides between rows,
// the tick pops and the chosen label swaps in through a blur. Arrow keys, Home, End, type-ahead, Enter and Escape work;
// `searchable` adds a filter box.
import { useCallback, useEffect, useId, useRef, useState, type ChangeEvent, type CSSProperties, type KeyboardEvent, type ReactNode } from "react";
import { AnimatePresence, motion } from "framer-motion";
import { Check, ChevronDown, Search } from "lucide-react";

export type SelectCue = "unfold" | "fold" | "choose";

export type SelectOption = {
  /** Value reported to onChange. */
  value: string;
  /** Row label. */
  label: ReactNode;
  /** Small second line under the label. */
  hint?: ReactNode;
  /** Icon before the label, about 16px. */
  icon?: ReactNode;
  /** Greys the row out and blocks picking it. */
  disabled?: boolean;
  /** Group heading; consecutive options with the same group sit under one heading. */
  group?: string;
};

export type SelectProps = {
  /** The choices. */
  options?: SelectOption[];
  /** Selected value. Controlled. */
  value?: string;
  /** Initial value when uncontrolled. */
  defaultValue?: string;
  /** Called with the picked value. */
  onChange?: (value: string) => void;
  /** Shown while nothing is selected. */
  placeholder?: ReactNode;
  /** Adds a filter box at the top of the card. */
  searchable?: boolean;
  /** Placeholder of the filter box. */
  searchPlaceholder?: string;
  /** Shown when the filter matches nothing. */
  emptyText?: string;
  /** Trigger height: sm 32px, md 40px, lg 48px. */
  size?: "sm" | "md" | "lg";
  /** Accessible name of the trigger. */
  label?: string;
  /** Starts open, without moving focus or a sound. */
  defaultOpen?: boolean;
  /** Sound hook: "unfold" on open, "fold" on close, "choose" on a pick. */
  onSound?: (cue: SelectCue) => void;
  className?: string;
  style?: CSSProperties;
};

export const DEFAULT_OPTIONS: SelectOption[] = [
  { value: "personal", label: "Personal · $48,210", hint: "USD" },
  { value: "savings", label: "Savings · $10,000", hint: "USD" },
  { value: "studio", label: "Studio · $25,000", hint: "EUR" },
  { value: "reserve", label: "Reserve · $100,000", hint: "Locked" },
];

const EXIT = [0.4, 0, 1, 1] as const;
// motion presets for the card, the rows and the tick
const CARD_MOTION = {
  initial: { opacity: 0, y: -8, scaleX: 0.92, scaleY: 0.8 },
  animate: { opacity: 1, y: 0, scaleX: 1, scaleY: 1 },
  exit: { opacity: 0, y: -6, scaleX: 0.96, scaleY: 0.9, transition: { duration: 0.14, ease: EXIT } },
};
const TICK_MOTION = {
  initial: { scale: 0.4, opacity: 0 },
  animate: { scale: 1, opacity: 1 },
  exit: { scale: 0.4, opacity: 0, transition: { duration: 0.14, ease: EXIT } },
};
const ROW_MOTION = { initial: { opacity: 0, y: -6 }, animate: { opacity: 1, y: 0 } };
const SNAP = { type: "spring", stiffness: 380, damping: 34, mass: 0.8 } as const;
const POP = { type: "spring", stiffness: 500, damping: 18, mass: 0.7 } as const;

export function Select({
  options = DEFAULT_OPTIONS,
  value,
  defaultValue = "personal",
  onChange,
  placeholder = "Select…",
  searchable,
  searchPlaceholder = "Search…",
  emptyText = "No matches",
  size = "md",
  label = "Account",
  defaultOpen = false,
  onSound,
  className,
  style,
}: SelectProps) {
  const id = useId();
  const [innerValue, setInnerValue] = useState<string | undefined>(defaultValue);
  const selected = value ?? innerValue;
  const [open, setOpen] = useState(defaultOpen);
  const woke = useRef(!defaultOpen); // false while resting (defaultOpen, untouched)
  const [query, setQuery] = useState("");
  const [highlighted, setHighlighted] = useState(-1);
  const root = useRef<HTMLDivElement>(null);
  const trigger = useRef<HTMLButtonElement>(null);
  const list = useRef<HTMLDivElement>(null);
  const search = useRef<HTMLInputElement>(null);
  const sound = useRef(onSound);
  sound.current = onSound;
  const current = options.find((option) => option.value === selected);
  // the longest label, drawn invisibly in the trigger, so the trigger is as wide as its widest choice and a pick never resizes it
  const widest = options.reduce((a, o) => (String(o.label).length > String(a.label).length ? o : a), options[0]);
  const needle = query.toLowerCase();
  const visible = query ? options.filter((option) => String(option.label).toLowerCase().includes(needle)) : options;

  const openList = () => {
    sound.current?.("unfold");
    setOpen(true);
    setQuery("");
    setHighlighted(Math.max(0, visible.findIndex((option) => option.value === selected)));
  };
  const closeList = useCallback((quiet = false) => {
    if (!quiet) sound.current?.("fold");
    setOpen(false);
    setQuery("");
    setHighlighted(-1);
  }, []);
  const toggleList = () => (open ? closeList() : openList());
  const pick = (option: SelectOption) => {
    if (option.disabled) return;
    onSound?.("choose");
    if (value === undefined) setInnerValue(option.value);
    onChange?.(option.value);
    closeList(true); // the pick cue is the only sound
    trigger.current?.focus();
  };

  // while open: a press outside closes the list, and the filter box takes focus
  useEffect(() => {
    if (!open) return;
    const closeOnOutsidePress = (e: PointerEvent) => (root.current?.contains(e.target as Node) ? (woke.current = true) : woke.current && closeList());
    const listen = setTimeout(() => document.addEventListener("pointerdown", closeOnOutsidePress), 0);
    const focusSearch = searchable ? setTimeout(() => search.current?.focus(), 30) : undefined;
    return () => {
      clearTimeout(listen);
      clearTimeout(focusSearch);
      document.removeEventListener("pointerdown", closeOnOutsidePress);
    };
  }, [open, searchable, closeList]);
  useEffect(() => {
    list.current?.querySelectorAll<HTMLElement>(".slct-row")[highlighted]?.scrollIntoView({ block: "nearest" });
  }, [highlighted]);

  const filter = (e: ChangeEvent<HTMLInputElement>) => {
    setQuery(e.target.value);
    setHighlighted(0);
  };
  const startsWith = (option: SelectOption, letter: string) => String(option.label).toLowerCase().startsWith(letter.toLowerCase());
  const onKey = (e: KeyboardEvent) => {
    woke.current = true;
    if (!open) {
      if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) {
        e.preventDefault();
        openList();
      }
      return;
    }
    const last = visible.length - 1;
    switch (e.key) {
      case "Escape":
        e.preventDefault();
        closeList();
        trigger.current?.focus();
        return;
      case "Tab":
        closeList();
        return;
      case "ArrowDown":
        e.preventDefault();
        setHighlighted(Math.min(last, highlighted + 1));
        return;
      case "ArrowUp":
        e.preventDefault();
        setHighlighted(Math.max(0, highlighted - 1));
        return;
      case "Home":
        e.preventDefault();
        setHighlighted(0);
        return;
      case "End":
        e.preventDefault();
        setHighlighted(last);
        return;
      case "Enter":
        e.preventDefault();
        if (visible[highlighted]) pick(visible[highlighted]);
        return;
    }
    // type-ahead: jump to the next option starting with the letter, wrapping around
    if (!searchable && e.key.length === 1 && /\S/.test(e.key)) {
      const after = visible.findIndex((option, i) => i > highlighted && startsWith(option, e.key));
      const match = after >= 0 ? after : visible.findIndex((option) => startsWith(option, e.key));
      if (match >= 0) setHighlighted(match);
    }
  };

  return (
    <div ref={root} className={className ? `slct-root ${className}` : "slct-root"} style={style} data-size={size} data-open={open || undefined} onKeyDown={onKey}>
      <button ref={trigger} type="button" role="combobox" className="slct-trigger" aria-haspopup="listbox" aria-expanded={open} aria-controls={`${id}-list`} aria-activedescendant={open && visible[highlighted] ? `${id}-o${highlighted}` : undefined} aria-label={label} onClick={toggleList}>
        {current?.icon && <span className="slct-ico">{current.icon}</span>}
        <span className="slct-value" data-empty={!current || undefined}>
          <span className="slct-size" aria-hidden>{widest?.label}</span>
          <AnimatePresence initial={false}>
            <motion.span className="slct-label" key={String(selected ?? "__")} initial={{ opacity: 0, y: 5, filter: "blur(3px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)", transitionEnd: { filter: "none" } }} exit={{ opacity: 0, y: -5, filter: "blur(3px)" }} transition={{ duration: 0.16 }}>
              {current?.label ?? placeholder}
            </motion.span>
          </AnimatePresence>
        </span>
        <motion.span className="slct-chev" animate={{ rotate: open ? 180 : 0 }} transition={SNAP}>
          <ChevronDown />
        </motion.span>
      </button>
      <AnimatePresence>
        {open && (
          <motion.div
            className="slct-card"
            {...CARD_MOTION}
            transition={{ ...POP, mass: 0.8, opacity: { duration: 0.14 } }}
          >
            {searchable && (
              <div className="slct-search">
                <Search />
                <input ref={search} value={query} placeholder={searchPlaceholder} aria-label="Search options" aria-controls={`${id}-list`} aria-activedescendant={visible[highlighted] ? `${id}-o${highlighted}` : undefined} onChange={filter} />
              </div>
            )}
            <div ref={list} id={`${id}-list`} role="listbox" className="slct-list">
              {visible.length === 0 && <div className="slct-empty">{emptyText}</div>}
              {visible.map((option, i) => {
                const on = option.value === selected;
                const startsGroup = option.group && option.group !== visible[i - 1]?.group;
                return (
                  <div key={option.value}>
                    {startsGroup && <div className="slct-group">{option.group}</div>}
                    <motion.div
                      id={`${id}-o${i}`}
                      role="option"
                      aria-selected={on}
                      aria-disabled={option.disabled || undefined}
                      className="slct-row"
                      data-on={on || undefined}
                      data-hot={highlighted === i || undefined}
                      {...ROW_MOTION}
                      transition={{ ...SNAP, mass: 0.6, delay: 0.02 + Math.min(i, 8) * 0.025 }}
                      onPointerEnter={() => setHighlighted(i)}
                      onClick={() => pick(option)}
                    >
                      {highlighted === i && <motion.span className="slct-hl" layoutId={`${id}-hl`} transition={{ ...SNAP, mass: 0.5 }} />}
                      {option.icon && <span className="slct-ico">{option.icon}</span>}
                      <span className="slct-text">
                        {option.label}
                        {option.hint && <span className="slct-hint">{option.hint}</span>}
                      </span>
                      <AnimatePresence initial={false}>
                        {on && (
                          <motion.span className="slct-tick" {...TICK_MOTION} transition={{ ...POP, mass: 0.5, opacity: { duration: 0.16 } }}>
                            <Check />
                          </motion.span>
                        )}
                      </AnimatePresence>
                    </motion.div>
                  </div>
                );
              })}
            </div>
          </motion.div>
        )}
      </AnimatePresence>
    </div>
  );
}
Select.css196 lines
/* Select. Prefix slct-. */
.slct-root, .slct-root * { box-sizing: border-box; }
:where(.slct-root) button { font: inherit; color: inherit; background: none; border: 0; padding: 0; margin: 0; }
.slct-root {
  --slct-h: 40px;
  --slct-px: 12px;
  --slct-fs: 14px;
  position: relative;
  display: inline-block;
  min-width: 180px;
  font-family: var(--ts-font, system-ui, sans-serif);
}
.slct-root[data-size="sm"] {
  --slct-h: 32px;
  --slct-px: 10px;
  --slct-fs: 13px;
}
.slct-root[data-size="lg"] {
  --slct-h: 48px;
  --slct-px: 14px;
  --slct-fs: 15px;
}
.slct-trigger {
  width: 100%;
  height: var(--slct-h);
  padding: 0 calc(var(--slct-px) - 2px) 0 var(--slct-px);
  border-radius: 999px;
  background: var(--ts-raised);
  color: var(--ts-fg);
  box-shadow: var(--ts-edge);
  font-size: var(--slct-fs);
  font-weight: 500;
  letter-spacing: -0.01em;
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  text-align: left;
  -webkit-tap-highlight-color: transparent;
  transition: box-shadow 0.16s cubic-bezier(.22, 1, .36, 1), scale 0.22s cubic-bezier(.22, 1, .36, 1), background 0.16s cubic-bezier(.22, 1, .36, 1);
}
.slct-trigger:hover,
.slct-root[data-open] .slct-trigger {
  background: var(--ts-tint-1);
}
.slct-trigger:active {
  scale: 0.96;
}
.slct-trigger:focus {
  outline: 0;
}
.slct-root .slct-trigger:focus-visible {
  box-shadow: 0 0 0 2px var(--ts-sunken), 0 0 0 4px var(--ts-accent);
}
/* the old and the new label share one cell while they swap, and the invisible widest label sets the width: nothing resizes */
.slct-value {
  flex: 1;
  min-width: 0;
  display: grid;
  align-items: center;
  overflow: hidden;
  white-space: nowrap;
}
.slct-value > * {
  grid-area: 1 / 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}
.slct-size {
  visibility: hidden;
  height: 0;
}
.slct-value[data-empty] {
  color: var(--ts-fg-faint);
  font-weight: 400;
}
.slct-chev {
  display: grid;
  color: var(--ts-fg-muted);
}
.slct-chev svg,
.slct-ico svg,
.slct-tick svg,
.slct-search svg {
  width: 16px;
  height: 16px;
}
.slct-ico {
  display: grid;
  place-items: center;
  color: var(--ts-fg-tertiary);
  flex-shrink: 0;
}
.slct-card {
  position: absolute;
  z-index: 30;
  top: calc(100% + 6px);
  left: 0;
  right: 0;
  border-radius: var(--ts-r-pop);
  background: var(--ts-raised);
  box-shadow: var(--ts-edge);
  transform-origin: top center;
  overflow: hidden;
}
[data-theme="dark"] .slct-card {
  background: var(--ts-tint-3);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .slct-card {
    background: var(--ts-tint-3);
  }
}
.slct-search {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 0 12px;
  height: 38px;
  box-shadow: inset 0 -1px 0 var(--ts-border);
  color: var(--ts-fg-muted);
}
.slct-search input {
  flex: 1;
  border: 0;
  background: transparent;
  font: inherit;
  font-size: 13.5px;
  color: var(--ts-fg);
  outline: 0;
}
.slct-list {
  max-height: 260px;
  overflow: auto;
  padding: 6px;
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.slct-group {
  padding: 8px 10px 4px;
  font-size: 12px;
  font-weight: 500;
  color: var(--ts-fg-faint);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.slct-empty {
  padding: 14px;
  text-align: center;
  color: var(--ts-fg-faint);
  font-size: 13px;
}
.slct-row {
  position: relative;
  display: flex;
  align-items: center;
  gap: 10px;
  height: 44px;
  padding: 0 10px;
  border-radius: calc(var(--ts-r-pop) - 6px);
  font-size: 13.5px;
  letter-spacing: -0.01em;
  color: var(--ts-fg);
  cursor: pointer;
  user-select: none;
}
.slct-row[aria-disabled] {
  opacity: 0.4;
  cursor: default;
}
/* (0,2,0) so `.slct-row > *` below can't make it a relative flex item that pushes the label right */
.slct-row > .slct-hl {
  position: absolute;
  inset: 0;
  border-radius: inherit;
  background: rgba(var(--ts-fg-rgb), 0.07);
}
.slct-row > * {
  position: relative;
}
.slct-text {
  flex: 1;
  display: flex;
  flex-direction: column;
  line-height: 1.2;
}
.slct-hint {
  font-size: 12px;
  color: var(--ts-fg-muted);
}
.slct-tick {
  display: grid;
  color: var(--ts-fg-tertiary);
}
tokens.cssshared · add once
/*
 * Teseria tokens — the only colours, fonts and corner radii a block reads.
 *
 * Every name sits under --ts- so it never collides with your app's own variables. Include this file once, or point
 * the names at your theme (e.g. --ts-accent: var(--brand)). Light by default; dark follows [data-theme="dark"] on
 * any ancestor, or the OS setting unless [data-theme="light"] is set.
 *
 *   Surfaces   page → raised (cards, popovers) · sunken (wells, tracks) · tint-1..3 (neutral fills, light to deep)
 *   Text       fg → secondary → tertiary → muted → faint · fg-inverse sits on an fg-coloured fill
 *   Lines      border · edge (the box-shadow a filled surface uses instead of an outline: soft in light, nothing in
 *              dark; composes with other shadows)
 *   Signals    accent · focus · success · warning · danger
 *   Corners    controls are always pills · r-panel (dialogs, sheets, cards) · r-pop (menus, popovers, toasts) · r-inner
 *              (thumbnails, inner cards); a row inside a panel uses the panel radius minus the panel padding
 */
:root {
  --ts-font: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --ts-r-panel: 24px;
  --ts-r-pop: 18px;
  --ts-r-inner: 12px;

  --ts-page: #ffffff;
  --ts-raised: #ffffff;
  --ts-sunken: #f3f2f0;
  --ts-tint-1: #f5f4f2;
  --ts-tint-2: #ecebe8;
  --ts-tint-3: #e3e1dd;

  --ts-fg: #161514;
  --ts-fg-rgb: 22, 21, 20;
  --ts-fg-secondary: #3a3836;
  --ts-fg-tertiary: #585552;
  --ts-fg-muted: #6d6a66;
  --ts-fg-faint: #89857f;
  --ts-fg-inverse: #ffffff;

  --ts-border: rgba(22, 21, 20, 0.1);
  --ts-edge: 0 0 0 1px rgba(22, 21, 20, 0.07), 0 1px 2px rgba(22, 21, 20, 0.05);

  --ts-accent: #3446eb;
  --ts-focus: #3446eb;
  --ts-success: #13804a;
  --ts-warning: #a9650b;
  --ts-danger: #df474e;
}

:root[data-theme="dark"] {
  --ts-page: #0b0a09;
  --ts-raised: #24221f;
  --ts-sunken: #0d0c0b;
  --ts-tint-1: #131211;
  --ts-tint-2: #1b1a18;
  --ts-tint-3: #2b2926;

  --ts-fg: #f6f5f3;
  --ts-fg-rgb: 246, 245, 243;
  --ts-fg-secondary: #bab8b4;
  --ts-fg-tertiary: #9e9b97;
  --ts-fg-muted: #8e8b86;
  --ts-fg-faint: #72706c;
  --ts-fg-inverse: #0b0a09;

  --ts-border: rgba(246, 245, 243, 0.1);
  --ts-edge: 0 0 #0000;

  --ts-accent: #3446eb;
  --ts-focus: #7f8cff;
  --ts-success: #3ccf80;
  --ts-warning: #efa130;
  --ts-danger: #df474e;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ts-page: #0b0a09;
    --ts-raised: #24221f;
    --ts-sunken: #0d0c0b;
    --ts-tint-1: #131211;
    --ts-tint-2: #1b1a18;
    --ts-tint-3: #2b2926;

    --ts-fg: #f6f5f3;
    --ts-fg-rgb: 246, 245, 243;
    --ts-fg-secondary: #bab8b4;
    --ts-fg-tertiary: #9e9b97;
    --ts-fg-muted: #8e8b86;
    --ts-fg-faint: #72706c;
    --ts-fg-inverse: #0b0a09;

    --ts-border: rgba(246, 245, 243, 0.1);
    --ts-edge: 0 0 #0000;

    --ts-accent: #3446eb;
    --ts-focus: #7f8cff;
    --ts-success: #3ccf80;
    --ts-warning: #efa130;
    --ts-danger: #df474e;
  }
}

3. Usage

Demo.tsx
import { Select } from "./Select";
import "./Select.css";

export default function Demo() {
  return <Select />;
}