Free shadcn/ui select (dropdown) component for React — grouped & scrollable lists, icons, country picker, React Hook Form validation, controlled value and sizes. Copy or install in one command.

Installation

1 Configure registry — once per project
{
  "registries": {
    "@designrevision": "https://registry.designrevision.com/r/{name}.json"
  }
}

Add to your existing components.json. Requires shadcn/ui v2.3+.

2 Install the component

Packages

@radix-ui/react-select
lucide-react
utils

Props

value = —
string

Controlled selected value (on Select). Note: SelectItem cannot have an empty-string value — set the Select value to "" to clear to the placeholder.

defaultValue = —
string

Initial value when uncontrolled (on Select).

onValueChange = —
(value: string) => void

Fires when the selection changes (on Select).

size = default
'sm' | 'default'

Trigger height preset (on SelectTrigger).

Ten composable parts: Select, SelectTrigger, SelectValue, SelectContent, SelectItem, SelectGroup, SelectLabel, SelectSeparator, and SelectScroll{Up,Down}Button. Set aria-invalid on SelectTrigger for the error styling.

Copied!
components/ui/select.tsx
"use client"

import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"

import { cn } from "@/lib/utils"

function Select({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
  return <SelectPrimitive.Root data-slot="select" {...props} />
}

function SelectGroup({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
  return <SelectPrimitive.Group data-slot="select-group" {...props} />
}

function SelectValue({
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
  return <SelectPrimitive.Value data-slot="select-value" {...props} />
}

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
  size?: "sm" | "default"
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon>
        <ChevronDownIcon className="size-4 opacity-50" />
      </SelectPrimitive.Icon>
    </SelectPrimitive.Trigger>
  )
}

function SelectContent({
  className,
  children,
  position = "popper",
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Content
        data-slot="select-content"
        className={cn(
          "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
          position === "popper" &&
            "data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
          className
        )}
        position={position}
        {...props}
      >
        <SelectScrollUpButton />
        <SelectPrimitive.Viewport
          className={cn(
            "p-1",
            position === "popper" &&
              "h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
          )}
        >
          {children}
        </SelectPrimitive.Viewport>
        <SelectScrollDownButton />
      </SelectPrimitive.Content>
    </SelectPrimitive.Portal>
  )
}

function SelectLabel({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
  return (
    <SelectPrimitive.Label
      data-slot="select-label"
      className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
        className
      )}
      {...props}
    >
      <span className="absolute right-2 flex size-3.5 items-center justify-center">
        <SelectPrimitive.ItemIndicator>
          <CheckIcon className="size-4" />
        </SelectPrimitive.ItemIndicator>
      </span>
      <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
    </SelectPrimitive.Item>
  )
}

function SelectSeparator({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
  return (
    <SelectPrimitive.Separator
      data-slot="select-separator"
      className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
      {...props}
    />
  )
}

function SelectScrollUpButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
  return (
    <SelectPrimitive.ScrollUpButton
      data-slot="select-scroll-up-button"
      className={cn(
        "flex cursor-default items-center justify-center py-1",
        className
      )}
      {...props}
    >
      <ChevronUpIcon className="size-4" />
    </SelectPrimitive.ScrollUpButton>
  )
}

function SelectScrollDownButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
  return (
    <SelectPrimitive.ScrollDownButton
      data-slot="select-scroll-down-button"
      className={cn(
        "flex cursor-default items-center justify-center py-1",
        className
      )}
      {...props}
    >
      <ChevronDownIcon className="size-4" />
    </SelectPrimitive.ScrollDownButton>
  )
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
}

Examples

With label

A labelled select with a placeholder — the canonical single-choice dropdown built from `SelectTrigger`, `SelectValue`, and `SelectItem`.

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-label-01.tsx
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

