Select

Displays a list of options for the user to pick from, opened from a button.

import * as stylex from "@stylexjs/stylex"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "12rem" },
})

const items = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
  { label: "Grapes", value: "grapes" },
  { label: "Pineapple", value: "pineapple" },
]

export default function SelectDemo() {
  return (
    <Select items={items}>
      <SelectTrigger xstyle={styles.trigger} aria-label="Fruit">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectLabel>Fruits</SelectLabel>
          {items.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  )
}

Installation

Install the following dependencies:

pnpm add @base-ui/react @stylexjs/stylex clsx lucide-react

Set up the token layer and the StyleX compiler, if you have not already. See Installation, and paste states.ts and state-styles.ts from the same page. states.ts holds the popup surface, the item highlight and its nested corner, the trigger's field surface, its focus and invalid rings, and the dim a disabled trigger or item takes. state-styles.ts is how the trigger and the items read their Base UI state. Include interop.css for icon sizing in the trigger, items and scroll arrows.

Paste field-state.ts from the same installation page.

Copy and paste the following code into your project.

components/ui/select.tsx
"use client"

import { use, type CSSProperties, type ReactNode } from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import * as stylex from "@stylexjs/stylex"
import type { StyleXStyles } from "@stylexjs/stylex"
import { clsx } from "clsx"
import { Check, ChevronDown, ChevronUp } from "lucide-react"

import { stateStyles } from "@/lib/state-styles"
import { FieldStateContext } from "@/lib/field-state"
import { states } from "@/theme/states"
import { color, duration, ease, layer, leading, radius, space, text } from "@/theme/tokens.stylex"

const fieldHoverBg = `light-dark(${color.buttonSecondaryBg}, color-mix(in oklab, ${color.input} 50%, transparent))`

const styles = stylex.create({
  hidden: { opacity: 0 },
  trigger: {
    display: "flex",
    width: "fit-content",
    alignItems: "center",
    justifyContent: "space-between",
    gap: space.s1_5,
    whiteSpace: "nowrap",
    borderRadius: radius.lg,
    borderWidth: 1,
    borderStyle: "solid",
    paddingInlineStart: space.s2_5,
    paddingInlineEnd: space.s2,
    paddingBlock: space.s1,
    fontSize: text.sm,
    lineHeight: leading.sm,
    color: color.foreground,
    userSelect: "none",
    transitionProperty: "color, background-color, border-color, box-shadow",
    transitionDuration: duration.color,
    transitionTimingFunction: ease.standard,
    "--carbon-icon-size": space.s4,
  },
  triggerFill: {
    backgroundColor: {
      default: color.fieldBg,
      ":hover": fieldHoverBg,
      ":disabled": color.inputDisabledBg,
    },
  },
  triggerPlaceholder: { color: color.mutedForeground },
  value: {
    display: "block",
    flexGrow: 1,
    minWidth: 0,
    overflow: "hidden",
    textOverflow: "ellipsis",
    textAlign: "start",
  },
  icon: { display: "flex", color: color.mutedForeground },
  content: {
    position: "relative",
    zIndex: layer.popup,
    display: "flex",
    flexDirection: "column",
    width: "var(--anchor-width)",
    minWidth: "9rem",
    maxHeight: "var(--available-height)",
    overflow: "hidden",
    "--carbon-popup-radius": radius.lg,
    borderRadius: "var(--carbon-popup-radius)",
    transformOrigin: "var(--transform-origin)",
    transitionProperty: "opacity, transform",
    transitionDuration: {
      default: duration.fade,
      "@media (prefers-reduced-motion: reduce)": "0s",
    },
    transitionTimingFunction: ease.standard,
  },
  list: { minHeight: 0 },
  scrollArrow: {
    position: "absolute",
    insetInline: 0,
    zIndex: layer.overlay,
    display: "flex",
    alignItems: "center",
    justifyContent: "center",
    paddingBlock: space.s1,
    cursor: "default",
    backgroundColor: color.popover,
    "--carbon-icon-size": space.s4,
  },
  scrollUp: { top: 0 },
  scrollDown: { bottom: 0 },
  group: { padding: space.s1, scrollMarginTop: space.s1, scrollMarginBottom: space.s1 },
  label: {
    paddingInline: space.s1_5,
    paddingBlock: space.s1,
    fontSize: text.xs,
    lineHeight: leading.xs,
    fontWeight: 500,
    color: color.mutedForeground,
  },
  item: {
    position: "relative",
    display: "flex",
    width: "100%",
    alignItems: "center",
    gap: space.s1_5,
    cursor: "default",
    userSelect: "none",
    paddingBlock: space.s1,
    paddingInlineStart: space.s1_5,
    paddingInlineEnd: space.s8,
    fontSize: text.sm,
    lineHeight: leading.sm,
    outlineStyle: "none",
    "--carbon-icon-size": space.s4,
  },
  itemText: {
    display: "flex",
    flexGrow: 1,
    flexShrink: 0,
    alignItems: "center",
    gap: space.s2,
    whiteSpace: "nowrap",
  },
  indicator: {
    pointerEvents: "none",
    position: "absolute",
    insetInlineEnd: space.s2,
    display: "flex",
    width: space.s4,
    height: space.s4,
    alignItems: "center",
    justifyContent: "center",
  },
  separator: { height: 1, backgroundColor: color.border },
})

const sizes = stylex.create({
  default: { height: space.s8 },
  sm: { height: space.s7, borderRadius: radius.md },
})

const selectStyles = {
  trigger: [styles.trigger, states.focusRing, states.fieldSurface, styles.triggerFill],
  triggerPlaceholder: styles.triggerPlaceholder,
  triggerDisabled: states.disabled,
  invalid: states.invalidRing,
  sizes,
  content: [states.popupSurface, styles.content],
  item: [styles.item, states.popupItem],
  itemHighlighted: states.itemHighlight,
  itemDisabled: states.disabled,
  label: styles.label,
  separator: styles.separator,
}

type SelectSize = keyof typeof sizes

const slide = stylex.create({
  top: { transform: `scale(0.95) translateY(${space.s2})` },
  bottom: { transform: `scale(0.95) translateY(calc(-1 * ${space.s2}))` },
  left: { transform: `scale(0.95) translateX(${space.s2})` },
  right: { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  "inline-start": { transform: `scale(0.95) translateX(${space.s2})` },
  "inline-end": { transform: `scale(0.95) translateX(calc(-1 * ${space.s2}))` },
  none: { transform: "none" },
})

type Overrides = { xstyle?: StyleXStyles; className?: string }

type StateOverrides = Overrides & { style?: CSSProperties }

function Select<Value, Multiple extends boolean | undefined = false>(
  props: SelectPrimitive.Root.Props<Value, Multiple>,
) {
  return <SelectPrimitive.Root {...props} />
}

type SelectGroupProps = Omit<SelectPrimitive.Group.Props, "className"> & Overrides

function SelectGroup({ xstyle, className, ...props }: SelectGroupProps) {
  const sx = stylex.props(styles.group, xstyle)
  return (
    <SelectPrimitive.Group
      data-slot="select-group"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type SelectValueProps = Omit<SelectPrimitive.Value.Props, "className"> & Overrides

function SelectValue({ xstyle, className, ...props }: SelectValueProps) {
  const sx = stylex.props(styles.value, xstyle)
  return (
    <SelectPrimitive.Value
      data-slot="select-value"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type SelectTriggerProps = Omit<SelectPrimitive.Trigger.Props, "className" | "style"> &
  StateOverrides & {
    size?: SelectSize
    icon?: ReactNode
  }

function SelectTrigger({
  xstyle,
  style,
  className,
  size = "default",
  icon = <ChevronDown />,
  "aria-invalid": ariaInvalid,
  children,
  ...props
}: SelectTriggerProps) {
  const fieldState = use(FieldStateContext)
  const invalid = ariaInvalid != null && ariaInvalid !== false
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      aria-invalid={invalid || undefined}
      {...props}
      {...stateStyles(
        (state) => [
          styles.trigger,
          states.focusRing,
          states.fieldSurface,
          styles.triggerFill,
          sizes[size],
          state.placeholder && styles.triggerPlaceholder,
          (state.valid === false || fieldState?.valid === false || invalid) && states.invalidRing,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      {children}
      {icon != null && (
        <SelectPrimitive.Icon data-slot="select-trigger-icon" {...stylex.props(styles.icon)}>
          {icon}
        </SelectPrimitive.Icon>
      )}
    </SelectPrimitive.Trigger>
  )
}

type SelectContentProps = Omit<SelectPrimitive.Popup.Props, "className" | "style"> &
  Pick<
    SelectPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
  > &
  StateOverrides

function SelectContent({
  xstyle,
  style,
  className,
  children,
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  alignItemWithTrigger = true,
  ...props
}: SelectContentProps) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        alignItemWithTrigger={alignItemWithTrigger}
        {...stylex.props(states.positioner)}
      >
        <SelectPrimitive.Popup
          data-slot="select-content"
          {...props}
          {...stateStyles(
            (state) => [
              states.popupSurface,
              styles.content,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                styles.hidden,
              (state.transitionStatus === "starting" || state.transitionStatus === "ending") &&
                slide[state.side],
              xstyle,
            ],
            { className, style },
          )}
        >
          <SelectScrollUpButton />
          <SelectPrimitive.List data-slot="select-list" {...stylex.props(styles.list)}>
            {children}
          </SelectPrimitive.List>
          <SelectScrollDownButton />
        </SelectPrimitive.Popup>
      </SelectPrimitive.Positioner>
    </SelectPrimitive.Portal>
  )
}

type SelectLabelProps = Omit<SelectPrimitive.GroupLabel.Props, "className"> & Overrides

function SelectLabel({ xstyle, className, ...props }: SelectLabelProps) {
  const sx = stylex.props(styles.label, xstyle)
  return (
    <SelectPrimitive.GroupLabel
      data-slot="select-label"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type SelectItemProps = Omit<SelectPrimitive.Item.Props, "className" | "style"> & StateOverrides

function SelectItem({ xstyle, style, className, children, ...props }: SelectItemProps) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      {...props}
      {...stateStyles(
        (state) => [
          styles.item,
          states.popupItem,
          state.highlighted && states.itemHighlight,
          state.disabled && states.disabled,
          xstyle,
        ],
        { className, style, hook: "carbon-icons" },
      )}
    >
      <SelectPrimitive.ItemText data-slot="select-item-text" {...stylex.props(styles.itemText)}>
        {children}
      </SelectPrimitive.ItemText>
      <SelectPrimitive.ItemIndicator
        render={<span data-slot="select-item-indicator" {...stylex.props(styles.indicator)} />}
      >
        <Check />
      </SelectPrimitive.ItemIndicator>
    </SelectPrimitive.Item>
  )
}

type SelectSeparatorProps = Omit<SelectPrimitive.Separator.Props, "className"> & Overrides

function SelectSeparator({ xstyle, className, ...props }: SelectSeparatorProps) {
  const sx = stylex.props(styles.separator, xstyle)
  return (
    <SelectPrimitive.Separator
      data-slot="select-separator"
      {...sx}
      {...props}
      className={clsx(sx.className, className)}
    />
  )
}

type SelectScrollUpButtonProps = Omit<SelectPrimitive.ScrollUpArrow.Props, "className"> & Overrides

function SelectScrollUpButton({ xstyle, className, ...props }: SelectScrollUpButtonProps) {
  const sx = stylex.props(styles.scrollArrow, styles.scrollUp, xstyle)
  return (
    <SelectPrimitive.ScrollUpArrow
      data-slot="select-scroll-up-button"
      {...sx}
      {...props}
      className={clsx("carbon-icons", sx.className, className)}
    >
      <ChevronUp />
    </SelectPrimitive.ScrollUpArrow>
  )
}

type SelectScrollDownButtonProps = Omit<SelectPrimitive.ScrollDownArrow.Props, "className"> &
  Overrides

function SelectScrollDownButton({ xstyle, className, ...props }: SelectScrollDownButtonProps) {
  const sx = stylex.props(styles.scrollArrow, styles.scrollDown, xstyle)
  return (
    <SelectPrimitive.ScrollDownArrow
      data-slot="select-scroll-down-button"
      {...sx}
      {...props}
      className={clsx("carbon-icons", sx.className, className)}
    >
      <ChevronDown />
    </SelectPrimitive.ScrollDownArrow>
  )
}

export {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectScrollDownButton,
  SelectScrollUpButton,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
  selectStyles,
  type SelectSize,
}

Update the import paths to match your project setup.

Usage

import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
const items = [
  { label: "Theme", value: null },
  { label: "Light", value: "light" },
  { label: "Dark", value: "dark" },
  { label: "System", value: "system" },
]

export function Example() {
  return (
    <Select items={items}>
      <SelectTrigger aria-label="Theme">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          {items.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  )
}

Pass the options through items so SelectValue can show the chosen item's label rather than its value. An entry whose value is null is the placeholder. Without items, pass placeholder to SelectValue instead.

Give the trigger an aria-label or a Label with htmlFor pointing at its id. There is no Field to name it.

Wrap items in a SelectGroup, even without a label. The group carries the popup's inset, so a bare item sits flush against the edge.

The trigger sizes itself to its content. Give it a width through xstyle to stop it changing size when the value does. Right-to-left layout is not covered here.

Composition

Select
├── SelectTrigger
│   └── SelectValue
└── SelectContent
    ├── SelectGroup
    │   ├── SelectLabel
    │   ├── SelectItem
    │   └── SelectItem
    ├── SelectSeparator
    └── SelectGroup
        ├── SelectLabel
        ├── SelectItem
        └── SelectItem

Align item with trigger

Set alignItemWithTrigger on SelectContent. When true, the popup opens over the trigger with the selected item on top of it, the way a native select does. When false, it opens below the trigger like a menu.

"use client"

import { useState } from "react"
import * as stylex from "@stylexjs/stylex"
import { Checkbox } from "@/components/ui/checkbox"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { HStack, VStack } from "@/components/ui/stack"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "12rem" },
})

const items = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
  { label: "Grapes", value: "grapes" },
  { label: "Pineapple", value: "pineapple" },
]

export default function SelectAlignItem() {
  const [alignItemWithTrigger, setAlignItemWithTrigger] = useState(true)

  return (
    <VStack gap={4} align="start">
      <HStack gap={2} align="center">
        <Checkbox
          id="select-align-item"
          checked={alignItemWithTrigger}
          onCheckedChange={setAlignItemWithTrigger}
        />
        <Label htmlFor="select-align-item">Align item with trigger</Label>
      </HStack>
      <Select items={items} defaultValue="banana">
        <SelectTrigger xstyle={styles.trigger} aria-label="Fruit">
          <SelectValue />
        </SelectTrigger>
        <SelectContent alignItemWithTrigger={alignItemWithTrigger}>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </VStack>
  )
}

The overlap is skipped on touch, and when there is no room for it. The popup then opens from the trigger's edge and slides in from that side instead of fading in place.

Groups

Use SelectGroup, SelectLabel and SelectSeparator to organise items.

import * as stylex from "@stylexjs/stylex"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectSeparator,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "12rem" },
})

