Select
Pick from lists. Custom styling. Simplify complex choices.
UpdatedYour database is created in the same region.
1'use client';23import { Label } from '@/components/ui/label';4import { Select } from '@/components/ui/select';56const regions = [7 { value: 'us-east-1', label: 'US East, Virginia' },8 { value: 'us-west-2', label: 'US West, Oregon' },9 { value: 'eu-central-1', label: 'Europe, Frankfurt' },10 { value: 'sa-east-1', label: 'South America, Sao Paulo' },11 { value: 'ap-southeast-1', label: 'Asia Pacific, Singapore' },12];1314export function Default() {15 return (16 <div className="flex w-full max-w-sm flex-col gap-1.5">17 <Label htmlFor="deploy-region" description="Your database is created in the same region.">18 Deployment region19 </Label>20 <Select defaultValue="eu-central-1">21 <Select.Trigger id="deploy-region" placeholder="Pick a region" />22 <Select.Content>23 {regions.map((region) => (24 <Select.Item key={region.value} value={region.value}>25 {region.label}26 </Select.Item>27 ))}28 </Select.Content>29 </Select>30 </div>31 );32}
Installation
Copy and paste the following code into your project.
1'use client';23import { cva, type VariantProps } from 'class-variance-authority';4import { motion, useReducedMotion } from 'motion/react';5import * as React from 'react';6import { cn } from '../lib/cn';7import { FloatingPortal, useFloating } from '../lib/floating';8import { floatingOrigin, springs, still } from '../lib/motion';9import { useLayer } from '../lib/overlay';10import { useControllableState } from '../lib/use-controllable-state';1112type IconProps = React.SVGProps<SVGSVGElement> & {13 size?: number | string;14};1516function CheckIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {17 return (18 <svg19 xmlns="http://www.w3.org/2000/svg"20 width={size}21 height={size}22 viewBox="0 0 24 24"23 fill="none"24 stroke="currentColor"25 strokeWidth={strokeWidth}26 strokeLinecap="round"27 strokeLinejoin="round"28 aria-hidden="true"29 {...props}30 >31 <path d="m4 12.5 5 5L20 6.5" />32 </svg>33 );34}3536function ChevronDownIcon({ size = 24, strokeWidth = 1.5, ...props }: IconProps) {37 return (38 <svg39 xmlns="http://www.w3.org/2000/svg"40 width={size}41 height={size}42 viewBox="0 0 24 24"43 fill="none"44 stroke="currentColor"45 strokeWidth={strokeWidth}46 strokeLinecap="round"47 strokeLinejoin="round"48 aria-hidden="true"49 {...props}50 >51 <path d="m6 9 6 6 6-6" />52 </svg>53 );54}5556interface SelectContextValue {57 value: string;58 selectValue: (value: string) => void;59 isOpen: boolean;60 openMenu: () => void;61 closeMenu: (options?: { restoreFocus?: boolean }) => void;62 toggleMenu: () => void;63 disabled: boolean;64 labels: ReadonlyMap<string, string>;65 registerItem: (value: string, label: string) => () => void;66 triggerRef: React.RefObject<HTMLButtonElement | null>;67 listRef: React.RefObject<HTMLDivElement | null>;68 triggerId: string;69 contentId: string;70 layer: ReturnType<typeof useLayer>;71}7273interface SelectGroupContextValue {74 labelId: string;75 registerLabel: () => () => void;76}7778interface SelectProps {79 children: React.ReactNode;80 value?: string;81 defaultValue?: string;82 onValueChange?: (value: string) => void;83 open?: boolean;84 defaultOpen?: boolean;85 onOpenChange?: (open: boolean) => void;86 disabled?: boolean;87 name?: string;88 required?: boolean;89 className?: string;90}9192interface SelectTriggerProps93 extends94 Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'>,95 VariantProps<typeof selectTriggerVariants> {96 placeholder?: string;97 children?: React.ReactNode;98}99100interface SelectContentProps {101 children: React.ReactNode;102 className?: string;103 sideOffset?: number;104}105106interface SelectItemProps {107 value: string;108 children: React.ReactNode;109 textValue?: string;110 disabled?: boolean;111 className?: string;112}113114interface SelectGroupProps {115 children: React.ReactNode;116 className?: string;117}118119interface SelectLabelProps {120 children: React.ReactNode;121 className?: string;122}123124interface SelectSeparatorProps {125 className?: string;126}127128const selectTriggerVariants = cva(129 [130 'border-input bg-transparent text-foreground flex w-full items-center justify-between gap-2 rounded-md border text-left transition-[color,box-shadow] outline-none',131 'dark:bg-input/30 hover:border-border-interactive',132 'focus-visible:border-foreground/40 focus-visible:ring-foreground/10 focus-visible:ring-[1px]',133 'aria-invalid:border-destructive aria-invalid:ring-destructive/15 dark:aria-invalid:ring-destructive/30',134 'disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-40',135 'data-[placeholder=true]:text-muted-foreground',136 ],137 {138 variants: {139 size: {140 sm: 'h-8 px-2.5 text-xs',141 default: 'h-9 px-3 text-sm',142 lg: 'h-10 px-4 text-base',143 },144 },145 defaultVariants: {146 size: 'default',147 },148 },149);150151const ICON_VARIANTS = {152 open: { rotate: 180 },153 closed: { rotate: 0 },154} as const;155156const ICON_TRANSITION = springs.snappy;157const TYPEAHEAD_RESET_MS = 500;158const ICON_STYLE = { willChange: 'transform' } as const;159const CONTENT_STYLE = { willChange: 'opacity, transform' } as const;160161const CONTENT_OPEN = {162 opacity: 1,163 scale: 1,164 y: 0,165 visibility: 'visible' as const,166 transition: { ...springs.smooth, opacity: { duration: 0.15 } },167};168169const CONTENT_CLOSED = {170 bottom: {171 opacity: 0,172 scale: 0.97,173 y: -4,174 transition: { duration: 0.15 },175 transitionEnd: { visibility: 'hidden' as const },176 },177 top: {178 opacity: 0,179 scale: 0.97,180 y: 4,181 transition: { duration: 0.15 },182 transitionEnd: { visibility: 'hidden' as const },183 },184} as const;185186const SelectContext = React.createContext<SelectContextValue | null>(null);187188const SelectGroupContext = React.createContext<SelectGroupContextValue | null>(null);189190const useSelectContext = (): SelectContextValue => {191 const context = React.use(SelectContext);192 if (!context) throw new Error('Select components must be used within Select');193 return context;194};195196const noop = () => undefined;197198function useClickOutside(199 ref: React.RefObject<HTMLElement | null>,200 triggerRef: React.RefObject<HTMLElement | null>,201 handler: () => void,202 enabled: boolean,203 isInsideUpperLayer: (target: EventTarget | null) => boolean,204) {205 React.useEffect(() => {206 if (!enabled) return;207 const handlePointerDownOutside = (event: PointerEvent) => {208 if (209 ref.current?.contains(event.target as Node) ||210 triggerRef.current?.contains(event.target as Node) ||211 isInsideUpperLayer(event.target)212 ) {213 return;214 }215 handler();216 };217 document.addEventListener('pointerdown', handlePointerDownOutside);218 return () => document.removeEventListener('pointerdown', handlePointerDownOutside);219 }, [ref, triggerRef, handler, enabled, isInsideUpperLayer]);220}221222function useTypeahead() {223 const bufferRef = React.useRef('');224 const timerRef = React.useRef<ReturnType<typeof setTimeout> | undefined>(undefined);225226 React.useEffect(() => () => clearTimeout(timerRef.current), []);227228 const isTyping = React.useCallback(() => bufferRef.current !== '', []);229230 const search = React.useCallback(231 (232 key: string,233 options: HTMLElement[],234 current: number,235 labelOf: (option: HTMLElement) => string,236 ) => {237 clearTimeout(timerRef.current);238 timerRef.current = setTimeout(() => {239 bufferRef.current = '';240 }, TYPEAHEAD_RESET_MS);241 const buffer = bufferRef.current + key.toLowerCase();242 bufferRef.current = buffer;243 const repeated = Array.from(buffer).every((char) => char === buffer[0]);244 const query = repeated ? buffer.charAt(0) : buffer;245 const start = repeated ? current + 1 : Math.max(current, 0);246 const ordered = [...options.slice(start), ...options.slice(0, start)];247 return ordered.find((option) => labelOf(option).toLowerCase().startsWith(query));248 },249 [],250 );251252 return React.useMemo(() => ({ search, isTyping }), [search, isTyping]);253}254255function getOptions(container: HTMLElement | null): HTMLElement[] {256 if (!container) return [];257 return Array.from(258 container.querySelectorAll<HTMLElement>('[role="option"]:not([aria-disabled="true"])'),259 );260}261262function focusOption(container: HTMLElement | null, value: string) {263 const options = getOptions(container);264 const selected = options.find((option) => option.dataset.value === value);265 (selected ?? options[0])?.focus();266}267268const SelectRoot = ({269 children,270 value: controlledValue,271 defaultValue = '',272 onValueChange,273 open: controlledOpen,274 defaultOpen = false,275 onOpenChange,276 disabled = false,277 name,278 required,279 className,280}: SelectProps): React.JSX.Element => {281 const [value, setValue] = useControllableState({282 value: controlledValue,283 defaultValue,284 onChange: onValueChange,285 });286 const [isOpen, setOpen] = useControllableState({287 value: controlledOpen,288 defaultValue: defaultOpen,289 onChange: onOpenChange,290 });291 const [labels, setLabels] = React.useState<ReadonlyMap<string, string>>(() => new Map());292 const triggerRef = React.useRef<HTMLButtonElement>(null);293 const listRef = React.useRef<HTMLDivElement>(null);294 const layer = useLayer(isOpen, false, listRef);295296 const openMenu = React.useCallback(() => {297 if (!disabled) setOpen(true);298 }, [disabled, setOpen]);299300 const closeMenu = React.useCallback(301 (options?: { restoreFocus?: boolean }) => {302 setOpen(false);303 if (options?.restoreFocus !== false) triggerRef.current?.focus();304 },305 [setOpen],306 );307308 const toggleMenu = React.useCallback(() => {309 if (disabled) return;310 setOpen(!isOpen);311 }, [disabled, isOpen, setOpen]);312313 const selectValue = React.useCallback(314 (next: string) => {315 setValue(next);316 closeMenu();317 },318 [setValue, closeMenu],319 );320321 const registerItem = React.useCallback((itemValue: string, label: string) => {322 setLabels((prev) => {323 if (prev.get(itemValue) === label) return prev;324 const next = new Map(prev);325 next.set(itemValue, label);326 return next;327 });328 return () => {329 setLabels((prev) => {330 if (!prev.has(itemValue)) return prev;331 const next = new Map(prev);332 next.delete(itemValue);333 return next;334 });335 };336 }, []);337338 const id = React.useId();339 const triggerId = `select-trigger-${id}`;340 const contentId = `select-content-${id}`;341342 const contextValue = React.useMemo(343 () => ({344 value,345 selectValue,346 isOpen,347 openMenu,348 closeMenu,349 toggleMenu,350 disabled,351 labels,352 registerItem,353 triggerRef,354 listRef,355 triggerId,356 contentId,357 layer,358 }),359 [360 value,361 selectValue,362 isOpen,363 openMenu,364 closeMenu,365 toggleMenu,366 disabled,367 labels,368 registerItem,369 triggerId,370 contentId,371 layer,372 ],373 );374375 return (376 <SelectContext value={contextValue}>377 <div className={cn('relative w-full', className)}>378 {children}379 {(name || required) && (380 <input381 aria-hidden="true"382 tabIndex={-1}383 name={name}384 value={value}385 required={required}386 disabled={disabled}387 onChange={noop}388 onFocus={() => triggerRef.current?.focus()}389 className="pointer-events-none absolute bottom-0 left-0 h-px w-full opacity-0"390 />391 )}392 </div>393 </SelectContext>394 );395};396397const SelectTrigger = ({398 className,399 size,400 placeholder,401 children,402 onClick,403 onKeyDown,404 disabled: disabledProp,405 ref,406 ...props407}: SelectTriggerProps & { ref?: React.Ref<HTMLButtonElement> }): React.JSX.Element => {408 const {409 value,410 labels,411 isOpen,412 toggleMenu,413 openMenu,414 disabled,415 triggerRef,416 triggerId,417 contentId,418 listRef,419 closeMenu,420 layer,421 } = useSelectContext();422423 React.useImperativeHandle(ref, () => triggerRef.current as HTMLButtonElement);424425 const label = value ? labels.get(value) : undefined;426 const showPlaceholder = !label;427428 const handleClick = React.useCallback(429 (e: React.MouseEvent<HTMLButtonElement>) => {430 onClick?.(e);431 if (e.defaultPrevented) return;432 const opening = !isOpen;433 toggleMenu();434 if (opening) requestAnimationFrame(() => focusOption(listRef.current, value));435 },436 [onClick, toggleMenu, isOpen, listRef, value],437 );438439 const handleKeyDown = React.useCallback(440 (e: React.KeyboardEvent<HTMLButtonElement>) => {441 onKeyDown?.(e);442 if (e.defaultPrevented) return;443 if (isOpen && e.key === 'Escape') {444 if (!layer.isTopmost()) return;445 e.preventDefault();446 closeMenu();447 return;448 }449 if (isOpen && e.key === 'Tab') {450 closeMenu({ restoreFocus: false });451 return;452 }453 if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {454 e.preventDefault();455 openMenu();456 requestAnimationFrame(() => focusOption(listRef.current, value));457 }458 },459 [onKeyDown, openMenu, closeMenu, isOpen, layer, listRef, value],460 );461462 return (463 <button464 ref={triggerRef}465 type="button"466 role="combobox"467 id={triggerId}468 aria-expanded={isOpen}469 aria-haspopup="listbox"470 aria-controls={isOpen ? contentId : undefined}471 data-placeholder={showPlaceholder}472 data-state={isOpen ? 'open' : 'closed'}473 disabled={disabled || disabledProp}474 onClick={handleClick}475 onKeyDown={handleKeyDown}476 className={cn(selectTriggerVariants({ size }), className)}477 {...props}478 >479 <span className="truncate">{children ?? label ?? placeholder ?? 'ย '}</span>480 <motion.span481 aria-hidden="true"482 variants={ICON_VARIANTS}483 animate={isOpen ? 'open' : 'closed'}484 transition={ICON_TRANSITION}485 style={ICON_STYLE}486 className="text-muted-foreground flex shrink-0"487 >488 <ChevronDownIcon size={16} className="size-4" />489 </motion.span>490 </button>491 );492};493494type ListboxName = { 'aria-labelledby': string } | { 'aria-label': string };495496function listboxNameFor(trigger: HTMLButtonElement | null, fallbackId: string): ListboxName {497 if (!trigger) return { 'aria-labelledby': fallbackId };498 const labelledBy = trigger.getAttribute('aria-labelledby');499 if (labelledBy) return { 'aria-labelledby': labelledBy };500 const label = trigger.getAttribute('aria-label') ?? trigger.labels?.[0]?.textContent?.trim();501 if (label) return { 'aria-label': label };502 return { 'aria-labelledby': trigger.id || fallbackId };503}504505const SelectContent = ({506 children,507 className,508 sideOffset = 4,509}: SelectContentProps): React.JSX.Element => {510 const {511 isOpen,512 closeMenu,513 selectValue,514 contentId,515 triggerId,516 triggerRef,517 listRef,518 labels,519 layer,520 } = useSelectContext();521 const shouldReduceMotion = useReducedMotion();522 const typeahead = useTypeahead();523 const [triggerWidth, setTriggerWidth] = React.useState<number>();524 const [name, setName] = React.useState<ListboxName>({ 'aria-labelledby': triggerId });525 const { style: floatingStyle, side } = useFloating({526 open: isOpen,527 anchorRef: triggerRef,528 floatingRef: listRef,529 side: 'bottom',530 align: 'start',531 sideOffset,532 });533 const position = side === 'top' ? 'top' : 'bottom';534535 const closeWithoutFocus = React.useCallback(536 () => closeMenu({ restoreFocus: false }),537 [closeMenu],538 );539540 useClickOutside(listRef, triggerRef, closeWithoutFocus, isOpen, layer.isInsideUpperLayer);541542 React.useLayoutEffect(() => {543 const trigger = triggerRef.current;544 setName(listboxNameFor(trigger, triggerId));545 if (!isOpen || !trigger) return;546 const measure = () => setTriggerWidth(trigger.offsetWidth || undefined);547 measure();548 const observer = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(measure);549 observer?.observe(trigger);550 return () => observer?.disconnect();551 }, [isOpen, triggerRef, triggerId]);552553 const handleKeyDown = React.useCallback(554 (e: React.KeyboardEvent<HTMLDivElement>) => {555 const options = getOptions(listRef.current);556 const currentIndex = options.findIndex((option) => option === document.activeElement);557558 if (559 e.key.length === 1 &&560 !e.altKey &&561 !e.ctrlKey &&562 !e.metaKey &&563 (e.key !== ' ' || typeahead.isTyping())564 ) {565 e.preventDefault();566 typeahead567 .search(568 e.key,569 options,570 currentIndex,571 (option) => labels.get(option.dataset.value ?? '') ?? option.textContent?.trim() ?? '',572 )573 ?.focus();574 return;575 }576577 switch (e.key) {578 case 'Escape':579 if (!layer.isTopmost()) return;580 e.preventDefault();581 closeMenu();582 return;583 case 'Tab':584 closeMenu();585 return;586 case 'ArrowDown':587 e.preventDefault();588 options[currentIndex < options.length - 1 ? currentIndex + 1 : 0]?.focus();589 return;590 case 'ArrowUp':591 e.preventDefault();592 options[currentIndex > 0 ? currentIndex - 1 : options.length - 1]?.focus();593 return;594 case 'Home':595 e.preventDefault();596 options[0]?.focus();597 return;598 case 'End':599 e.preventDefault();600 options[options.length - 1]?.focus();601 return;602 case 'Enter':603 case ' ': {604 e.preventDefault();605 const active = options[currentIndex];606 if (active?.dataset.value !== undefined) selectValue(active.dataset.value);607 return;608 }609 }610 },611 [listRef, closeMenu, selectValue, labels, layer, typeahead],612 );613614 const handleClick = React.useCallback(615 (e: React.MouseEvent<HTMLDivElement>) => {616 const option = (e.target as Element).closest<HTMLElement>('[role="option"]');617 if (!option || option.getAttribute('aria-disabled') === 'true') return;618 if (option.dataset.value !== undefined) selectValue(option.dataset.value);619 },620 [selectValue],621 );622623 const closedState = shouldReduceMotion624 ? { ...CONTENT_CLOSED[position], scale: 1, y: 0, transition: still }625 : CONTENT_CLOSED[position];626627 const style = React.useMemo(628 () => ({ ...floatingStyle, visibility: undefined, width: triggerWidth, ...CONTENT_STYLE }),629 [floatingStyle, triggerWidth],630 );631632 return (633 <FloatingPortal>634 <motion.div635 ref={listRef}636 id={contentId}637 role="listbox"638 {...name}639 aria-hidden={!isOpen}640 tabIndex={-1}641 onKeyDown={handleKeyDown}642 onClick={handleClick}643 initial={false}644 animate={645 isOpen646 ? shouldReduceMotion647 ? { ...CONTENT_OPEN, transition: still }648 : CONTENT_OPEN649 : closedState650 }651 style={style}652 className={cn(653 'bg-popover text-popover-foreground border-border z-500 max-h-[min(18rem,var(--available-height,18rem))] overflow-y-auto overscroll-contain rounded-md border p-1 shadow-md outline-none',654 floatingOrigin[position],655 !isOpen && 'pointer-events-none',656 className,657 )}658 inert={!isOpen}659 >660 {children}661 </motion.div>662 </FloatingPortal>663 );664};665666const SelectItem = ({667 value,668 children,669 textValue,670 disabled = false,671 className,672}: SelectItemProps): React.JSX.Element => {673 const { value: selectedValue, registerItem } = useSelectContext();674 const itemRef = React.useRef<HTMLDivElement>(null);675 const isSelected = selectedValue === value;676677 const staticLabel = textValue ?? (typeof children === 'string' ? children : undefined);678679 React.useLayoutEffect(() => {680 const label = staticLabel ?? itemRef.current?.textContent ?? '';681 return registerItem(value, label.trim());682 }, [value, staticLabel, registerItem]);683684 return (685 <div686 ref={itemRef}687 role="option"688 aria-selected={isSelected}689 aria-disabled={disabled || undefined}690 data-value={value}691 data-selected={isSelected}692 tabIndex={-1}693 className={cn(694 'relative flex cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none',695 'transition-colors duration-150',696 'hover:bg-muted focus-visible:bg-muted focus:bg-muted',697 disabled && 'pointer-events-none opacity-40',698 className,699 )}700 >701 <span className="flex-1 truncate">{children}</span>702 {isSelected && (703 <span className="absolute right-2 flex items-center" aria-hidden="true">704 <CheckIcon size={16} className="size-4" />705 </span>706 )}707 </div>708 );709};710711const SelectGroup = ({ children, className }: SelectGroupProps): React.JSX.Element => {712 const labelId = React.useId();713 const [labelCount, setLabelCount] = React.useState(0);714715 const registerLabel = React.useCallback(() => {716 setLabelCount((count) => count + 1);717 return () => setLabelCount((count) => count - 1);718 }, []);719720 const contextValue = React.useMemo(() => ({ labelId, registerLabel }), [labelId, registerLabel]);721722 return (723 <SelectGroupContext value={contextValue}>724 <div725 role="group"726 aria-labelledby={labelCount > 0 ? labelId : undefined}727 className={cn('flex flex-col', className)}728 >729 {children}730 </div>731 </SelectGroupContext>732 );733};734735const SelectLabel = ({ children, className }: SelectLabelProps): React.JSX.Element => {736 const group = React.use(SelectGroupContext);737 const registerLabel = group?.registerLabel;738739 React.useLayoutEffect(() => registerLabel?.(), [registerLabel]);740741 return (742 <div743 id={group?.labelId}744 role="presentation"745 className={cn(746 'text-muted-foreground px-2 py-1.5 text-xs font-semibold tracking-wider uppercase',747 className,748 )}749 >750 {children}751 </div>752 );753};754755const SelectSeparator = ({ className }: SelectSeparatorProps): React.JSX.Element => (756 <div757 role="separator"758 aria-orientation="horizontal"759 className={cn('bg-border/50 my-1 h-px', className)}760 />761);762763const Select = Object.assign(SelectRoot, {764 Trigger: SelectTrigger,765 Content: SelectContent,766 Item: SelectItem,767 Group: SelectGroup,768 Label: SelectLabel,769 Separator: SelectSeparator,770});771772export { Select, selectTriggerVariants };773export type {774 SelectContentProps,775 SelectGroupProps,776 SelectItemProps,777 SelectLabelProps,778 SelectProps,779 SelectSeparatorProps,780 SelectTriggerProps,781};
Update the import paths to match your project structure.
Anatomy
1import { Select } from '@/components/ui/select';
1<Select defaultValue="one">2 <Select.Trigger placeholder="Pick an option" />3 <Select.Content>4 <Select.Item value="one">Option 1</Select.Item>5 <Select.Item value="two">Option 2</Select.Item>6 </Select.Content>7</Select>
Composition
Use the following composition to build a
Select:Selectโโโ Select.Triggerโโโ Select.Contentโโโ Select.Groupโ โโโ Select.Labelโ โโโ Select.Itemโโโ Select.Separator
The trigger shows the label of the selected item on its own. Pass children to the trigger only when you want to render something else in its place.
Variants
Default
Your database is created in the same region.
1'use client';23import { Label } from '@/components/ui/label';4import { Select } from '@/components/ui/select';56const regions = [7 { value: 'us-east-1', label: 'US East, Virginia' },8 { value: 'us-west-2', label: 'US West, Oregon' },9 { value: 'eu-central-1', label: 'Europe, Frankfurt' },10 { value: 'sa-east-1', label: 'South America, Sao Paulo' },11 { value: 'ap-southeast-1', label: 'Asia Pacific, Singapore' },12];1314export function Default() {15 return (16 <div className="flex w-full max-w-sm flex-col gap-1.5">17 <Label htmlFor="deploy-region" description="Your database is created in the same region.">18 Deployment region19 </Label>20 <Select defaultValue="eu-central-1">21 <Select.Trigger id="deploy-region" placeholder="Pick a region" />22 <Select.Content>23 {regions.map((region) => (24 <Select.Item key={region.value} value={region.value}>25 {region.label}26 </Select.Item>27 ))}28 </Select.Content>29 </Select>30 </div>31 );32}
Grouped Items
Wrap related items in
Select.Group and give each group a Select.Label.1'use client';23import { Label } from '@/components/ui/label';4import { Select } from '@/components/ui/select';56const teams = [7 {8 label: 'Engineering',9 members: [10 { value: 'lucia', label: 'Lucia Mendez' },11 { value: 'marco', label: 'Marco Rivas' },12 { value: 'priya', label: 'Priya Nair' },13 ],14 },15 {16 label: 'Design',17 members: [18 { value: 'ines', label: 'Ines Duarte' },19 { value: 'tomas', label: 'Tomas Fuentes' },20 ],21 },22 {23 label: 'Support',24 members: [25 { value: 'noa', label: 'Noa Bergman' },26 { value: 'hugo', label: 'Hugo Salas' },27 ],28 },29];3031export function GroupedItems() {32 return (33 <div className="flex w-full max-w-sm flex-col gap-1.5">34 <Label htmlFor="assignee">Assign ticket to</Label>35 <Select>36 <Select.Trigger id="assignee" placeholder="Unassigned" />37 <Select.Content>38 {teams.map((team) => (39 <Select.Group key={team.label}>40 <Select.Label>{team.label}</Select.Label>41 {team.members.map((member) => (42 <Select.Item key={member.value} value={member.value}>43 {member.label}44 </Select.Item>45 ))}46 </Select.Group>47 ))}48 </Select.Content>49 </Select>50 </div>51 );52}
Keyboard
| Key | Action |
|---|---|
Enter, Space, Arrows | Open the list and focus the selected item |
ArrowUp, ArrowDown | Move between items |
Home, End | Jump to the first or last item |
| Typing | Focus the next item whose label starts with it |
Enter, Space | Select the focused item |
Escape | Close the list and return focus to the trigger |
Tab | Close the list and move on |
API Reference
Select
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | Controlled value |
defaultValue | string | '' | Initial value when uncontrolled |
onValueChange | (value: string) => void | - | Called when the selected value changes |
open | boolean | - | Controlled open state |
defaultOpen | boolean | false | Initial open state when uncontrolled |
onOpenChange | (open: boolean) => void | - | Called when the open state changes |
disabled | boolean | false | Disables the trigger |
name | string | - | Renders a hidden input so the value submits with forms |
required | boolean | - | Blocks form submission until a value is picked |
className | string | - | Additional CSS classes for the wrapper |
Select.Trigger
Accepts every
button attribute, so id and aria-label work as usual. Name the trigger with aria-label, aria-labelledby or a <label htmlFor> pointing at its id. The open list reuses that name, so screen readers announce what the list is for instead of the current value.| Prop | Type | Default | Description |
|---|---|---|---|
placeholder | string | - | Text shown when no item is selected |
size | 'sm' | 'default' | 'lg' | 'default' | Height and padding of the trigger |
children | ReactNode | - | Replaces the selected label with custom content |
className | string | - | Additional CSS classes |
Select.Content
| Prop | Type | Default | Description |
|---|---|---|---|
sideOffset | number | 4 | Gap between the trigger and the list |
className | string | - | Additional CSS classes |
Select.Item
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | - | Value reported when the item is selected |
textValue | string | - | Label shown in the trigger when children are not plain text |
disabled | boolean | false | Skips the item in keyboard navigation and blocks selection |
className | string | - | Additional CSS classes |
Select.Group, Select.Label, Select.Separator
| Prop | Type | Default | Description |
|---|---|---|---|
className | string | - | Additional CSS classes |
Found something to improve?
Notice a bug, typo, or missing detail on this page? Help us make the documentation better by opening a GitHub issue.