export default function SelectLabel01() {
  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="fruit">Favorite fruit</Label>
      <Select>
        <SelectTrigger id="fruit" className="w-full">
          <SelectValue placeholder="Select a fruit" />
        </SelectTrigger>
        <SelectContent>
          <SelectItem value="apple">Apple</SelectItem>
          <SelectItem value="banana">Banana</SelectItem>
          <SelectItem value="blueberry">Blueberry</SelectItem>
          <SelectItem value="grapes">Grapes</SelectItem>
          <SelectItem value="pineapple">Pineapple</SelectItem>
        </SelectContent>
      </Select>
    </div>
  )
}

Grouped & scrollable

A timezone picker with `SelectGroup` headings, `SelectSeparator` dividers, and the scroll buttons that appear automatically on long lists.

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-groups-01.tsx
import * as React from "react"

import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const zones = [
  {
    region: "North America",
    items: [
      ["est", "Eastern (EST)"],
      ["cst", "Central (CST)"],
      ["mst", "Mountain (MST)"],
      ["pst", "Pacific (PST)"],
      ["akst", "Alaska (AKST)"],
      ["hst", "Hawaii (HST)"],
    ],
  },
  {
    region: "Europe & Africa",
    items: [
      ["gmt", "Greenwich Mean (GMT)"],
      ["cet", "Central European (CET)"],
      ["eet", "Eastern European (EET)"],
      ["cat", "Central Africa (CAT)"],
    ],
  },
  {
    region: "Asia",
    items: [
      ["ist", "India (IST)"],
      ["cstc", "China (CST)"],
      ["jst", "Japan (JST)"],
      ["kst", "Korea (KST)"],
    ],
  },
  {
    region: "Australia & Pacific",
    items: [
      ["awst", "Australian Western (AWST)"],
      ["aest", "Australian Eastern (AEST)"],
      ["nzst", "New Zealand (NZST)"],
    ],
  },
]

