component — selection

Select

An accessible single or multiple select with a quebi input-style trigger and a dropdown surface for options. Built on react-aria-components — foundational for Calendar and Conform Select.

  • select
  • form
  • input
  • dropdown
  • listbox
  • options
  • interactive

07 examples

Default

A basic single select with a placeholder and a list of options.

Sizes

xs, sm, and the default md — the same scale Input publishes, so a trigger lines up with the button beside it.

With label & description

Compose with the field Label and Description primitives.

PlanChoose the tier for your workspace.

Items with icons

Options can carry a leading icon alongside the label.

Descriptions

Each option can show a secondary line of context.

Sections, separator & intent

Group options under a titled section, divide them, and flag destructive items.

Disabled

The whole control dims and blocks interaction.

source

select.tsx

Copy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.

"use client"

import { ChevronsUpDown } from "lucide-react"
import type {
  ListBoxProps,
  PopoverProps,
  SelectProps as SelectPrimitiveProps,
} from "react-aria-components"
import {
  Button,
  composeRenderProps,
  ListBox,
  Select as SelectPrimitive,
  SelectValue,
} from "react-aria-components"
import { useFieldSizing } from "@/lib/field-size"
import { cn } from "@/lib/utils"
import { fieldStyles } from "@/components/field"
import {
  DropdownDescription,
  DropdownItem,
  DropdownLabel,
  DropdownSection,
  DropdownSeparator,
} from "@/components/dropdown"
import { PopoverContent } from "@/components/popover"

/**
 * Select — quebi design system
 *
 * An accessible single/multiple select built on react-aria-components. The
 * trigger is drawn like `Input` — boxed by default, underlined inside
 * `quebi-editorial`, its frame doubled while focused or open, no ring; the
 * chevron is muted; the options reuse the dropdown surface and items. Foundational — Calendar and Conform Select
 * compose this.
 */

interface SelectProps<T extends object, M extends "single" | "multiple" = "single">
  extends SelectPrimitiveProps<T, M> {
  items?: Iterable<T, M>
}

const Select = <T extends object, M extends "single" | "multiple" = "single">({
  className,
  ...props
}: SelectProps<T, M>) => {
  return (
    <SelectPrimitive
      data-slot="control"
      className={composeRenderProps(className, (resolved) => cn("group/select", fieldStyles, resolved))}
      {...props}
    />
  )
}

interface SelectListProps<T extends object>
  extends Omit<ListBoxProps<T>, "layout" | "orientation"> {
  items?: Iterable<T>
  popover?: Omit<PopoverProps, "children">
}

const SelectContent = <T extends object>({
  items,
  className,
  popover,
  ...props
}: SelectListProps<T>) => {
  return (
    <PopoverContent
      placement={popover?.placement ?? "bottom"}
      className={composeRenderProps(popover?.className, (resolved) =>
        cn(
          "min-w-(--trigger-width) scroll-py-1 overflow-y-auto overscroll-contain",
          resolved,
        ),
      )}
      {...popover}
    >
      <ListBox
        layout="stack"
        orientation="vertical"
        className={composeRenderProps(className, (resolved) =>
          cn(
            "grid max-h-96 w-full grid-cols-[auto_1fr] flex-col gap-y-1 overflow-y-auto p-1 outline-hidden *:[[role='group']+[role=group]]:mt-4 *:[[role='group']+[role=separator]]:mt-1",
            resolved,
          ),
        )}
        items={items}
        {...props}
      />
    </PopoverContent>
  )
}

/**
 * The trigger's size scale — the same three steps `Input` publishes, and for
 * the same reason: a trigger's height is line-height + padding + 1px of border
 * top (transparent) and bottom, so `xs` (30px) and `sm` (38px) line up exactly
 * with `Button`'s `xs` and `sm`. No horizontal padding: the value starts under
 * the label.
 *
 * Spelled out here rather than imported so `Select` does not gain `Input` as a
 * registry dependency for three strings.
 */
