Select

Free · MIT

Fully 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.

MarkdownOpen in v0
Open in v0

Installation

$ npx shadcn@latest add https://goodui.dev/r/select.json

The 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

Use it for
  • 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.
Reach for something else
  • 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 needUse
Formsvariant="outline" (default)
Filters in a toolbarvariant="ghost" or "pill", size="sm"
Dense settings or tablesvariant="filled" or "underline"
Choose peopleoptions with avatar (URL, or true for initials) and description
More than ~10 optionssearchable, with group on the options
Several valuesmultiple with maxChips; clearable to reset
Calm menuanimation="fade-scale" (default) or "none"; "stagger" for short showcase lists
Playful or retro brandsvariant="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

PropTypeDefault
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—
disabledboolean—
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 / SpaceOn the closed trigger, opens the menu with the selected (or first enabled) option active.
Arrow Down / Arrow UpMoves the active option, wrapping and skipping disabled ones.
Home / EndJumps to the first or last enabled option (not while typing in the search box).
EnterPicks the active option. Single select closes; multiple toggles and stays open.
SpaceSame as Enter, except inside the search box where it types a space.
LettersType-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.
BackspaceWith multiple and the menu closed, removes the last selected value.
EscapeCloses the menu and returns focus to the trigger.
TabCloses 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