rapidUI

Select

Native-feeling select menu built on Base UI Select.

Installation

pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/select.json

Installs to @/components/ui/select.

Manual installation

Install the dependencies:

pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0

Copy the source into your project:

components/ui/select.tsx
'use client'

import { Select as SelectPrimitive } from '@base-ui/react/select'
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from 'lucide-react'
import * as React from 'react'
import { cn } from '@/lib/utils'

type SelectReadOnlyContextValue = {
	readOnly: boolean
	setTriggerReadOnly: (readOnly: boolean) => void
}

const SelectReadOnlyContext = React.createContext<SelectReadOnlyContextValue>({
	readOnly: false,
	setTriggerReadOnly: () => {},
})

/**
 * `readOnly` shows the value but keeps the list closed (pointer, keyboard and label clicks) and blocks changes.
 * Unlike `disabled`, the trigger stays focusable. It can be set here or on `SelectTrigger`.
 */
function Select<Value, Multiple extends boolean | undefined = false>({
	readOnly: readOnlyProp = false,
	onOpenChange,
	...props
}: SelectPrimitive.Root.Props<Value, Multiple>) {
	const [triggerReadOnly, setTriggerReadOnly] = React.useState(false)
	const readOnly = readOnlyProp || triggerReadOnly
	const contextValue = React.useMemo(() => ({ readOnly, setTriggerReadOnly }), [readOnly])

	return (
		<SelectReadOnlyContext.Provider value={contextValue}>
			<SelectPrimitive.Root<Value, Multiple>
				readOnly={readOnly}
				onOpenChange={(open, eventDetails) => {
					// While read-only, keep the list closed however it is asked to open
					if (readOnly && open) {
						eventDetails.cancel()
						return
					}
					onOpenChange?.(open, eventDetails)
				}}
				{...props}
			/>
		</SelectReadOnlyContext.Provider>
	)
}

function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
	return <SelectPrimitive.Group data-slot="select-group" className={cn('scroll-my-1 p-1', className)} {...props} />
}

function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
	return <SelectPrimitive.Value data-slot="select-value" className={cn('flex flex-1 text-left', className)} {...props} />
}

