Forms
MorphSelect
A select box whose panel unfolds out of the button. A highlight glides between options, the tick draws itself, and arrows, Home / End, Enter, Esc and type-to-find all work.
Download
- React
- Tailwind CSS v4
- motion
components/ui/morph-select.tsx
"use client";
import { useEffect, useId, useRef, useState, type CSSProperties, type KeyboardEvent } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
export type MorphSelectOption = {
value: string;
label: string;
/** Small text on the right of the option. */
hint?: string;
disabled?: boolean;
};
type MorphSelectProps = {
options: MorphSelectOption[];
/** Field name shown above the box. */
label: string;
/** Picked value (controlled). */
value?: string;
/** Picked value at start (uncontrolled). */
defaultValue?: string;
/** Called with the new value when an option is picked. */
onChange?: (value: string) => void;
/** Text shown when nothing is picked. */
placeholder?: string;
/** Form field name, so the value is sent with a normal form. */
name?: string;
/** Highlight and tick colour. */
accent?: string;
/** Wrapper look (width etc.). */
className?: string;
};
/* MorphSelect: a select box whose panel unfolds out of the button. A highlight glides between options, the tick draws itself, and the keyboard works like a native select. */
export function MorphSelect({
options,
label,
value,
defaultValue,
onChange,
placeholder = "Choose…",
name,
accent = "#a78bfa",
className = "w-full max-w-xs",
}: MorphSelectProps) {
const reduce = useReducedMotion();
const uid = useId();
const labelId = `${uid}-label`;
const listId = `${uid}-list`;
const optId = (i: number) => `${uid}-opt-${i}`;
const button = useRef<HTMLButtonElement>(null);
const typed = useRef({ text: "", timer: 0 });
const [inner, setInner] = useState(defaultValue);
const selected = value ?? inner;
const selectedIndex = options.findIndex((o) => o.value === selected);
const [open, setOpen] = useState(false);
const [active, setActive] = useState(0);
const spring = reduce ? { duration: 0 } : { type: "spring" as const, stiffness: 480, damping: 36 };
useEffect(() => {
if (open) document.getElementById(`${uid}-opt-${active}`)?.scrollIntoView({ block: "nearest" });
}, [open, active, uid]);
const enabled = (i: number) => !!options[i] && !options[i].disabled;
const firstEnabled = (from: number, dir: 1 | -1) => {
for (let i = from; i >= 0 && i < options.length; i += dir) if (enabled(i)) return i;
return -1;
};
const step = (dir: 1 | -1) => {
const next = firstEnabled(active + dir, dir);
if (next !== -1) setActive(next);
};
const show = (at = selectedIndex) => {
setActive(enabled(at) ? at : firstEnabled(0, 1));
setOpen(true);
};
const pick = (i: number) => {
if (!enabled(i)) return;
const v = options[i].value;
if (value === undefined) setInner(v);
if (v !== selected) onChange?.(v);
setOpen(false);
};
const typeahead = (key: string) => {
const t = typed.current;
window.clearTimeout(t.timer);
t.text += key.toLowerCase();
t.timer = window.setTimeout(() => (t.text = ""), 500);
const start = open ? active : selectedIndex;
const from = t.text.length === 1 ? start + 1 : Math.max(start, 0);
for (let k = 0; k < options.length; k++) {
const i = (from + k) % options.length;
if (enabled(i) && options[i].label.toLowerCase().startsWith(t.text)) {
if (open) setActive(i);
else show(i);
return;
}
}
};
const onKey = (e: KeyboardEvent<HTMLButtonElement>) => {
const { key } = e;
if (key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey && (key !== " " || typed.current.text)) {
e.preventDefault();
return typeahead(key);
}
if (!open) {
if (["ArrowDown", "ArrowUp", "Enter", " "].includes(key)) {
e.preventDefault();
show();
} else if (key === "Home" || key === "End") {
e.preventDefault();
show(key === "Home" ? firstEnabled(0, 1) : firstEnabled(options.length - 1, -1));
}
return;
}
if (key === "ArrowDown" || key === "ArrowUp") {
e.preventDefault();
if (e.altKey && key === "ArrowUp") return pick(active);
step(key === "ArrowDown" ? 1 : -1);
} else if (key === "Home" || key === "End") {
e.preventDefault();
setActive(key === "Home" ? firstEnabled(0, 1) : firstEnabled(options.length - 1, -1));
} else if (key === "Enter" || key === " ") {
e.preventDefault();
pick(active);
} else if (key === "Escape") {
e.preventDefault();
setOpen(false);
} else if (key === "Tab") {
setOpen(false);
}
};
const current = options[selectedIndex];
return (
<div className={className} style={{ "--ms": accent } as CSSProperties}>
<span id={labelId} onClick={() => button.current?.focus()} className="mb-2 block text-sm font-medium text-white/80">
{label}
</span>
<div className="relative">
<button
ref={button}
type="button"
role="combobox"
aria-labelledby={labelId}
aria-haspopup="listbox"
aria-expanded={open}
aria-controls={listId}
aria-activedescendant={open && active >= 0 ? optId(active) : undefined}
onClick={() => (open ? setOpen(false) : show())}
onKeyDown={onKey}
onBlur={() => setOpen(false)}
className={`flex h-12 w-full items-center gap-3 rounded-xl border bg-[#0f0d1c] px-4 text-left text-base outline-none transition-colors duration-200 hover:border-white/25 focus-visible:border-[var(--ms)] focus-visible:ring-2 focus-visible:ring-[color-mix(in_srgb,var(--ms)_35%,transparent)] motion-reduce:transition-none ${
open ? "border-[var(--ms)]" : "border-white/12"
}`}
>
<span className={`min-w-0 flex-1 truncate ${current ? "text-white" : "text-white/40"}`}>{current?.label ?? placeholder}</span>
<motion.svg
aria-hidden
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className="size-4 shrink-0 text-white/50"
animate={{ rotate: open ? 180 : 0 }}
transition={spring}
>
<path d="m6 9 6 6 6-6" />
</motion.svg>
</button>
{name && <input type="hidden" name={name} value={selected ?? ""} />}
<AnimatePresence>
{open && (
<motion.ul
id={listId}
role="listbox"
aria-labelledby={labelId}
tabIndex={-1}
onMouseDown={(e) => e.preventDefault()}
initial={reduce ? { opacity: 0 } : { clipPath: "inset(0% 0% 100% 0% round 14px)", y: -6, opacity: 0.4 }}
animate={reduce ? { opacity: 1 } : { clipPath: "inset(0% 0% 0% 0% round 14px)", y: 0, opacity: 1 }}
exit={reduce ? { opacity: 0, transition: { duration: 0 } } : { clipPath: "inset(0% 0% 100% 0% round 14px)", y: -6, opacity: 0, transition: { duration: 0.16, ease: "easeIn" } }}
transition={reduce ? { duration: 0 } : { type: "spring", stiffness: 420, damping: 34 }}
className="absolute inset-x-0 top-full z-20 mt-2 max-h-64 overflow-auto rounded-[14px] border border-white/10 bg-[#141026] p-1.5 shadow-2xl shadow-black/50"
>
{options.map((o, i) => {
const isActive = i === active;
const isSelected = i === selectedIndex;
return (
<li
key={o.value}
id={optId(i)}
role="option"
aria-selected={isSelected}
aria-disabled={o.disabled || undefined}
onMouseMove={() => enabled(i) && !isActive && setActive(i)}
onClick={() => pick(i)}
className={`relative flex min-h-10 cursor-pointer items-center gap-3 rounded-lg px-3 py-2 text-[15px] ${
o.disabled ? "cursor-not-allowed text-white/25" : isSelected ? "text-white" : "text-white/70"
}`}
>
{isActive && !o.disabled && (
<motion.span
layoutId={`${uid}-hl`}
aria-hidden
className="absolute inset-0 rounded-lg"
style={{ background: "color-mix(in srgb, var(--ms) 16%, transparent)" }}
transition={spring}
/>
)}
<span className="relative min-w-0 flex-1 truncate">{o.label}</span>
{o.hint && <span className="relative shrink-0 text-xs text-white/40">{o.hint}</span>}
<svg aria-hidden viewBox="0 0 24 24" fill="none" stroke="var(--ms)" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round" className="relative size-4 shrink-0">
{isSelected && (
<motion.path
d="m5 12.5 4.5 4.5L19 7.5"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: reduce ? 0 : 0.3, delay: reduce ? 0 : 0.08, ease: "easeOut" }}
/>
)}
</svg>
</li>
);
})}
</motion.ul>
)}
</AnimatePresence>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/morph-select.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/morph-select-demo.tsx
"use client"; import { useState } from "react"; import { MorphSelect } from "@/components/ui/morph-select"; const OPTIONS = [ { value: "next", label: "Next.js", hint: "React" }, { value: "astro", label: "Astro", hint: "Islands" }, { value: "remix", label: "Remix", hint: "React" }, { value: "svelte", label: "SvelteKit", hint: "Svelte" }, { value: "nuxt", label: "Nuxt", hint: "Vue" }, { value: "angular", label: "Angular", hint: "Soon", disabled: true }, ]; export default function MorphSelectExample() { const [value, setValue] = useState("next"); return ( <div className="grid h-full place-items-start justify-items-center bg-[#0b0a14] p-5 pt-12"> <MorphSelect label="Framework" options={OPTIONS} value={value} onChange={setValue} name="framework" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| options | { value, label, hint?, disabled? }[] | — | Choices. hint = small text on the right. |
| label | string | — | Field name above the box. |
| value | string | — | Picked value (controlled). |
| defaultValue | string | — | Picked value at start (uncontrolled). |
| onChange | (value: string) => void | — | Called when an option is picked. |
| placeholder | string | "Choose…" | Text when nothing is picked. |
| name | string | — | Form field name (adds a hidden input). |
| accent | string | "#a78bfa" | Highlight and tick colour. |
| className | string | "w-full max-w-xs" | Wrapper look. |