Installation
pnpm dlx shadcn@latest add https://rdf.dev.360online.com/r/select.jsonInstalls to @/components/ui/select.
Manual installation
Install the dependencies:
pnpm add @base-ui/react@^1.8.0 lucide-react@^1.48.0Copy the source into your project:
'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:
defaultandsm - Invalid state via
aria-invalidon the trigger - Item labels shown in the trigger through the
itemsprop (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 selectdefaultValue- The default value when uncontrolledonValueChange-(value, eventDetails) => void, called when the value changesitems- Item labels, soSelectValuecan show the label of the selected itemmultiple- Allow selecting several valuesdisabled- Whether the select is disabledreadOnly- Show the value but keep the list closed and block changesrequired- Whether a value is requiredname- The name of the select when used in a form
SelectTrigger
size-'default' | 'sm'(default'default')readOnly- Same asreadOnlyonSelectaria-invalid- Shows the destructive error styleclassName- Additional CSS classes (the trigger isw-fitby default)children- The trigger contentrender- Replace the rendered element
SelectValue
placeholder- Text to show when no value is selectedchildren- A function(value) => ReactNodeto format the selected valuerender- Replace the rendered element
SelectContent
alignItemWithTrigger- Position the list over the trigger with the selected item aligned (defaulttrue)side- The preferred side to render onsideOffset- The distance from the triggeralign- The alignment against the trigger (default'center')alignOffset- The distance from the aligned edgeclassName- Additional CSS classes
SelectItem
value- The value of the itemdisabled- Whether the item is disabledclassName- Additional CSS classeschildren- 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 optionUp/Down: Navigate optionsHome/End: Jump to first/last optionEsc: Close select
- Announces selected values
- Provides clear visual feedback
- Maintains focus management
Best Practices
-
Options:
- Use clear, concise labels
- Group related options
- Order logically
- Consider default values
-
Visual Design:
- Maintain sufficient spacing
- Use clear visual hierarchy
- Provide clear feedback
- Consider mobile viewports
-
Interaction:
- Handle keyboard input
- Manage focus states
- Consider touch targets
- Support type-to-select
-
Accessibility:
- Write descriptive labels
- Test with screen readers
- Verify keyboard navigation
- Maintain ARIA attributes
-
Implementation:
- Manage form integration
- Consider validation
- Test edge cases
Related Components
- Native Select
- Multi Select
- Dropdown Menu
- Autocomplete