export default function SelectGroups01() {
  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="timezone">Timezone</Label>
      <Select>
        <SelectTrigger id="timezone" className="w-full">
          <SelectValue placeholder="Select a timezone" />
        </SelectTrigger>
        <SelectContent>
          {zones.map((zone, index) => (
            <React.Fragment key={zone.region}>
              {index > 0 ? <SelectSeparator /> : null}
              <SelectGroup>
                <SelectLabel>{zone.region}</SelectLabel>
                {zone.items.map(([value, label]) => (
                  <SelectItem key={value} value={value}>
                    {label}
                  </SelectItem>
                ))}
              </SelectGroup>
            </React.Fragment>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

With icons

Status options each with a leading colored dot — the indicator renders in both the open list and the trigger via `SelectValue`.

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-icons-01.tsx
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const statuses = [
  { value: "backlog", label: "Backlog", color: "bg-muted-foreground" },
  { value: "todo", label: "Todo", color: "bg-blue-500" },
  { value: "in-progress", label: "In Progress", color: "bg-amber-500" },
  { value: "done", label: "Done", color: "bg-emerald-500" },
  { value: "canceled", label: "Canceled", color: "bg-destructive" },
]

export default function SelectIcons01() {
  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="status">Status</Label>
      <Select defaultValue="in-progress">
        <SelectTrigger id="status" className="w-full">
          <SelectValue placeholder="Select a status" />
        </SelectTrigger>
        <SelectContent>
          {statuses.map((status) => (
            <SelectItem key={status.value} value={status.value}>
              <span className={`size-2 rounded-full ${status.color}`} />
              {status.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

Country picker

A real-world country select with a flag emoji beside each option.

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-country-01.tsx
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const countries = [
  { value: "us", label: "United States", flag: "🇺🇸" },
  { value: "gb", label: "United Kingdom", flag: "🇬🇧" },
  { value: "ca", label: "Canada", flag: "🇨🇦" },
  { value: "au", label: "Australia", flag: "🇦🇺" },
  { value: "de", label: "Germany", flag: "🇩🇪" },
  { value: "fr", label: "France", flag: "🇫🇷" },
  { value: "jp", label: "Japan", flag: "🇯🇵" },
  { value: "br", label: "Brazil", flag: "🇧🇷" },
]

export default function SelectCountry01() {
  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="country">Country</Label>
      <Select defaultValue="us">
        <SelectTrigger id="country" className="w-full">
          <SelectValue placeholder="Select a country" />
        </SelectTrigger>
        <SelectContent>
          {countries.map((country) => (
            <SelectItem key={country.value} value={country.value}>
              <span className="text-base leading-none">{country.flag}</span>
              {country.label}
            </SelectItem>
          ))}
        </SelectContent>
      </Select>
    </div>
  )
}

React Hook Form

A required role select validated with `react-hook-form` and `zod` — the trigger gets `aria-invalid` and an error message until a value is chosen.

Packages

react-hook-form
zod
@hookform/resolvers
@designrevision/select
label
button

Props

No props documented yet.

Copied!
components/ui/select-form-01.tsx
"use client"

import * as React from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import { z } from "zod"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const schema = z.object({
  role: z.enum(["admin", "editor", "viewer"], {
    message: "Please select a role.",
  }),
})

type FormValues = z.infer<typeof schema>

export default function SelectForm01() {
  const [submitted, setSubmitted] = React.useState(false)
  const {
    handleSubmit,
    setValue,
    watch,
    formState: { errors },
  } = useForm<FormValues>({ resolver: zodResolver(schema) })

  const role = watch("role")

  return (
    <form
      onSubmit={handleSubmit(() => setSubmitted(true))}
      className="grid w-full max-w-sm gap-4"
    >
      <div className="grid gap-2">
        <Label htmlFor="role">Role</Label>
        <Select
          value={role}
          onValueChange={(value) =>
            setValue("role", value as FormValues["role"], {
              shouldValidate: true,
            })
          }
        >
          <SelectTrigger
            id="role"
            className="w-full"
            aria-invalid={!!errors.role}
            aria-describedby={errors.role ? "role-error" : undefined}
          >
            <SelectValue placeholder="Select a role" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="admin">Admin</SelectItem>
            <SelectItem value="editor">Editor</SelectItem>
            <SelectItem value="viewer">Viewer</SelectItem>
          </SelectContent>
        </Select>
        {errors.role ? (
          <p id="role-error" className="text-sm text-destructive">
            {errors.role.message}
          </p>
        ) : null}
      </div>
      <Button type="submit" className="w-fit">
        {submitted ? "Submitted!" : "Save"}
      </Button>
    </form>
  )
}

Controlled & clear

A controlled select with a default value and a clear button. Clearing sets the value back to `""`, which shows the placeholder again.

Packages

lucide-react
@designrevision/select
label
button

Props

No props documented yet.

Copied!
components/ui/select-controlled-01.tsx
"use client"

import * as React from "react"
import { X } from "lucide-react"

import { Button } from "@/components/ui/button"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const fruits = [
  ["apple", "Apple"],
  ["banana", "Banana"],
  ["blueberry", "Blueberry"],
  ["grapes", "Grapes"],
]

export default function SelectControlled01() {
  const [value, setValue] = React.useState("banana")

  return (
    <div className="grid w-full max-w-sm gap-2">
      <Label htmlFor="fruit-controlled">Favorite fruit</Label>
      <div className="flex items-center gap-2">
        <Select value={value} onValueChange={setValue}>
          <SelectTrigger id="fruit-controlled" className="w-full">
            <SelectValue placeholder="Select a fruit" />
          </SelectTrigger>
          <SelectContent>
            {fruits.map(([itemValue, label]) => (
              <SelectItem key={itemValue} value={itemValue}>
                {label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
        <Button
          type="button"
          variant="outline"
          size="icon"
          onClick={() => setValue("")}
          disabled={!value}
          aria-label="Clear selection"
        >
          <X />
        </Button>
      </div>
      <p className="text-sm text-muted-foreground">
        Selected:{" "}
        <span className="font-medium text-foreground">{value || "none"}</span>
      </p>
    </div>
  )
}

States

A disabled select, a single disabled option, and an invalid select (`aria-invalid` switches on the destructive ring, paired with `aria-describedby`).

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-states-01.tsx
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

export default function SelectStates01() {
  return (
    <div className="grid w-full max-w-sm gap-6">
      <div className="grid gap-2">
        <Label htmlFor="sel-disabled" className="text-muted-foreground">
          Disabled
        </Label>
        <Select disabled>
          <SelectTrigger id="sel-disabled" className="w-full">
            <SelectValue placeholder="Unavailable" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="a">Option A</SelectItem>
          </SelectContent>
        </Select>
      </div>
      <div className="grid gap-2">
        <Label htmlFor="sel-option">One option disabled</Label>
        <Select>
          <SelectTrigger id="sel-option" className="w-full">
            <SelectValue placeholder="Select a plan" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="free">Free</SelectItem>
            <SelectItem value="pro">Pro</SelectItem>
            <SelectItem value="enterprise" disabled>
              Enterprise — contact sales
            </SelectItem>
          </SelectContent>
        </Select>
      </div>
      <div className="grid gap-2">
        <Label htmlFor="sel-invalid">Required</Label>
        <Select>
          <SelectTrigger
            id="sel-invalid"
            className="w-full"
            aria-invalid
            aria-describedby="sel-error"
          >
            <SelectValue placeholder="Select an option" />
          </SelectTrigger>
          <SelectContent>
            <SelectItem value="x">Option X</SelectItem>
            <SelectItem value="y">Option Y</SelectItem>
          </SelectContent>
        </Select>
        <p id="sel-error" className="text-sm text-destructive">
          This field is required.
        </p>
      </div>
    </div>
  )
}

Sizes

The `sm` and `default` trigger heights via the built-in `size` prop on `SelectTrigger`.

Packages

@designrevision/select
label

Props

No props documented yet.

Copied!
components/ui/select-sizes-01.tsx
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const items = [
  ["apple", "Apple"],
  ["banana", "Banana"],
  ["grapes", "Grapes"],
]

export default function SelectSizes01() {
  return (
    <div className="flex flex-wrap items-end gap-6">
      <div className="grid gap-2">
        <Label htmlFor="sz-sm" className="text-xs">
          Small
        </Label>
        <Select defaultValue="apple">
          <SelectTrigger id="sz-sm" size="sm" className="w-[140px]">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {items.map(([value, label]) => (
              <SelectItem key={value} value={value}>
                {label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
      <div className="grid gap-2">
        <Label htmlFor="sz-default">Default</Label>
        <Select defaultValue="apple">
          <SelectTrigger id="sz-default" className="w-[160px]">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {items.map(([value, label]) => (
              <SelectItem key={value} value={value}>
                {label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      </div>
    </div>
  )
}

Frequently asked questions

Yes — it is the canonical shadcn/ui select, the full ten-part Radix composition (trigger, content, item, group, label, separator, scroll buttons) with the same tokens and aria-invalid handling, so it drops into any shadcn/ui project. On top of it we ship grouped, icon, country, React Hook Form, and controlled examples.
No — the shadcn/ui select is single-choice, like a native dropdown. For choosing several values use a multi-select, and for filtering a long list as you type use a combobox. A radio group is the better fit when you only have a few options and want them all visible.
A SelectItem cannot have an empty-string value — Radix reserves "" for the cleared/placeholder state, so throws. Give every item a real value, and to clear the selection set the Select value back to "" (which shows the placeholder). The Controlled example shows this with a clear button.
Pass defaultValue to the Select for an uncontrolled default, or value plus onValueChange for a controlled one. The value must match one of the SelectItem value props exactly.
The select uses onValueChange, which receives the chosen item value as a string — there is no native onChange. Store it in state for a controlled select, or read defaultValue for an uncontrolled one.
Yes. It is styled with CSS variable tokens like --primary, --input, --ring, and --popover, so it follows your theme automatically, including dark mode and any palette from a shadcn-compatible theme generator.