Select
A pill trigger with a springy menu, typeahead, and full keyboard support.
$
pnpm dlx shadcn@latest add https://lodeui.com/r/select.jsonCheckout stalls on Safari 17
Tapping Pay leaves the spinner running after the card is approved.
- Status
- Assignee
- Priority
components/examples/select-demo.tsx
"use client";
import * as React from "react";
import { CircleCheck, CircleDashed, CircleDot, Circle, SignalHigh, SignalLow, SignalMedium, TriangleAlert } from "lucide-react";
import { Select } from "@/components/ui/select";
function Avatar({ name, hue }: { name: string; hue: number }) {
const initials = name.split(" ").map((p) => p[0]).join("");
return (
<span
className="grid size-5 place-items-center rounded-full text-[9px] font-semibold text-white"
style={{ background: `oklch(0.62 0.13 ${hue})` }}
>
{initials}
</span>
);
}
const statuses = [
{ value: "backlog", label: "Backlog", icon: <Circle /> },
{ value: "todo", label: "Todo", icon: <CircleDot /> },
{ value: "in-progress", label: "In progress", icon: <CircleDashed /> },
{ value: "done", label: "Done", icon: <CircleCheck /> },
] as const;
const people = [
{ value: "marcus", label: "Marcus Johnson", icon: <Avatar name="Marcus Johnson" hue={40} /> },
{ value: "chloe", label: "Chloe Nguyen", icon: <Avatar name="Chloe Nguyen" hue={320} /> },
{ value: "daniel", label: "Daniel Kim", icon: <Avatar name="Daniel Kim" hue={220} /> },
] as const;
const priorities = [
{ value: "urgent", label: "Urgent", icon: <TriangleAlert /> },
{ value: "high", label: "High", icon: <SignalHigh /> },
{ value: "medium", label: "Medium", icon: <SignalMedium /> },
{ value: "low", label: "Low", icon: <SignalLow /> },
] as const;
export default function SelectDemo() {
return (
<div className="w-full max-w-lg rounded-xl border border-border bg-card p-7 shadow-sm">
<h3 className="text-lg font-medium">Checkout stalls on Safari 17</h3>
<p className="mt-2 text-[15px] leading-relaxed text-muted-foreground">
Tapping Pay leaves the spinner running after the card is approved.
</p>
<dl className="mt-6 space-y-2">
<Field label="Status">
<Select aria-label="Status" options={statuses} defaultValue="in-progress" />
</Field>
<Field label="Assignee">
<Select aria-label="Assignee" options={people} defaultValue="marcus" />
</Field>
<Field label="Priority">
<Select aria-label="Priority" options={priorities} defaultValue="high" />
</Field>
</dl>
</div>
);
}
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex items-center justify-between gap-4">
<dt className="text-[15px] text-muted-foreground">{label}</dt>
<dd>{children}</dd>
</div>
);
}When to use
- Picking one value from a short list: status, assignee, priority.
- Property panels where the current value reads as text with an icon.
When not to use
- Long lists people need to search: use a combobox.
- Two or three options that should stay visible: use a segmented control.
- Multiple values: use a combobox with multiple.
Installation
$
pnpm dlx shadcn@latest add https://lodeui.com/r/select.json1Install the dependencies
npm install motion lucide-react2Copy the source into your project
components/ui/select.tsx"use client"; import * as React from "react"; import { AnimatePresence, motion } from "motion/react"; import { Check, ChevronDown } from "lucide-react"; import { cn } from "@/lib/utils"; export interface SelectOption<T extends string> { value: T; label: string; icon?: React.ReactNode; } export interface SelectProps<T extends string> { options: readonly SelectOption<T>[]; value?: T; defaultValue?: T; onValueChange?: (value: T) => void; placeholder?: string; /** Which edge of the trigger the menu lines up with. */ align?: "start" | "end"; className?: string; "aria-label"?: string; } const MENU_SPACE = 280; export function Select<T extends string>({ options, value, defaultValue, onValueChange, placeholder = "Select", align = "end", className, ...aria }: SelectProps<T>) { const id = React.useId(); const [internal, setInternal] = React.useState<T | undefined>(defaultValue); const current = value ?? internal; const selected = options.find((o) => o.value === current); const [open, setOpen] = React.useState(false); const [side, setSide] = React.useState<"bottom" | "top">("bottom"); const [activeIndex, setActiveIndex] = React.useState(0); const rootRef = React.useRef<HTMLDivElement>(null); const triggerRef = React.useRef<HTMLButtonElement>(null); const listRef = React.useRef<HTMLDivElement>(null); const typeahead = React.useRef({ text: "", at: 0 }); const openMenu = (index?: number) => { const box = triggerRef.current?.getBoundingClientRect(); if (box) setSide(window.innerHeight - box.bottom < MENU_SPACE && box.top > MENU_SPACE ? "top" : "bottom"); setActiveIndex(index ?? Math.max(0, options.findIndex((o) => o.value === current))); setOpen(true); }; const close = (refocus = true) => { setOpen(false); if (refocus) triggerRef.current?.focus(); }; const choose = (index: number) => { const option = options[index]; if (!option) return; if (value === undefined) setInternal(option.value); onValueChange?.(option.value); close(); }; React.useEffect(() => { if (!open) return; listRef.current?.focus(); const onDown = (e: PointerEvent) => { if (!rootRef.current?.contains(e.target as Node)) close(false); }; document.addEventListener("pointerdown", onDown); return () => document.removeEventListener("pointerdown", onDown); }, [open]); const onTriggerKeyDown = (e: React.KeyboardEvent) => { if (["ArrowDown", "ArrowUp", "Enter", " "].includes(e.key)) { e.preventDefault(); openMenu(); } }; const onListKeyDown = (e: React.KeyboardEvent) => { const last = options.length - 1; switch (e.key) { case "ArrowDown": e.preventDefault(); setActiveIndex((i) => (i >= last ? 0 : i + 1)); break; case "ArrowUp": e.preventDefault(); setActiveIndex((i) => (i <= 0 ? last : i - 1)); break; case "Home": e.preventDefault(); setActiveIndex(0); break; case "End": e.preventDefault(); setActiveIndex(last); break; case "Enter": case " ": e.preventDefault(); choose(activeIndex); break; case "Escape": e.preventDefault(); close(); break; case "Tab": close(false); break; default: if (e.key.length === 1) { const now = e.timeStamp; const t = typeahead.current; t.text = now - t.at > 600 ? e.key.toLowerCase() : t.text + e.key.toLowerCase(); t.at = now; const match = options.findIndex((o) => o.label.toLowerCase().startsWith(t.text)); if (match >= 0) setActiveIndex(match); } } }; return ( <div ref={rootRef} className={cn("relative inline-flex", className)}> <motion.button ref={triggerRef} layout type="button" aria-haspopup="listbox" aria-expanded={open} aria-controls={open ? `${id}-list` : undefined} aria-label={aria["aria-label"]} onClick={() => (open ? close() : openMenu())} onKeyDown={onTriggerKeyDown} style={{ borderRadius: 999 }} transition={{ type: "spring", bounce: 0.1, duration: 0.35 }} className={cn( "flex h-11 items-center gap-2 border border-input bg-transparent pl-4 pr-3 text-sm font-medium outline-none", "transition-colors hover:bg-accent focus-visible:ring-2 focus-visible:ring-ring", open && "bg-accent", )} > <AnimatePresence mode="popLayout" initial={false}> <motion.span key={selected?.value ?? "placeholder"} layout="position" initial={{ opacity: 0, y: 8, filter: "blur(2px)" }} animate={{ opacity: 1, y: 0, filter: "blur(0px)" }} exit={{ opacity: 0, y: -8, filter: "blur(2px)" }} transition={{ type: "spring", bounce: 0, duration: 0.3 }} className={cn("flex items-center gap-2 whitespace-nowrap [&_svg]:size-4", !selected && "text-muted-foreground")} > {selected?.icon} {selected?.label ?? placeholder} </motion.span> </AnimatePresence> <motion.span layout="position" animate={{ rotate: open ? 180 : 0 }} className="text-muted-foreground"> <ChevronDown className="size-4" /> </motion.span> </motion.button> <AnimatePresence> {open && ( <motion.div ref={listRef} id={`${id}-list`} role="listbox" tabIndex={-1} aria-activedescendant={`${id}-opt-${activeIndex}`} onKeyDown={onListKeyDown} initial={{ opacity: 0, scale: 0.95, y: side === "bottom" ? -6 : 6 }} animate={{ opacity: 1, scale: 1, y: 0 }} exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.12 } }} transition={{ type: "spring", bounce: 0.15, duration: 0.3 }} style={{ transformOrigin: `${side === "bottom" ? "top" : "bottom"} ${align === "end" ? "right" : "left"}` }} className={cn( "absolute z-50 min-w-48 rounded-lg border border-border bg-popover p-1.5 text-popover-foreground shadow-xl outline-none", side === "bottom" ? "top-full mt-2" : "bottom-full mb-2", align === "end" ? "right-0" : "left-0", )} > {options.map((option, i) => { const isSelected = option.value === current; return ( <div key={option.value} id={`${id}-opt-${i}`} role="option" aria-selected={isSelected} onPointerMove={() => setActiveIndex(i)} onClick={() => choose(i)} className="relative flex h-10 cursor-pointer items-center gap-2.5 rounded-md px-3 text-sm [&_svg]:size-4" > {activeIndex === i && ( <motion.span layoutId={`${id}-highlight`} className="absolute inset-0 rounded-md bg-accent" transition={{ type: "spring", bounce: 0, duration: 0.25 }} /> )} <span className="relative flex flex-1 items-center gap-2.5 whitespace-nowrap"> {option.icon} {option.label} </span> {isSelected && <Check className="relative text-muted-foreground" />} </div> ); })} </motion.div> )} </AnimatePresence> </div> ); }
Usage
Import it from @/components/ui/select after installing.
example.tsx
import { Select } from "@/components/ui/select";
const priorities = [
{ value: "high", label: "High" },
{ value: "medium", label: "Medium" },
{ value: "low", label: "Low" },
] as const;
export function PriorityField() {
return <Select aria-label="Priority" options={priorities} defaultValue="medium" />;
}API reference
Select
A pill trigger that opens a listbox. The menu flips above the trigger when there isn't room below.
| Prop | Type | Default | Description |
|---|---|---|---|
| options | readonly { value: T; label: string; icon?: ReactNode }[] | – | The choices. |
| value / defaultValue | T | – | Controlled or uncontrolled value. |
| onValueChange | (value: T) => void | – | Called when an option is chosen. |
| placeholder | string | "Select" | Shown when there's no value. |
| align | "start" | "end" | "end" | Which edge of the trigger the menu lines up with. |
Keyboard interactions
| Keys | Action |
|---|---|
| EnterorSpaceor↓ | Open the menu |
| ↑or↓ | Move the highlight |
| HomeorEnd | First or last option |
| EnterorSpace | Choose |
| Esc | Close and return focus |
| Letters | Jump to the matching option |
Accessibility
- Trigger has aria-haspopup="listbox" and aria-expanded.
- The menu is a listbox using aria-activedescendant, so focus stays on one element.
- Selected option has aria-selected and a check mark.
Motion
- The menu scales in from its anchor corner (spring, 300ms).
- The trigger's width and label animate when the value changes.
Responsive behavior
- The trigger hugs its label; the menu is at least 192px wide.
- Opening near the bottom of the viewport flips the menu upward.
Performance
- Options render only while open. Typeahead resets after 600ms.
Notes for AI
- Always pass aria-label when there's no visible label.
- For more than ~10 options use Combobox instead.
This page as Markdown, for assistants and coding agents:
/components/select/markdownThe full library index for assistants is at /llms.txt.
Lode222 doc pagesllms.txt