function SelectTrigger({
	className,
	size = 'default',
	readOnly: readOnlyProp,
	children,
	...props
}: SelectPrimitive.Trigger.Props & {
	size?: 'sm' | 'default'
	/** Shows the value but prevents opening the list. Unlike `disabled`, the trigger stays focusable. */
	readOnly?: boolean
}) {
	const { readOnly: rootReadOnly, setTriggerReadOnly } = React.useContext(SelectReadOnlyContext)
	const readOnly = readOnlyProp || rootReadOnly

	React.useLayoutEffect(() => {
		setTriggerReadOnly(!!readOnlyProp)
		return () => setTriggerReadOnly(false)
	}, [readOnlyProp, setTriggerReadOnly])

	return (
		<SelectPrimitive.Trigger
			data-slot="select-trigger"
			data-size={size}
			data-read-only={readOnly || undefined}
			// Base UI's trigger is a combobox, which allows aria-readonly
			aria-readonly={readOnly || undefined}
			className={cn(
				"flex w-full items-center justify-between gap-1.5 rounded-lg border border-input bg-background py-2 pr-2 pl-2.5 text-sm whitespace-nowrap transition-colors outline-none select-none hover:border-primary/90 focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-read-only:cursor-default data-read-only:border-dashed data-read-only:border-input data-read-only:bg-muted/50 data-read-only:text-foreground data-read-only:hover:border-input data-read-only:hover:bg-muted/50 data-[size=default]:h-10 data-[size=sm]:h-8 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		>
			{children}
			<SelectPrimitive.Icon render={<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />} />
		</SelectPrimitive.Trigger>
	)
}

function SelectContent({
	className,
	children,
	side = 'bottom',
	sideOffset = 4,
	align = 'center',
	alignOffset = 0,
	alignItemWithTrigger = true,
	...props
}: SelectPrimitive.Popup.Props &
	Pick<SelectPrimitive.Positioner.Props, 'align' | 'alignOffset' | 'side' | 'sideOffset' | 'alignItemWithTrigger'>) {
	return (
		<SelectPrimitive.Portal>
			<SelectPrimitive.Positioner
				side={side}
				sideOffset={sideOffset}
				align={align}
				alignOffset={alignOffset}
				alignItemWithTrigger={alignItemWithTrigger}
				className="isolate z-50"
			>
				<SelectPrimitive.Popup
					data-slot="select-content"
					data-align-trigger={alignItemWithTrigger}
					className={cn(
						'relative isolate z-50 max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-x-hidden overflow-y-auto rounded-lg bg-popover text-popover-foreground shadow-md ring-1 ring-border duration-100 data-[align-trigger=true]:animate-none data-[side=bottom]:slide-in-from-top-2 data-[side=inline-end]:slide-in-from-left-2 data-[side=inline-start]:slide-in-from-right-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 data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:fade-out-0 data-closed:zoom-out-95',
						className,
					)}
					{...props}
				>
					<SelectScrollUpButton />
					<SelectPrimitive.List>{children}</SelectPrimitive.List>
					<SelectScrollDownButton />
				</SelectPrimitive.Popup>
			</SelectPrimitive.Positioner>
		</SelectPrimitive.Portal>
	)
}

function SelectLabel({ className, ...props }: SelectPrimitive.GroupLabel.Props) {
	return (
		<SelectPrimitive.GroupLabel data-slot="select-label" className={cn('px-1.5 py-1 text-xs', className)} {...props} />
	)
}

function SelectItem({ className, children, ...props }: SelectPrimitive.Item.Props) {
	return (
		<SelectPrimitive.Item
			data-slot="select-item"
			className={cn(
				"relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 pr-8 pl-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground 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}
		>
			<SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
				{children}
			</SelectPrimitive.ItemText>
			<SelectPrimitive.ItemIndicator
				render={<span className="pointer-events-none absolute right-2 flex size-4 items-center justify-center" />}
			>
				<CheckIcon className="pointer-events-none" />
			</SelectPrimitive.ItemIndicator>
		</SelectPrimitive.Item>
	)
}

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

function SelectScrollUpButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
	return (
		<SelectPrimitive.ScrollUpArrow
			data-slot="select-scroll-up-button"
			className={cn(
				"top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		>
			<ChevronUpIcon aria-hidden="true" />
		</SelectPrimitive.ScrollUpArrow>
	)
}

function SelectScrollDownButton({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
	return (
		<SelectPrimitive.ScrollDownArrow
			data-slot="select-scroll-down-button"
			className={cn(
				"bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
				className,
			)}
			{...props}
		>
			<ChevronDownIcon aria-hidden="true" />
		</SelectPrimitive.ScrollDownArrow>
	)
}

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

A select component built on top of Base UI Select, providing an accessible dropdown menu for selecting options.

Features

  • Built on Base UI Select primitive
  • Custom styling with Tailwind CSS
  • Accessible by default
  • Keyboard navigation
  • Screen reader support
  • Grouped options
  • Custom triggers
  • Disabled states
  • Controlled and uncontrolled modes
  • Placeholder support
  • Scrollable content with scroll buttons
  • Two trigger sizes: default and sm
  • Invalid state via aria-invalid on the trigger
  • Item labels shown in the trigger through the items prop (icons included)
  • Read-only state that keeps the list closed
  • Check indicator on the right of the selected item

Usage

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

const caseTypes = {
  'building-permit': 'Building permit',
  'parking-permit': 'Parking permit',
  'school-placement': 'School placement',
}

// Basic usage
export function BasicSelect() {
  return (
    <Select items={caseTypes}>
      <SelectTrigger className="w-[220px]" aria-label="Case type">
        <SelectValue placeholder="Select case type" />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectLabel>Case types</SelectLabel>
          {Object.entries(caseTypes).map(([value, label]) => (
            <SelectItem key={value} value={value}>
              {label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  )
}

// Controlled usage
export function ControlledSelect() {
  const [value, setValue] = React.useState<string | null>('en')

  return (
    <Select
      value={value}
      onValueChange={(next) => setValue(next)}
      items={{ en: 'English', sv: 'Swedish', fi: 'Finnish' }}
    >
      <SelectTrigger size="sm" className="w-[180px]" aria-label="Language">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectItem value="en">English</SelectItem>
        <SelectItem value="sv">Swedish</SelectItem>
        <SelectItem value="fi">Finnish</SelectItem>
      </SelectContent>
    </Select>
  )
}

The trigger is w-fit by default; give it a width (e.g. className="w-full" or w-[180px]) to size it.

SelectValue shows the raw value unless the Select knows the item labels. Pass them with the items prop (an object of value: label, an array of { value, label } or an array of groups), or format the value yourself with a function child: <SelectValue>{(value) => labels[value]}</SelectValue>.

onValueChange receives (value, eventDetails), and value can be null.

Examples

Default

A case type picker with a group label. Give a select without a visible label an aria-label.

With Label

Connected to a FieldLabel through the trigger id, with a description.

Sizes

SelectTrigger supports size="sm" and size="default".

With Groups

Committees in option groups with a separator.

With Icons

Case statuses with icons. Icons inside SelectItem are sized automatically. Give the items labels the same icons to show them in the trigger.

Scrollable

A long list of Nordic municipalities scrolls with buttons at the top and bottom.

Popper Position

By default the content is positioned over the trigger with the selected item aligned (alignItemWithTrigger). Set alignItemWithTrigger={false} on SelectContent to open it below the trigger like a dropdown.

Disabled States

Booked appointment times are disabled items; the meeting room select is disabled until a time is chosen.

Read Only

Pass readOnly on Select (or on SelectTrigger) to show the value without opening the list. The list stays closed for pointer, keyboard and label clicks, and the value can't change. Unlike disabled, the trigger stays focusable and is announced as read-only (aria-readonly).

Invalid

Set aria-invalid on the trigger to show the error style, and link the error with aria-describedby.

Controlled

A controlled language select whose value updates the description.

In a Form

Two selects side by side in a problem report form.

Props

Select

  • value - The controlled value of the select
  • defaultValue - The default value when uncontrolled
  • onValueChange - (value, eventDetails) => void, called when the value changes
  • items - Item labels, so SelectValue can show the label of the selected item
  • multiple - Allow selecting several values
  • disabled - Whether the select is disabled
  • readOnly - Show the value but keep the list closed and block changes
  • required - Whether a value is required
  • name - The name of the select when used in a form

SelectTrigger

  • size - 'default' | 'sm' (default 'default')
  • readOnly - Same as readOnly on Select
  • aria-invalid - Shows the destructive error style
  • className - Additional CSS classes (the trigger is w-fit by default)
  • children - The trigger content
  • render - Replace the rendered element

SelectValue

  • placeholder - Text to show when no value is selected
  • children - A function (value) => ReactNode to format the selected value
  • render - Replace the rendered element

SelectContent

  • alignItemWithTrigger - Position the list over the trigger with the selected item aligned (default true)
  • side - The preferred side to render on
  • sideOffset - The distance from the trigger
  • align - The alignment against the trigger (default 'center')
  • alignOffset - The distance from the aligned edge
  • className - Additional CSS classes

SelectItem

  • value - The value of the item
  • disabled - Whether the item is disabled
  • className - Additional CSS classes
  • children - The item content

Accessibility

The Select component follows WAI-ARIA guidelines:

  • Uses proper ARIA roles (listbox, option)
  • Manages focus within dropdown
  • Supports keyboard navigation:
    • Space/Enter: Open/close select, choose option
    • Up/Down: Navigate options
    • Home/End: Jump to first/last option
    • Esc: Close select
  • Announces selected values
  • Provides clear visual feedback
  • Maintains focus management

Best Practices

  1. Options:

    • Use clear, concise labels
    • Group related options
    • Order logically
    • Consider default values
  2. Visual Design:

    • Maintain sufficient spacing
    • Use clear visual hierarchy
    • Provide clear feedback
    • Consider mobile viewports
  3. Interaction:

    • Handle keyboard input
    • Manage focus states
    • Consider touch targets
    • Support type-to-select
  4. Accessibility:

    • Write descriptive labels
    • Test with screen readers
    • Verify keyboard navigation
    • Maintain ARIA attributes
  5. Implementation:

    • Manage form integration
    • Consider validation
    • Test edge cases
  • Native Select
  • Multi Select
  • Dropdown Menu
  • Autocomplete