# Select

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

> For the complete documentation index, see [llms.txt](/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](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```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 [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/select.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install @base-ui/react cn lucide-react motion
```

## Usage [#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.

```tsx
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 [#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.

```tsx
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.

<ComponentPreview src="examples/select-demo.tsx" title="select-demo.tsx">
  <SelectCustomTriggerDemo />
</ComponentPreview>

### Advanced Composition [#advanced-composition]

The existing primitives remain available when you need a custom trigger, value display, or popup layout.

```tsx
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 [#examples]

<ComponentPreview name="select-demo" title="select-demo.tsx">
  <SelectDemo />
</ComponentPreview>

### Inside a Drawer [#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.

<ComponentPreview src="examples/select-demo.tsx" title="select-demo.tsx">
  <SelectInsideDrawerDemo />
</ComponentPreview>

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 [#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 [#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`.

```tsx
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" />;
```

<ComponentPreview src="examples/select-demo.tsx" title="select-demo.tsx">
  <SelectGroupedDemo />
</ComponentPreview>

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 [#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.

```tsx
<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 [#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 [#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 [#portable-storybook-stories]

Install Select first, then optionally install its colocated stories without overwriting the component:

```bash
npx 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 [#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 [#props]

| Prop                 | Type                                                              | Default          | Description                                                                                                                         |
| -------------------- | ----------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| label                | string                                                            | undefined        | Visible field label for the built-in trigger. Custom triggers own their label layout.                                               |
| labelStyle           | "floating" \| "static"                                            | "floating"       | Select label presentation.                                                                                                          |
| placeholder          | ReactNode                                                         | Not set          | Select: text shown before choosing a value.                                                                                         |
| size                 | "default" \| "sm"                                                 | "default"        | Select: default trigger height.                                                                                                     |
| disabled             | boolean                                                           | false            | Select: prevents interaction with the control.                                                                                      |
| animated             | boolean                                                           | true             | Select: enables popup reveal and exit. Respects reduced motion.                                                                     |
| alignItemWithTrigger | boolean                                                           | false            | contentProps.alignItemWithTrigger: overlaps the trigger to align the selected option.                                               |
| value                | Value \| null                                                     | Not set          | Select: controlled selected value.                                                                                                  |
| defaultValue         | Value \| null                                                     | Not set          | Select: initial uncontrolled value.                                                                                                 |
| onValueChange        | (value, details) => void                                          | Not set          | Select: receives the selected value and Base UI event details.                                                                      |
| data                 | readonly (SelectOption\<Value> \| SelectOptionGroup\<Value>)\[]   | Required         | Select: options and one-level groups rendered automatically.                                                                        |
| renderItem           | (item: Option) => ReactNode                                       | Not set          | Select: custom option content; label remains the default trigger and typeahead text. Retains option metadata.                       |
| trigger              | ReactElement \| ((context: SelectTriggerContext) => ReactElement) | Built-in trigger | Select: custom trigger element or callback receiving value, selectedLabel, placeholder, open and disabled. Owns its visual content. |
| triggerProps         | Omit\<SelectTriggerProps, 'children' \| 'size' \| 'render'>       | Not set          | Select: additional trigger props, including ref, event handlers, id and trigger-specific className styling.                         |
| contentProps         | Omit\<SelectContentProps, 'children' \| 'animated'>               | Not set          | Select: popup props and positioning options.                                                                                        |
| name                 | string                                                            | Not set          | Select: name included in native form submission.                                                                                    |

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 [#source]

```tsx
"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 [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/select): Select composition and foundation. Vandor UI adds Motion reveal and exit transitions, reduced-motion support, and direct cn integration.