const fruits = [
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
]

const vegetables = [
  { label: "Carrot", value: "carrot" },
  { label: "Broccoli", value: "broccoli" },
  { label: "Spinach", value: "spinach" },
]

const items = [{ label: "Select a fruit", value: null }, ...fruits, ...vegetables]

export default function SelectGroups() {
  return (
    <Select items={items}>
      <SelectTrigger xstyle={styles.trigger} aria-label="Produce">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        <SelectGroup>
          <SelectLabel>Fruits</SelectLabel>
          {fruits.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectGroup>
        <SelectSeparator />
        <SelectGroup>
          <SelectLabel>Vegetables</SelectLabel>
          {vegetables.map((item) => (
            <SelectItem key={item.value} value={item.value}>
              {item.label}
            </SelectItem>
          ))}
        </SelectGroup>
      </SelectContent>
    </Select>
  )
}

Scrollable

A list taller than the space below the trigger scrolls. Hovering the arrow at either end scrolls it. On touch, the arrows do not render.

import * as stylex from "@stylexjs/stylex"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectLabel,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "16rem" },
})

const regions = [
  {
    label: "North America",
    items: [
      { label: "Eastern Standard Time", value: "est" },
      { label: "Central Standard Time", value: "cst" },
      { label: "Mountain Standard Time", value: "mst" },
      { label: "Pacific Standard Time", value: "pst" },
      { label: "Alaska Standard Time", value: "akst" },
      { label: "Hawaii Standard Time", value: "hst" },
    ],
  },
  {
    label: "Europe & Africa",
    items: [
      { label: "Greenwich Mean Time", value: "gmt" },
      { label: "Central European Time", value: "cet" },
      { label: "Eastern European Time", value: "eet" },
      { label: "Western European Summer Time", value: "west" },
      { label: "Central Africa Time", value: "cat" },
      { label: "East Africa Time", value: "eat" },
    ],
  },
  {
    label: "Asia",
    items: [
      { label: "Moscow Time", value: "msk" },
      { label: "India Standard Time", value: "ist" },
      { label: "China Standard Time", value: "cst_china" },
      { label: "Japan Standard Time", value: "jst" },
      { label: "Korea Standard Time", value: "kst" },
      { label: "Indonesia Central Standard Time", value: "ist_indonesia" },
    ],
  },
  {
    label: "Australia & Pacific",
    items: [
      { label: "Australian Western Standard Time", value: "awst" },
      { label: "Australian Central Standard Time", value: "acst" },
      { label: "Australian Eastern Standard Time", value: "aest" },
      { label: "New Zealand Standard Time", value: "nzst" },
      { label: "Fiji Time", value: "fjt" },
    ],
  },
  {
    label: "South America",
    items: [
      { label: "Argentina Time", value: "art" },
      { label: "Bolivia Time", value: "bot" },
      { label: "Brasilia Time", value: "brt" },
      { label: "Chile Standard Time", value: "clt" },
    ],
  },
]

