Select
Free · MITFully custom single or multi select: outline, filled, ghost, underline, pill and neo triggers; icons, avatars, descriptions and groups; search with match highlight, chips, clear, type-ahead and full keyboard; animated menu that flips up when space runs out.
Installation
$ npx shadcn@latest add https://goodui.dev/r/select.jsonThe file lands in components/goodui/select.tsx and is yours to edit. With the @goodui registry added, npx shadcn@latest add @goodui/select works too.
Usage
import * as React from "react"
import { Scissors } from "lucide-react"
import { Select, type SelectOption } from "@/components/goodui/select"
const stylists: SelectOption[] = [
{ value: "maya", label: "Maya Chen", description: "Color specialist", avatar: "/team/maya.jpg", group: "Downtown" },
{ value: "leo", label: "Leo Park", description: "Barber", avatar: true, group: "Downtown" },
{ value: "ana", label: "Ana Ruiz", description: "Senior stylist", avatar: true, group: "Riverside" },
{ value: "sam", label: "Sam Okafor", description: "On leave until Dec 1", avatar: true, group: "Riverside", disabled: true },
]
export function BookingForm() {
const [stylist, setStylist] = React.useState<string | null>("maya")
const [extras, setExtras] = React.useState<string[]>([])
return (
<form className="flex flex-col gap-4">
<Select aria-label="Stylist" options={stylists} value={stylist} onValueChange={setStylist} searchable clearable name="stylist" />
<Select
aria-label="Add-on services"
multiple
icon={<Scissors />}
placeholder="Add services"
options="Deep conditioning, Scalp massage, Blow-dry, Toner, Brow tint"
value={extras}
onValueChange={setExtras}
name="extras"
/>
</form>
)
}When to use
- Picking one option from a list that is too long for radio buttons: a service, a staff member, a location.
- Picking several options shown as chips: add-on services, tags, team members (multiple).
- Long lists that need search, with matches highlighted: searchable, matching labels, descriptions, groups and keywords.
- Options that need more than text: avatars for people, icons for services, a description line, grouped sections.
- Two to five options that fit on screen: use checkbox (RadioGroup) or segmented-control, so all choices are visible.
- Free-form tags the user types: use tag-input.
- Actions in a menu (Edit, Duplicate, Delete): use dropdown-stagger; a select holds a value.
| If you need | Use |
|---|---|
| Forms | variant="outline" (default) |
| Filters in a toolbar | variant="ghost" or "pill", size="sm" |
| Dense settings or tables | variant="filled" or "underline" |
| Choose people | options with avatar (URL, or true for initials) and description |
| More than ~10 options | searchable, with group on the options |
| Several values | multiple with maxChips; clearable to reset |
| Calm menu | animation="fade-scale" (default) or "none"; "stagger" for short showcase lists |
| Playful or retro brands | variant="neo" |
Examples
Toolbar filter
<Select aria-label="Location" variant="pill" size="sm" className="w-44" options={["All locations", "Downtown", "Riverside"]} defaultValue="All locations" />Translated
<Select
aria-label="Mitarbeiter"
options={staff}
multiple
clearable
searchable
placeholder="Auswählen…"
searchPlaceholder="Suchen…"
emptyText="Keine Treffer"
labels={{ clear: "Auswahl löschen", selectedHint: (s) => `${s} ausgewählt. Rücktaste entfernt den letzten.` }}
/>API reference
| Prop | Type | Default |
|---|---|---|
| id Goes on the combobox trigger, so <label htmlFor> works. | string | — |
| aria-label Defines a string value that labels the current element. Names the trigger and the listbox. Pass one unless a visible <label htmlFor={id}> points at the select. | string | — |
| options Option objects, plain strings, or one comma-separated string: "Haircut, Beard trim". | (SelectOption | string)[] | string | — |
| placeholder Trigger text when nothing is selected. | string | — |
| variant Trigger style: outline (default), filled, ghost, underline, pill or neo. | "outline" | "filled" | "ghost" | "underline" | "pill" | "neo" | — |
| tone Accent for focus ring, selected option, checkboxes and chips. | "primary" | "neutral" | "success" | "warning" | "danger" | "info" | — |
| size | "sm" | "md" | "lg" | — |
| shadow Trigger shadow. Ignored by neo. | "none" | "sm" | "md" | "lg" | — |
| animation How the menu opens: fade-scale (default), slide (unrolls), stagger (options one by one) or none. | "none" | "fade-scale" | "slide" | "stagger" | — |
| searchable Search box at the top of the menu. | boolean | — |
| searchPlaceholder Placeholder and accessible name of the search box. | string | — |
| clearable × button that resets the value. | boolean | — |
| disabled | boolean | — |
| invalid Red border and aria-invalid on the trigger. | boolean | — |
| icon Leading icon in the trigger. | React.ReactNode | — |
| name Form field name (renders hidden inputs). | string | — |
| emptyText Shown when the search has no matches. | string | — |
| maxChips Multiple: chips shown before "+N". | number | — |
| open Menu open state (controlled). | boolean | — |
| defaultOpen Initial open state when uncontrolled. | boolean | — |
| onOpenChange Called when the menu opens or closes. | (open: boolean) => void | — |
| menuClassName Class for the dropdown menu. | string | — |
| labels Text for built-in controls and screen readers; pass translations here. | Partial<SelectLabels> | — |
| multiple true picks several values, shown as chips; value then becomes string[]. | false | — |
| value Selected value (controlled): a string or null, or string[] with multiple. | string | null | — |
| defaultValue Initial value when uncontrolled (string[] with multiple). | string | null | — |
| onValueChange Called with the picked value (null after clearing), or with the full string[] with multiple. | (value: string | null) => void | — |
Also accepts every <div> attribute.
Accessibility
| Arrow Down / Arrow Up / Enter / Space | On the closed trigger, opens the menu with the selected (or first enabled) option active. |
| Arrow Down / Arrow Up | Moves the active option, wrapping and skipping disabled ones. |
| Home / End | Jumps to the first or last enabled option (not while typing in the search box). |
| Enter | Picks the active option. Single select closes; multiple toggles and stays open. |
| Space | Same as Enter, except inside the search box where it types a space. |
| Letters | Type-ahead: jumps to the next option starting with the typed text (opens the menu if closed). With searchable, typing on the closed trigger opens the menu and starts the search. |
| Backspace | With multiple and the menu closed, removes the last selected value. |
| Escape | Closes the menu and returns focus to the trigger. |
| Tab | Closes the menu and moves focus on. |
- The trigger is a <button role="combobox"> with aria-haspopup="listbox", aria-expanded, aria-controls and aria-activedescendant; with searchable, the search input takes the combobox role and the active descendant.
- The menu is role="listbox" (aria-multiselectable with multiple), options are role="option" with aria-selected and aria-disabled, and groups are role="group" named after the group.
- There is no built-in visible label: pass aria-label, which names both the trigger and the listbox.
- With multiple, a visually hidden text lists the selected values and mentions Backspace (labels.selectedHint). Each chip's × is a real button in the tab order named "Remove Haircut" (labels.remove); focus returns to the trigger after removing. The clear button is named "Clear selection" (labels.clear).
- id goes on the combobox button, so <label htmlFor={id}> names it; without aria-label the listbox is labelled by the trigger.
- invalid sets aria-invalid on the trigger and a red border.
Motion
- fade-scale (default): the menu fades in, scales from 0.96 and moves 4px on a spring (stiffness 520, damping 34, mass 0.7). slide: it unrolls with a clip from the trigger side and moves 10px. stagger: options slide in 6px, 28 ms apart, 220 ms each.
- The menu closes with a 120 ms fade. It opens upward when there is not enough room below and more room above.
- Chips pop in and out (scale from 0.8) and the others shift into place. The chevron turns 180° in 200 ms; the underline variant draws its bar in 250 ms.
- Reduced motion: the menu, options and chips appear and disappear without animation, and trigger transitions are off.
Notes for AI
Coding agents read these rules through MCP and llms.txt, or paste this page with Copy page.
- Single: value is string | null and onValueChange(string | null) (null after clear). With multiple: value is string[] and onValueChange(string[]). Do not mix them.
- options accepts objects { value, label, description?, icon?, avatar?, group?, disabled?, keywords? }, plain strings, or one comma-separated string. Options of the same group should be listed together.
- The menu renders inside the component (no portal). Avoid overflow: hidden on parents, or give the parent room; it flips up when space below runs out.
- Default width is w-72; set className="w-full" in forms. name renders hidden inputs (one per value with multiple) for native form posts.
- Built-in text: placeholder, searchPlaceholder and emptyText are props; labels has clear, selectedHint and remove (chip ×).
FAQ
Can I use it with a <label> element?
Yes. Pass id to Select and point <label htmlFor> at it: the id goes on the combobox button. Without a visible label, pass aria-label.
How does search match?
It ignores case and accents and matches anywhere in the label, description, group name and keywords. The matched part of the label is underlined.
Related
Six field styles with top, floating, inline or hidden labels, focus ring, glow, underline-grow or lift, error shake with sliding messages, icon and text addons, clear button, counter and auto-growing textarea.
Checkbox and RadioGroup on real inputs with default, filled, outline, circle, neo and whole-card variants, draw, pop and bounce animations, tones, sizes, shadows, indeterminate, invalid and descriptions.
iOS-style segmented control whose thumb springs to the chosen segment and squeezes while pressed, with sizes, icons, full-width mode, dividers that hide next to the thumb, and radio-group arrow keys.
Chips input where Enter, comma or paste adds tags that spring in, Backspace arms then removes, duplicates shake, with a filtered suggestions dropdown, validation and a max count.