For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to any URL or sending an Accept: text/markdown header. An agent skill is available at /.well-known/agent-skills/site-skill.md.
1
Support

Select

A Base UI Select with smooth Motion-powered option-menu transitions.

Playground

Live preview
Open the menu to preview the motion. Arrow keys navigate, Enter selects, and Escape closes.
Customize Select
Customize
select-demo.tsx
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:

Dependencies4 required packages
  • @base-ui/react
  • cn
  • lucide-react
  • motion
$ pnpm add @base-ui/react cn lucide-react motion

Usage

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.

select-demo.tsx
"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

select-demo.tsx
"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.

select-demo.tsx
"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" />;
select-demo.tsx
"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} on SelectContent for an instant reveal and close.
  • Menus open below the trigger by default. Set alignItemWithTrigger to 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-label on SelectTrigger.

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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
labelstring

Visible field label for the built-in trigger. Custom triggers own their label layout.

undefined
labelStyle"floating" | "static"

Select label presentation.

"floating"
placeholderReactNode

Select: text shown before choosing a value.

Not set
size"default" | "sm"

Select: default trigger height.

"default"
disabledboolean

Select: prevents interaction with the control.

false
animatedboolean

Select: enables popup reveal and exit. Respects reduced motion.

true
alignItemWithTriggerboolean

contentProps.alignItemWithTrigger: overlaps the trigger to align the selected option.

false
valueValue | null

Select: controlled selected value.

Not set
defaultValueValue | null

Select: initial uncontrolled value.

Not set
onValueChange(value, details) => void

Select: receives the selected value and Base UI event details.

Not set
datareadonly (SelectOption<Value> | SelectOptionGroup<Value>)[]

Select: options and one-level groups rendered automatically.

Required
renderItem(item: Option) => ReactNode

Select: custom option content; label remains the default trigger and typeahead text. Retains option metadata.

Not set
triggerReactElement | ((context: SelectTriggerContext) => ReactElement)

Select: custom trigger element or callback receiving value, selectedLabel, placeholder, open and disabled. Owns its visual content.

Built-in trigger
triggerPropsOmit<SelectTriggerProps, 'children' | 'size' | 'render'>

Select: additional trigger props, including ref, event handlers, id and trigger-specific className styling.

Not set
contentPropsOmit<SelectContentProps, 'children' | 'animated'>

Select: popup props and positioning options.

Not set
namestring

Select: 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.