const items = [
  { label: "Select a timezone", value: null },
  ...regions.flatMap((region) => region.items),
]

export default function SelectScrollable() {
  return (
    <Select items={items}>
      <SelectTrigger xstyle={styles.trigger} aria-label="Timezone">
        <SelectValue />
      </SelectTrigger>
      <SelectContent>
        {regions.map((region) => (
          <SelectGroup key={region.label}>
            <SelectLabel>{region.label}</SelectLabel>
            {region.items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        ))}
      </SelectContent>
    </Select>
  )
}

Size

Use the size prop on SelectTrigger.

import * as stylex from "@stylexjs/stylex"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "12rem" },
})

const items = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
]

export default function SelectSize() {
  return (
    <HStack gap={4} align="center" wrap="wrap">
      <Select items={items}>
        <SelectTrigger size="sm" xstyle={styles.trigger} aria-label="Fruit">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
      <Select items={items}>
        <SelectTrigger size="default" xstyle={styles.trigger} aria-label="Fruit">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </HStack>
  )
}

Disabled

Set disabled on Field to disable its control and dim its label, or on a bare control directly.

import { Field, FieldLabel } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { HStack } from "@/components/ui/stack"

const styles = stylex.create({
  trigger: { width: "100%", maxWidth: "12rem" },
})

