- Accordion
- AppShell
- Autocomplete
- Badge
- Blobatar
- BoringAvatar
- Button
- Calendar
- Checkbox
- DataGrid
- Date Picker
- Date Range Picker
- DateTime
- Dialog
- Drawer
- Dropdown
- Empty
- Error State
- Frame
- Input
- Input Amount
- Input Group
- Input OTP
- Input Password
- Input Phone
- Input Search
- Input Secret
- Loading
- PageContainer
- PageHeader
- Popover
- Select
- StatsCard
- Switch
- Tabs
- TextArea
- Toast
- Tooltip
Playground
import { Select } from "@/components/ui/select";
const items = [
{
"label": "Apple",
"value": "apple"
},
{
"label": "Banana",
"value": "banana"
},
{
"label": "Blueberry",
"value": "blueberry"
},
{
"label": "Grapes",
"value": "grapes"
},
{
"label": "Pineapple",
"value": "pineapple"
}
];
export const SelectDemo = () => (
<Select
data={items}
label={"Fruit"}
labelStyle="floating"
className="w-56"
placeholder={"Select a fruit"}
/>
);Installation
Initialize shadcn in your React project, then install this component from the Vandor UI registry:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/select.json
Dependencies
Installed automatically by the registry CLI. For manual setup:
- @base-ui/react
- cn
- lucide-react
- motion
$ pnpm add @base-ui/react cn lucide-react motionUsage
Supply label="Fruit" to name the built-in trigger. Its default labelStyle="floating" places a transparent label in a real outline notch; labelStyle="static" places it above the field. Explicit trigger/root aria-label or aria-labelledby takes precedence. Unlabeled usage still needs an accessible name. A custom trigger owns its own label layout; these label props do not add markup around it. CLI installation includes field-outline.tsx in your configured UI alias; manual source copying must include this helper.
Use Select for a ready-to-use control. It renders the trigger, popup, options, and groups automatically.
import { Select } from "@/components/ui/select";
const data = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
];
export function Demo() {
return (
<Select
data={data}
aria-label="Fruit"
placeholder="Select a fruit"
className="w-56"
/>
);
}className sizes and styles the field wrapper when a visible label is provided; its built-in trigger fills that field. Without a label, or with a custom trigger, className applies directly to the trigger. aria-* props always apply to the trigger. size controls the built-in trigger's height. Use triggerProps for additional trigger attributes, event handlers, a ref, or trigger-specific className styling. Use contentProps for popup positioning and styling. Root props such as value, defaultValue, onValueChange, name, required, and multiple are forwarded to Base UI. data is the single source for option rendering and selected labels; do not also pass items or children.
Custom Trigger
Pass a trigger element for fixed content, or a callback to display the current selection. Select attaches Base UI's trigger behavior to the element without adding a wrapper button, default styling, press animation, or chevron.
import { Button } from "@/components/ui/button";
import { Select } from "@/components/ui/select";
<Select
data={data}
aria-label="Fruit"
placeholder="Select a fruit"
trigger={({ selectedLabel, placeholder, open }) => (
<Button variant="outline" aria-label="Fruit">
{selectedLabel ?? placeholder}
<span aria-hidden="true">{open ? "▴" : "▾"}</span>
</Button>
)}
/>;The callback receives value, selectedLabel, placeholder, open, and disabled. selectedLabel is null when no data option matches; multiple labels are joined with ", " in selected-value order. An empty-string label is preserved. Both controlled and uncontrolled selection are supported.
The callback must return a React element, not text or a fragment. Custom components must forward props and ref to a single interactive element. Prefer a native button; for non-button triggers, set triggerProps={{ nativeButton: false }} and provide appropriate styling. Keep custom trigger content non-interactive. triggerProps.render is not accepted: use trigger instead.
"use client";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody } from "@/components/ui/drawer";
import { Select } from "@/components/ui/select";
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
export const SelectDemo = () => (
<Select
data={items}
aria-label="Fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectInsideDrawerDemo = () => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<string | null>(null);
return (
<>
<Button onClick={() => setOpen(true)}>Choose fruit in drawer</Button>
<Drawer title="Choose fruit" open={open} onOpenChange={setOpen}>
<DrawerBody className="flex flex-col gap-3">
<Select
data={items}
value={value}
onValueChange={setValue}
aria-label="Drawer fruit"
placeholder="Select a fruit"
/>
<output>Selected: {value ?? "None"}</output>
</DrawerBody>
</Drawer>
</>
);
};
export const SelectGroupedDemo = () => (
<Select
data={[
{ label: "Apple", value: "apple" },
{
items: [
{ label: "Mango", value: "mango" },
{ disabled: true, label: "Banana", value: "banana" },
],
label: "Tropical",
},
]}
aria-label="Grouped fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectCustomTriggerDemo = () => (
<Select
data={items}
aria-label="Fruit"
placeholder="Select a fruit"
trigger={({ selectedLabel, placeholder, open }) => (
<Button variant="outline" className="w-56 max-w-full justify-between">
{selectedLabel ?? placeholder}
<ChevronDownIcon
aria-hidden="true"
className={open ? "rotate-180" : undefined}
/>
</Button>
)}
/>
);Advanced Composition
The existing primitives remain available when you need a custom trigger, value display, or popup layout.
import {
SelectRoot,
SelectContent,
SelectGroup,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
];
export function Demo() {
return (
<SelectRoot items={items}>
<SelectTrigger aria-label="Fruit" className="w-56">
<SelectValue placeholder="Select a fruit" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{items.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</SelectRoot>
);
}Use your project's configured UI alias if it differs from @/components/ui. The registry now installs in that configured UI directory rather than a fixed components/ui target. When updating older source with a custom alias, check for a copy at the old target and move customizations deliberately; the CLI does not remove it. Review overwrite prompts before replacing customized source. Pass items to map stored values to readable labels.
Examples
"use client";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody } from "@/components/ui/drawer";
import { Select } from "@/components/ui/select";
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
export const SelectDemo = () => (
<Select
data={items}
aria-label="Fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectInsideDrawerDemo = () => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<string | null>(null);
return (
<>
<Button onClick={() => setOpen(true)}>Choose fruit in drawer</Button>
<Drawer title="Choose fruit" open={open} onOpenChange={setOpen}>
<DrawerBody className="flex flex-col gap-3">
<Select
data={items}
value={value}
onValueChange={setValue}
aria-label="Drawer fruit"
placeholder="Select a fruit"
/>
<output>Selected: {value ?? "None"}</output>
</DrawerBody>
</Drawer>
</>
);
};
export const SelectGroupedDemo = () => (
<Select
data={[
{ label: "Apple", value: "apple" },
{
items: [
{ label: "Mango", value: "mango" },
{ disabled: true, label: "Banana", value: "banana" },
],
label: "Tropical",
},
]}
aria-label="Grouped fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectCustomTriggerDemo = () => (
<Select
data={items}
aria-label="Fruit"
placeholder="Select a fruit"
trigger={({ selectedLabel, placeholder, open }) => (
<Button variant="outline" className="w-56 max-w-full justify-between">
{selectedLabel ?? placeholder}
<ChevronDownIcon
aria-hidden="true"
className={open ? "rotate-180" : undefined}
/>
</Button>
)}
/>
);Inside a Drawer
Install Drawer as well as Select to use this example. The menu uses fixed positioning and the modal overlay layer so options remain interactive without moving the page behind the Drawer. Try pointer and keyboard selection while the documentation page is scrolled.
"use client";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody } from "@/components/ui/drawer";
import { Select } from "@/components/ui/select";
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
export const SelectDemo = () => (
<Select
data={items}
aria-label="Fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectInsideDrawerDemo = () => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<string | null>(null);
return (
<>
<Button onClick={() => setOpen(true)}>Choose fruit in drawer</Button>
<Drawer title="Choose fruit" open={open} onOpenChange={setOpen}>
<DrawerBody className="flex flex-col gap-3">
<Select
data={items}
value={value}
onValueChange={setValue}
aria-label="Drawer fruit"
placeholder="Select a fruit"
/>
<output>Selected: {value ?? "None"}</output>
</DrawerBody>
</Drawer>
</>
);
};
export const SelectGroupedDemo = () => (
<Select
data={[
{ label: "Apple", value: "apple" },
{
items: [
{ label: "Mango", value: "mango" },
{ disabled: true, label: "Banana", value: "banana" },
],
label: "Tropical",
},
]}
aria-label="Grouped fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectCustomTriggerDemo = () => (
<Select
data={items}
aria-label="Fruit"
placeholder="Select a fruit"
trigger={({ selectedLabel, placeholder, open }) => (
<Button variant="outline" className="w-56 max-w-full justify-between">
{selectedLabel ?? placeholder}
<ChevronDownIcon
aria-hidden="true"
className={open ? "rotate-180" : undefined}
/>
</Button>
)}
/>
);The optional select-stories item also includes an InsideDrawer fixture. Install Button and Drawer before using that stories file; stories-only installation deliberately does not install or overwrite those components.
Sizes and Disabled Options
Use size="sm" on Select for a compact built-in trigger. Set disabled on Select to disable the whole control, or on individual data options to disable them. For advanced composition, use SelectTrigger and SelectItem respectively.
Groups
Select accepts options and groups in the same array, preserving their order. A group uses label and items; an option uses label, value, and optionally disabled.
const data = [
{ label: "Apple", value: "apple" },
{
label: "Tropical",
items: [
{ label: "Mango", value: "mango" },
{ label: "Banana", value: "banana", disabled: true },
],
},
];
<Select data={data} aria-label="Fruit" placeholder="Select a fruit" />;"use client";
import { ChevronDownIcon } from "lucide-react";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Drawer, DrawerBody } from "@/components/ui/drawer";
import { Select } from "@/components/ui/select";
const items = [
{ label: "Apple", value: "apple" },
{ label: "Banana", value: "banana" },
{ label: "Blueberry", value: "blueberry" },
];
export const SelectDemo = () => (
<Select
data={items}
aria-label="Fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectInsideDrawerDemo = () => {
const [open, setOpen] = useState(false);
const [value, setValue] = useState<string | null>(null);
return (
<>
<Button onClick={() => setOpen(true)}>Choose fruit in drawer</Button>
<Drawer title="Choose fruit" open={open} onOpenChange={setOpen}>
<DrawerBody className="flex flex-col gap-3">
<Select
data={items}
value={value}
onValueChange={setValue}
aria-label="Drawer fruit"
placeholder="Select a fruit"
/>
<output>Selected: {value ?? "None"}</output>
</DrawerBody>
</Drawer>
</>
);
};
export const SelectGroupedDemo = () => (
<Select
data={[
{ label: "Apple", value: "apple" },
{
items: [
{ label: "Mango", value: "mango" },
{ disabled: true, label: "Banana", value: "banana" },
],
label: "Tropical",
},
]}
aria-label="Grouped fruit"
className="w-56 max-w-full"
placeholder="Select a fruit"
/>
);
export const SelectCustomTriggerDemo = () => (
<Select
data={items}
aria-label="Fruit"
placeholder="Select a fruit"
trigger={({ selectedLabel, placeholder, open }) => (
<Button variant="outline" className="w-56 max-w-full justify-between">
{selectedLabel ?? placeholder}
<ChevronDownIcon
aria-hidden="true"
className={open ? "rotate-180" : undefined}
/>
</Button>
)}
/>
);Groups support one level only. Use unique group labels and option values across the control. Labels are strings and values are strings or numbers. Readonly arrays are supported. An empty array renders no options and still displays the placeholder when no value is selected.
Place options inside SelectGroup. Use SelectLabel for a group heading and SelectSeparator between groups. Group headings do not label the control: give the trigger a visible label or aria-label.
Custom Option Content
Use renderItem to add icons or other non-interactive content. Additional option fields are inferred and available in the callback. The option's original label remains the built-in trigger text and keyboard typeahead label. Select still owns the option wrapper, selected indicator, groups, scrolling, portal, and popup; arbitrary popup children are not accepted.
<Select
data={data}
aria-label="Fruit"
renderItem={(item) => <span>{item.label}</span>}
contentProps={{ side: "top", align: "end" }}
/>Set size="sm", disabled, or animated={false} directly on Select. animated controls popup reveal and exit only. The built-in trigger retains its press feedback; custom triggers own their styling and motion. For searchable or very large lists, prefer a Combobox instead of extending Select with search.
Controlled Values and Forms
Use value and onValueChange on Select for controlled selection, or defaultValue for an initial uncontrolled value. Set name to include the value in form submission. Base UI's generic value and multiple-selection APIs are preserved.
Motion & Accessibility
The popup and portal wrapper stay out of normal flow, including when body uses flex/grid. Opening the menu does not add a layout item beside your app.
The trigger uses a subtle vertical gradient, a pointer cursor, and press feedback matching Button: it scales to 0.96 and darkens by 10% while pressed, with a 120ms ease-out transition. Disabled triggers show a not-allowed cursor and do not scale or darken. Reduced-motion preferences disable scaling and transitions while retaining instant color feedback. Options use a pointer cursor; disabled options show a not-allowed cursor and remain unselectable.
The option menu fades and scales from 0.96 to 1 over 180ms with an ease-out curve. Closing reverses the effect over 120ms, keeping the popup mounted until Motion finishes. Options remain immediately interactive, without staggered delays.
- Reduced-motion preferences disable scaling and animation duration.
- Set
animated={false}onSelectContentfor an instant reveal and close. - Menus open below the trigger by default. Set
alignItemWithTriggerto overlap the trigger and align the selected item. - Base UI preserves Arrow-key navigation, typeahead, Enter/Space selection, Escape dismissal, and focus restoration.
- Long lists scroll within the available viewport height, with scroll-arrow affordances.
- Give every control an accessible name using a visible label or
aria-labelonSelectTrigger.
Portable Storybook stories
Install Select first, then optionally install its colocated stories without overwriting the component:
$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/select-stories.json
The item installs only select.stories.tsx in your configured UI directory. It does not install Storybook packages or modify .storybook/. Stories are typechecked and composed with Storybook 10.6.1; use matching Storybook package versions in your consumer. Discover **/*.stories.tsx and import your Tailwind/shadcn global CSS and theme in the preview. No additional runtime dependency is required. Scenarios include sizes, disabled options, groups, multiple selection, invalid and controlled states, and disabled motion. Storybook UI was not verified because this repository has no Storybook server.
Migration
SelectInput is now Select, with the same data-driven usage. The previous compositional Select is now SelectRoot; its child primitives remain available. Update imports and JSX names accordingly. SelectInput is no longer exported.
Props
| Prop | Type / Description | Default |
|---|---|---|
| label | stringVisible field label for the built-in trigger. Custom triggers own their label layout. | undefined |
| labelStyle | "floating" | "static"Select label presentation. | "floating" |
| placeholder | ReactNodeSelect: text shown before choosing a value. | Not set |
| size | "default" | "sm"Select: default trigger height. | "default" |
| disabled | booleanSelect: prevents interaction with the control. | false |
| animated | booleanSelect: enables popup reveal and exit. Respects reduced motion. | true |
| alignItemWithTrigger | booleancontentProps.alignItemWithTrigger: overlaps the trigger to align the selected option. | false |
| value | Value | nullSelect: controlled selected value. | Not set |
| defaultValue | Value | nullSelect: initial uncontrolled value. | Not set |
| onValueChange | (value, details) => voidSelect: receives the selected value and Base UI event details. | Not set |
| data | readonly (SelectOption<Value> | SelectOptionGroup<Value>)[]Select: options and one-level groups rendered automatically. | Required |
| renderItem | (item: Option) => ReactNodeSelect: custom option content; label remains the default trigger and typeahead text. Retains option metadata. | Not set |
| trigger | ReactElement | ((context: SelectTriggerContext) => ReactElement)Select: custom trigger element or callback receiving value, selectedLabel, placeholder, open and disabled. Owns its visual content. | Built-in trigger |
| triggerProps | Omit<SelectTriggerProps, 'children' | 'size' | 'render'>Select: additional trigger props, including ref, event handlers, id and trigger-specific className styling. | Not set |
| contentProps | Omit<SelectContentProps, 'children' | 'animated'>Select: popup props and positioning options. | Not set |
| name | stringSelect: name included in native form submission. | Not set |
The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.
Source
"use client";
import { mergeProps } from "@base-ui/react/merge-props";
import { Select as SelectPrimitive } from "@base-ui/react/select";
import { useRender } from "@base-ui/react/use-render";
import { cn } from "cn";
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
import { motion, useReducedMotion } from "motion/react";
import { createContext, useContext, useId, useRef } from "react";
import type {
AriaAttributes,
ComponentProps,
ReactElement,
ReactNode,
} from "react";
import { FieldOutline } from "./field-outline";
const PopupElement = ({
nativeProps,
ref,
...props
}: ComponentProps<"div"> & { nativeProps: ComponentProps<"div"> }) =>
useRender({
props: { ...nativeProps },
ref: [nativeProps.ref ?? null, ref ?? null],
render: <div {...props} />,
});
const MotionPopupElement = motion.create(PopupElement);
const TriggerElement = ({
nativeProps,
state,
render,
ref,
...props
}: ComponentProps<"button"> & {
nativeProps: ComponentProps<"button">;
state: SelectPrimitive.Trigger.State;
render?: SelectPrimitive.Trigger.Props["render"];
}) =>
useRender({
defaultTagName: "button",
props: mergeProps({ ...nativeProps }, { ...props }),
ref: ref ?? null,
render,
state: { ...state },
});
const MotionTriggerElement = motion.create(TriggerElement);
const SelectActionsContext = createContext<(() => void) | null>(null);
const SelectRoot = <Value, Multiple extends boolean | undefined = false>({
actionsRef,
children,
...props
}: SelectPrimitive.Root.Props<Value, Multiple>) => {
const internalActions = useRef<SelectPrimitive.Root.Actions | null>(null);
const actions = actionsRef ?? internalActions;
return (
<SelectActionsContext.Provider value={() => actions.current?.unmount()}>
<SelectPrimitive.Root {...props} actionsRef={actions}>
{children}
</SelectPrimitive.Root>
</SelectActionsContext.Provider>
);
};
const SelectGroup = ({ className, ...props }: SelectPrimitive.Group.Props) => (
<SelectPrimitive.Group
data-slot="select-group"
className={cn("scroll-my-1 p-1", className)}
{...props}
/>
);
const SelectValue = ({ className, ...props }: SelectPrimitive.Value.Props) => (
<SelectPrimitive.Value
data-slot="select-value"
className={cn(
"flex min-w-0 flex-1 items-center gap-2 text-left data-placeholder:text-muted-foreground",
className
)}
{...props}
/>
);
const SelectTrigger = ({
className,
size = "default",
children,
render,
...props
}: SelectPrimitive.Trigger.Props & { size?: "sm" | "default" }) => {
const reduceMotion = useReducedMotion();
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"flex w-fit cursor-pointer items-center justify-between gap-2 rounded-md border border-input bg-background bg-linear-to-b from-white/10 to-black/10 px-3 py-2 text-sm whitespace-nowrap outline-none transition-[background-color,border-color,box-shadow] duration-200 ease-out select-none hover:not-disabled:not-data-disabled:not-aria-disabled:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 data-disabled:cursor-not-allowed aria-disabled:cursor-not-allowed aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[size=default]:h-9 data-[size=sm]:h-8 motion-reduce:transition-none [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
render={(triggerProps, state) => (
<MotionTriggerElement
nativeProps={triggerProps}
state={state}
render={render}
animate={{ filter: "brightness(1)", scale: 1 }}
whileTap={
state.disabled ||
state.readOnly ||
triggerProps["aria-disabled"] === true ||
triggerProps["aria-disabled"] === "true"
? undefined
: { filter: "brightness(0.9)", scale: reduceMotion ? 1 : 0.96 }
}
transition={{ duration: reduceMotion ? 0 : 0.12, ease: "easeOut" }}
>
{triggerProps.children}
</MotionTriggerElement>
)}
>
{children}
<SelectPrimitive.Icon className="text-muted-foreground transition-transform duration-200 ease-out data-popup-open:rotate-180 motion-reduce:transition-none">
<ChevronDownIcon aria-hidden="true" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
};
type SelectContentProps = Omit<SelectPrimitive.Popup.Props, "render"> &
Pick<
SelectPrimitive.Positioner.Props,
"align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
> & { animated?: boolean };
const SelectContent = ({
className,
children,
side = "bottom",
sideOffset = 4,
align = "start",
alignOffset = 0,
alignItemWithTrigger = false,
animated = true,
...props
}: SelectContentProps) => {
const reduceMotion = useReducedMotion();
const unmount = useContext(SelectActionsContext);
return (
// Explicit insets avoid an end-of-container static position while keeping
// the portal out of flex/grid flow.
<SelectPrimitive.Portal style={{ left: 0, position: "absolute", top: 0 }}>
<SelectPrimitive.Positioner
positionMethod="fixed"
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
alignItemWithTrigger={alignItemWithTrigger}
className="isolate z-50"
>
<SelectPrimitive.Popup
data-slot="select-content"
className={cn(
"relative max-h-(--available-height) min-w-[max(8rem,var(--anchor-width))] origin-(--transform-origin) overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md outline-none",
className
)}
{...props}
render={(popupProps, state) => (
<MotionPopupElement
nativeProps={popupProps}
initial={{
opacity: animated ? 0 : 1,
scale: animated && !reduceMotion ? 0.96 : 1,
}}
animate={{
opacity: state.open ? 1 : 0,
scale: animated && !reduceMotion && !state.open ? 0.96 : 1,
}}
transition={{
duration:
(!animated || reduceMotion ? 0 : 1) *
(state.open ? 0.18 : 0.12),
ease: [0.22, 1, 0.36, 1],
}}
onAnimationComplete={() => {
if (!state.open) {
unmount?.();
}
}}
>
{popupProps.children}
</MotionPopupElement>
)}
>
<SelectPrimitive.ScrollUpArrow className="top-0 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg]:size-4">
<ChevronUpIcon aria-hidden="true" />
</SelectPrimitive.ScrollUpArrow>
<SelectPrimitive.List className="max-h-[min(20rem,var(--available-height))] overflow-y-auto overscroll-contain">
{children}
</SelectPrimitive.List>
<SelectPrimitive.ScrollDownArrow className="bottom-0 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg]:size-4">
<ChevronDownIcon aria-hidden="true" />
</SelectPrimitive.ScrollDownArrow>
</SelectPrimitive.Popup>
</SelectPrimitive.Positioner>
</SelectPrimitive.Portal>
);
};
const SelectLabel = ({
className,
...props
}: SelectPrimitive.GroupLabel.Props) => (
<SelectPrimitive.GroupLabel
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)}
{...props}
/>
);
const SelectItem = ({
className,
children,
...props
}: SelectPrimitive.Item.Props) => (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-pointer items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-none select-none data-highlighted:bg-accent data-highlighted:text-accent-foreground data-disabled:cursor-not-allowed data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
>
<SelectPrimitive.ItemText className="flex min-w-0 flex-1 items-center gap-2">
{children}
</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator className="pointer-events-none absolute right-2 flex items-center justify-center">
<CheckIcon aria-hidden="true" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
const SelectSeparator = ({
className,
...props
}: SelectPrimitive.Separator.Props) => (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("pointer-events-none my-1 h-px bg-border", className)}
{...props}
/>
);
const SelectScrollUpButton = ({
className,
...props
}: SelectPrimitive.ScrollUpArrow.Props) => (
<SelectPrimitive.ScrollUpArrow
data-slot="select-scroll-up-button"
className={cn(
"top-0 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg]:size-4",
className
)}
{...props}
>
<ChevronUpIcon aria-hidden="true" />
</SelectPrimitive.ScrollUpArrow>
);
const SelectScrollDownButton = ({
className,
...props
}: SelectPrimitive.ScrollDownArrow.Props) => (
<SelectPrimitive.ScrollDownArrow
data-slot="select-scroll-down-button"
className={cn(
"bottom-0 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg]:size-4",
className
)}
{...props}
>
<ChevronDownIcon aria-hidden="true" />
</SelectPrimitive.ScrollDownArrow>
);
interface SelectOption<Value extends string | number = string> {
label: string;
value: Value;
disabled?: boolean;
}
interface SelectOptionGroup<
Value extends string | number = string,
Option extends SelectOption<Value> = SelectOption<Value>,
> {
label: string;
items: readonly Option[];
}
interface SelectTriggerContext<
Value extends string | number = string,
Multiple extends boolean | undefined = false,
> {
value: Multiple extends true ? Value[] : Value | null;
selectedLabel: string | null;
placeholder: ReactNode;
open: boolean;
disabled: boolean;
}
type SelectProps<
Value extends string | number = string,
Multiple extends boolean | undefined = false,
Option extends SelectOption<Value> = SelectOption<Value>,
> = Omit<
SelectPrimitive.Root.Props<NoInfer<Value>, Multiple>,
"children" | "items"
> &
AriaAttributes & {
data: readonly (Option | SelectOptionGroup<Value, Option>)[];
placeholder?: ReactNode;
size?: ComponentProps<typeof SelectTrigger>["size"];
className?: string;
animated?: boolean;
trigger?:
| ReactElement
| ((context: SelectTriggerContext<Value, Multiple>) => ReactElement);
triggerProps?: Omit<
ComponentProps<typeof SelectTrigger>,
"children" | "size" | "render"
>;
contentProps?: Omit<SelectContentProps, "children" | "animated">;
renderItem?: (item: Option) => ReactNode;
label?: string;
labelStyle?: "floating" | "static";
};
type SelectDataOption<Entry> = Entry extends {
items: readonly (infer Option)[];
}
? Option
: Entry;
const Select = <
const Data extends readonly (
| SelectOption<string | number>
| SelectOptionGroup<string | number>
)[],
Multiple extends boolean | undefined = false,
>({
data,
placeholder,
size,
className,
animated,
trigger,
triggerProps,
contentProps,
renderItem,
label,
labelStyle = "floating",
...props
}: Omit<
SelectProps<
SelectDataOption<Data[number]>["value"],
Multiple,
SelectDataOption<Data[number]>
>,
"data"
> & { data: Data }) => {
type Option = SelectDataOption<Data[number]>;
// Keep adjacent ungrouped options together, preserving mixed data order.
const groups: { label?: string; items: Option[] }[] = [];
// Data's constraint validates the shape; the conditional type preserves option metadata.
const entries = data as unknown as readonly (
| Option
| SelectOptionGroup<Option["value"], Option>
)[];
for (const entry of entries) {
if ("items" in entry) {
groups.push({ items: [...entry.items], label: entry.label });
} else {
const previous = groups.at(-1);
if (previous && previous.label === undefined) {
previous.items.push(entry);
} else {
groups.push({ items: [entry] });
}
}
}
// Base UI resolves labels from a flat lookup even when data mixes groups and options.
const items = groups.flatMap((group) => group.items);
const labelId = useId();
const triggerId = useId();
const rootProps = { ...props };
const ariaProps: AriaAttributes = {};
for (const key of Object.keys(rootProps)) {
if (key.startsWith("aria-")) {
Object.assign(ariaProps, { [key]: Reflect.get(rootProps, key) });
Reflect.deleteProperty(rootProps, key);
}
}
const renderDefaultTrigger = () =>
label ? (
<FieldOutline
label={label}
labelId={labelId}
htmlFor={triggerProps?.id ?? triggerId}
raised={labelStyle === "floating"}
staticLabel={labelStyle === "static"}
className={cn("group relative w-full", className)}
>
<SelectTrigger
{...triggerProps}
{...ariaProps}
id={triggerProps?.id ?? triggerId}
aria-labelledby={
ariaProps["aria-labelledby"] ??
triggerProps?.["aria-labelledby"] ??
(ariaProps["aria-label"] || triggerProps?.["aria-label"]
? undefined
: labelId)
}
className={cn(
"w-full",
labelStyle === "floating" &&
"border-transparent shadow-none focus-visible:ring-0",
triggerProps?.className
)}
size={size}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
</FieldOutline>
) : (
<SelectTrigger
{...triggerProps}
{...ariaProps}
className={cn(className, triggerProps?.className)}
size={size}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
);
return (
<SelectRoot {...rootProps} items={items}>
{trigger === undefined ? (
renderDefaultTrigger()
) : (
<SelectPrimitive.Trigger
{...triggerProps}
{...ariaProps}
data-slot="select-trigger"
data-size={size}
className={cn(className, triggerProps?.className)}
render={
typeof trigger === "function"
? (nativeProps, state) => {
let values: Option["value"][] = [];
if (Array.isArray(state.value)) {
values = state.value;
} else if (
state.value !== null &&
state.value !== undefined
) {
values = [state.value];
}
const labels = values.flatMap((value) => {
const item = items.find((option) => option.value === value);
return item ? [item.label] : [];
});
return (
<TriggerElement
nativeProps={nativeProps}
state={state}
render={trigger({
disabled: state.disabled,
open: state.open,
placeholder,
selectedLabel: labels.length ? labels.join(", ") : null,
value: state.value as SelectTriggerContext<
Option["value"],
Multiple
>["value"],
})}
/>
);
}
: trigger
}
/>
)}
<SelectContent {...contentProps} animated={animated}>
{groups.map((group, groupIndex) => {
const [firstOption] = group.items;
let key = `empty:${groupIndex}`;
if (group.label !== undefined) {
key = `group:${group.label}`;
} else if (firstOption) {
key = `options:${typeof firstOption.value}:${firstOption.value}`;
}
return (
<SelectGroup key={key}>
{group.label !== undefined && (
<SelectLabel>{group.label}</SelectLabel>
)}
{group.items.map((item) => (
<SelectItem
key={`${typeof item.value}:${item.value}`}
value={item.value}
label={item.label}
disabled={item.disabled}
>
{renderItem ? renderItem(item) : item.label}
</SelectItem>
))}
</SelectGroup>
);
})}
</SelectContent>
</SelectRoot>
);
};
export type {
SelectProps,
SelectTriggerContext,
SelectOption,
SelectOptionGroup,
};
export {
Select,
SelectContent,
SelectGroup,
SelectRoot,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
};
Credits
- shadcn/ui: Select composition and foundation. Vandor UI adds Motion reveal and exit transitions, reduced-motion support, and direct cn integration.