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
npm i framer-motion lucide-react
2. Files
"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. 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);
}
/*
* 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
import { Select } from "./Select";
import "./Select.css";
export default function Demo() {
return <Select />;
}