const items = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
  { label: "Grapes", value: "grapes", disabled: true },
  { label: "Pineapple", value: "pineapple" },
]

export default function SelectDisabled() {
  return (
    <HStack gap={4} align="center" wrap="wrap">
      <Field disabled>
        <FieldLabel>Fruit</FieldLabel>
        <Select items={items}>
          <SelectTrigger xstyle={styles.trigger} aria-label="Fruit">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            <SelectGroup>
              {items.map((item) => (
                <SelectItem key={item.value} value={item.value}>
                  {item.label}
                </SelectItem>
              ))}
            </SelectGroup>
          </SelectContent>
        </Select>
      </Field>
      <Select items={items}>
        <SelectTrigger xstyle={styles.trigger} aria-label="Fruit">
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value} disabled={item.disabled}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
    </HStack>
  )
}

Invalid

Set invalid on Field to style its control, or aria-invalid on a bare control. The examples show both.

import { Field, FieldLabel, FieldError } from "@/components/ui/field"
import * as stylex from "@stylexjs/stylex"
import { Label } from "@/components/ui/label"
import {
  Select,
  SelectContent,
  SelectGroup,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { VStack } from "@/components/ui/stack"

const styles = stylex.create({
  field: { width: "100%", maxWidth: "12rem" },
  trigger: { width: "100%" },
})

const items = [
  { label: "Select a fruit", value: null },
  { label: "Apple", value: "apple" },
  { label: "Banana", value: "banana" },
  { label: "Blueberry", value: "blueberry" },
]

function BareControl({ bare = false }: { bare?: boolean }) {
  return (
    <VStack gap={2} xstyle={styles.field}>
      {bare && <Label htmlFor={bare ? "select-invalid" : undefined}>Fruit</Label>}
      <Select items={items}>
        <SelectTrigger
          id={bare ? "select-invalid" : undefined}
          xstyle={styles.trigger}
          aria-invalid={bare || undefined}
          aria-describedby={bare ? "select-invalid-error" : undefined}
        >
          <SelectValue />
        </SelectTrigger>
        <SelectContent>
          <SelectGroup>
            {items.map((item) => (
              <SelectItem key={item.value} value={item.value}>
                {item.label}
              </SelectItem>
            ))}
          </SelectGroup>
        </SelectContent>
      </Select>
      <FieldError id={bare ? "select-invalid-error" : undefined}>Please select a fruit.</FieldError>
    </VStack>
  )
}

export default function InvalidExample() {
  return (
    <VStack gap={5}>
      <BareControl bare />
      <Field invalid>
        <FieldLabel>Fruit</FieldLabel>
        <BareControl />
      </Field>
    </VStack>
  )
}

API reference

Pass render to replace a part's element or compose it with another component.

Every part wraps the Base UI Select part of the same name and accepts its props. See the Base UI reference for the full list. Each styled part also takes xstyle and className. SelectTrigger and SelectItem take style too. It is real inline CSS, merged over whatever their Base UI state resolves to.

Select

PropTypeDefault
items{ label: ReactNode; value: Value }[] | Record<string, …>—
valueValue | null—
defaultValueValue | null—
onValueChange(value: Value | null) => void—
multiplebooleanfalse
namestring—
requiredbooleanfalse
disabledbooleanfalse
readOnlybooleanfalse

name renders a hidden input, so the value travels with a form.

SelectTrigger

PropTypeDefault
size"default" | "sm""default"
iconReactNodechevron
aria-invalidboolean—
disabledboolean—
styleCSSProperties—

Text inside the trigger clips with an ellipsis once it outgrows the width the trigger was given.

icon replaces the chevron after the value. Pass null to remove it.

SelectValue

PropTypeDefault
placeholderReactNode—
childrenReactNode | (value) => ReactNode—

A function child formats the selected value. It replaces the label items would otherwise provide.

SelectContent

PropTypeDefault
side"top" | "bottom" | "left" | "right" | "inline-start" | "inline-end""bottom"
sideOffsetnumber4
align"start" | "center" | "end""center"
alignOffsetnumber0
alignItemWithTriggerbooleantrue

The popup is the trigger's width and never narrower than 9rem. Its height is capped at the space available, and the list inside scrolls.

SelectItem

PropTypeDefault
valueValue—
disabledbooleanfalse
labelstring—
styleCSSProperties—

label is the text typed letters match against. It defaults to the item's text content.

selectStyles

The style maps behind the component. Spread them onto any element.

KeyWhat it is
triggerThe trigger's frame and type, ring, surface and hover
triggerPlaceholderThe muted text while nothing is selected
triggerDisabledThe dimmed, inert state every control shares
invalidThe destructive border and ring
sizesdefault and sm heights
contentThe popup: the shared surface and the box over it
itemAn item's row, type and indicator inset
itemHighlightedThe highlight fill and inset ring
itemDisabledThe same dim, on one option
labelA group label
separatorThe hairline between groups

trigger is the part's own box, states.focusRing, states.fieldSurface and the fill with its hover, in merge order. content merges states.popupSurface (the fill and hairline ring) under the popup's own box, which sets --carbon-popup-radius. item merges states.popupItem, so a row's corner is that radius minus the popup's inset. itemHighlighted is states.itemHighlight. invalid is states.invalidRing and both disabled keys are states.disabled. Set a trigger border of your own through --carbon-border-color rather than borderColor. A flat borderColor replaces the property with all of its conditions, so the ring loses its focus colour.

Data attributes

AttributeOnEffect
data-slot="select-trigger"the triggerTargets the trigger from a parent
data-sizethe triggerReflects the size prop
data-placeholderthe triggerBase UI writes it while nothing is selected
data-popup-open, data-disabledthe triggerBase UI writes them
data-slot="select-content"the popupPopup surface
data-side, data-alignthe popupBase UI writes them. data-side picks the slide; "none" means overlapped
data-starting-style, data-ending-stylethe popupBase UI writes them while the popup animates in or out
data-slot="select-item"an itemTargets items from a parent
data-highlighted, data-selectedan itemBase UI writes them
data-disabledan itemBase UI writes it