const selectTriggerSizeStyles = {
  xs: "text-xs px-(--q-field-px) py-1.5",
  sm: "text-sm px-(--q-field-px) py-2",
  md: "text-sm px-(--q-field-px) py-2.5",
} as const

type SelectTriggerSize = keyof typeof selectTriggerSizeStyles

interface SelectTriggerProps extends Omit<React.ComponentProps<typeof Button>, "size"> {
  prefix?: React.ReactNode
  className?: string
  /**
   * Control height. Matches `Input`'s scale and `Button`'s `xs` / `sm`. Left
   * out, it is whatever the surrounding surface asked for — a table cell being
   * the one that does. See `@/lib/field-size`.
   */
  size?: SelectTriggerSize
}

const SelectTrigger = ({ children, className, size: sizeProp, ...props }: SelectTriggerProps) => {
  const { size } = useFieldSizing({ size: sizeProp })
  return (
    <span data-slot="control" className="relative block w-full">
      <Button
        className={cn(
          // `Input`'s frame: a box, or an underline whose transparent top border
          // keeps the height the scale's.
          "group/select-trigger flex w-full min-w-0 cursor-default items-center gap-x-2 text-start text-quebi-fg",
          "quebi-field",
          selectTriggerSizeStyles[size],
          "transition-[border-color,box-shadow] duration-150",
          // focus / open → the frame turns `focus` and doubles, as `Input`'s.
          "outline-none focus:outline-none focus:shadow-(--q-field-focus)",
          "group-open/select:shadow-(--q-field-focus)",
          "group-invalid/select:[--q-field-edge:var(--q-danger)] group-invalid/select:focus:shadow-(--q-field-focus-danger)",
          // leading icons / loader, muted.
          "*:data-[slot=icon]:size-4 *:data-[slot=icon]:shrink-0 *:data-[slot=icon]:self-center *:data-[slot=icon]:text-quebi-fg-subtle",
          "*:data-[slot=loader]:size-4 *:data-[slot=loader]:shrink-0 *:data-[slot=loader]:self-center *:data-[slot=loader]:text-quebi-fg-subtle",
          "group-disabled/select:cursor-not-allowed group-disabled/select:opacity-50",
          "in-disabled:opacity-50",
          className,
        )}
      >
        {(values) => (
          <>
            {props.prefix && <span className="text-quebi-fg-subtle">{props.prefix}</span>}
            {typeof children === "function" ? children(values) : children}

            {!children && (
              <>
                <SelectValue
                  data-slot="select-value"
                  className={cn(
                    "truncate text-start data-placeholder:text-quebi-fg-subtle **:[[slot=description]]:hidden",
                    "has-data-[slot=avatar]:grid has-data-[slot=avatar]:grid-cols-[1fr_auto] has-data-[slot=avatar]:items-center has-data-[slot=avatar]:gap-x-2",
                    "has-data-[slot=icon]:grid has-data-[slot=icon]:grid-cols-[1fr_auto] has-data-[slot=icon]:items-center has-data-[slot=icon]:gap-x-2",
                    "*:data-[slot=icon]:size-4",
                    "*:mt-0 *:data-[slot=avatar]:[--avatar-size:--spacing(4)]",
                  )}
                />
                <ChevronsUpDown
                  data-slot="chevron"
                  className="ms-auto size-4 shrink-0 text-quebi-fg-subtle"
                />
              </>
            )}
          </>
        )}
      </Button>
    </span>
  )
}

const SelectSection = DropdownSection
const SelectSeparator = DropdownSeparator
const SelectLabel = DropdownLabel
const SelectDescription = DropdownDescription
const SelectItem = DropdownItem

export type { SelectProps, SelectTriggerProps, SelectTriggerSize }
export {
  Select,
  SelectContent,
  SelectDescription,
  SelectItem,
  SelectLabel,
  SelectSection,
  SelectSeparator,
  SelectTrigger,
}