Skip to contentVibraUI
Inputs & filters

Select

A trigger that opens a list of options, the chosen one checked.

Vibra puts the trigger on the card plane with the kit's solid focus outline and draws the list as the elev-1 floating layer, fading in over --duration-base without shadcn's zoom; options are set in the 13px label register. The trigger's padding, its value and each option's check sit on the inline axis, so a right-to-left select mirrors, and a long list opened over its trigger runs to the screen's edges with the chosen option in the trigger's place — shadcn caps it at the space below the trigger, which cuts it off above its own trigger. The list is the element that scrolls whether it opens over the trigger or drops below it (alignItemWithTrigger={false}), so a list longer than the room there is shows its scroll arrows either way; the popup only stops at the room. Pass items to the root so the trigger can show the chosen option's label.

Install

npx shadcn@latest add @vibra/select

Needs the @vibra registry in your components.json — set it up once.

Examples

Small, in a toolbar

size="sm" beside a table's filters: as wide as its value, and the list drops below the trigger instead of over it.

With icons

Each option leads with its picture and the trigger repeats the chosen one; the words carry the choice.

Grouped

Regions under their continents, with the region code at each option's end in the code face.

With descriptions

A second line under each plan while the list is open; the trigger keeps to the plan's name.

Several at once

multiple keeps the list open while the reader ticks, and the trigger names the choices rather than counting them.

Long list

Thirty-nine countries: the list scrolls with arrows at its ends, and typing a name jumps to it.

Required, with an error

Sending without a choice marks the trigger invalid, points it at the message and moves the focus to it.

Disabled, with the reason

A plan too small for the workspace stays in the list with the reason in its words; a setting that can no longer change says why under it.

With faces

Teammates with their face and role in the list, and the face beside the name once chosen.

Priority

The kit's PriorityBadge in the list and the trigger, each level saying what it promises the customer.

Right to left

The demo's period in Arabic: the value starts at the right, the chevron and the check keep the inline end.

Props

PropTypeDefaultDescription
items{ value: string; label: React.ReactNode }[] | Record<string, React.ReactNode>—The options' labels by value, so the trigger shows the chosen option's label rather than its value.
multiplebooleanfalseKeeps the list open while the reader ticks several; the value is then an array.
disabledbooleanfalseDisables the trigger. It leaves the tab order, so say why in the line under it.
SelectTrigger.size"sm" | "default""default"32px tall, or 28px for a toolbar.
SelectValue.placeholderReact.ReactNode—Shown in the muted tier while nothing is chosen.
SelectValue.children(value) => React.ReactNode—Draws the chosen value your way — a face, an icon, a badge, a list of several — instead of its label.
SelectContent.alignItemWithTriggerbooleantrueOpens the list over the trigger with the chosen option in its place; false drops it below.
SelectItem.disabledbooleanfalseDims the option and refuses it; the keyboard still lands on it, so a reason in its words is heard.
SelectItem.labelstring—The text typing matches against when the option holds more than its name.

Dependencies

Source

components/ui/select.tsx
"use client"

import * as React from "react"
import { Select as SelectPrimitive } from "@base-ui/react/select"
import { cn } from "@/lib/utils"
import { ChevronDownIcon, CheckIcon, ChevronUpIcon } from "lucide-react"

const Select = SelectPrimitive.Root

function SelectGroup({ className, ...props }: SelectPrimitive.Group.Props) {
  return (
    <SelectPrimitive.Group
      data-slot="select-group"
      className={cn("scroll-my-1 p-1", className)}
      {...props}
    />
  )
}

function SelectValue({ className, ...props }: SelectPrimitive.Value.Props) {
  return (
    <SelectPrimitive.Value
      data-slot="select-value"
      className={cn("flex flex-1 text-start", className)}
      {...props}
    />
  )
}

function SelectTrigger({
  className,
  size = "default",
  children,
  ...props
}: SelectPrimitive.Trigger.Props & {
  size?: "sm" | "default"
}) {
  return (
    <SelectPrimitive.Trigger
      data-slot="select-trigger"
      data-size={size}
      className={cn(
        "flex w-fit items-center justify-between gap-1.5 rounded-lg border border-input bg-card py-2 ps-2.5 pe-2 text-sm whitespace-nowrap transition-colors select-none focus-ring disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 data-placeholder:text-muted-foreground data-[size=default]:h-8 data-[size=sm]:h-7 data-[size=sm]:rounded-[min(var(--radius-md),10px)] *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-1.5 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      {children}
      <SelectPrimitive.Icon
        render={
          <ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground" />
        }
      />
    </SelectPrimitive.Trigger>
  )
}

function SelectContent({
  className,
  children,
  side = "bottom",
  sideOffset = 4,
  align = "center",
  alignOffset = 0,
  alignItemWithTrigger = true,
  ...props
}: SelectPrimitive.Popup.Props &
  Pick<
    SelectPrimitive.Positioner.Props,
    "align" | "alignOffset" | "side" | "sideOffset" | "alignItemWithTrigger"
  >) {
  return (
    <SelectPrimitive.Portal>
      <SelectPrimitive.Positioner
        side={side}
        sideOffset={sideOffset}
        align={align}
        alignOffset={alignOffset}
        alignItemWithTrigger={alignItemWithTrigger}
        className="isolate z-50"
      >
        <SelectPrimitive.Popup
          data-slot="select-content"
          data-align-trigger={alignItemWithTrigger}
          className={cn("relative isolate z-50 flex flex-col not-data-[side=none]:max-h-(--available-height) w-(--anchor-width) min-w-36 origin-(--transform-origin) overflow-hidden elev-1 text-popover-foreground data-[align-trigger=true]:animate-none data-[align-trigger=true]:transition-opacity data-[align-trigger=true]:duration-(--duration-base) data-[align-trigger=true]:ease-(--ease-standard) data-[align-trigger=true]:data-starting-style:opacity-0 data-[side=bottom]:slide-in-from-top-1 data-[side=inline-end]:slide-in-from-left-1 data-[side=inline-start]:slide-in-from-right-1 data-[side=left]:slide-in-from-right-1 data-[side=right]:slide-in-from-left-1 data-[side=top]:slide-in-from-bottom-1 data-open:animate-in data-open:fade-in-0 data-open:duration-(--duration-base) data-open:ease-(--ease-standard) data-closed:animate-out data-closed:fade-out-0 data-closed:duration-(--duration-fast) data-closed:ease-(--ease-exit)", className )}
          {...props}
        >
          <SelectScrollUpButton />
          {/* The list is what scrolls, in both modes: Base UI reads the list
              to show the arrows and scrolls it when one is hovered. Opened
              over the trigger it sizes the list itself; dropped below it, the
              popup stops at the room there is and the list shrinks to fit. */}
          <SelectPrimitive.List className="min-h-0 overflow-x-hidden overflow-y-auto">{children}</SelectPrimitive.List>
          <SelectScrollDownButton />
        </SelectPrimitive.Popup>
      </SelectPrimitive.Positioner>
    </SelectPrimitive.Portal>
  )
}

function SelectLabel({
  className,
  ...props
}: SelectPrimitive.GroupLabel.Props) {
  return (
    <SelectPrimitive.GroupLabel
      data-slot="select-label"
      className={cn("px-1.5 py-1 text-xs text-muted-foreground", className)}
      {...props}
    />
  )
}

function SelectItem({
  className,
  children,
  ...props
}: SelectPrimitive.Item.Props) {
  return (
    <SelectPrimitive.Item
      data-slot="select-item"
      className={cn(
        "relative flex w-full cursor-default items-center gap-1.5 rounded-md py-1 ps-1.5 pe-8 text-label outline-hidden select-none focus:bg-accent focus:text-accent-foreground not-data-[variant=destructive]:focus:**:text-accent-foreground data-disabled:pointer-events-none data-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
        className
      )}
      {...props}
    >
      <SelectPrimitive.ItemText className="flex flex-1 shrink-0 gap-2 whitespace-nowrap">
        {children}
      </SelectPrimitive.ItemText>
      <SelectPrimitive.ItemIndicator
        render={
          <span className="pointer-events-none absolute end-2 flex size-4 items-center justify-center" />
        }
      >
        <CheckIcon className="pointer-events-none" />
      </SelectPrimitive.ItemIndicator>
    </SelectPrimitive.Item>
  )
}

function SelectSeparator({
  className,
  ...props
}: SelectPrimitive.Separator.Props) {
  return (
    <SelectPrimitive.Separator
      data-slot="select-separator"
      className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)}
      {...props}
    />
  )
}

function SelectScrollUpButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpArrow>) {
  return (
    <SelectPrimitive.ScrollUpArrow
      data-slot="select-scroll-up-button"
      className={cn(
        "top-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <ChevronUpIcon
      />
    </SelectPrimitive.ScrollUpArrow>
  )
}

function SelectScrollDownButton({
  className,
  ...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownArrow>) {
  return (
    <SelectPrimitive.ScrollDownArrow
      data-slot="select-scroll-down-button"
      className={cn(
        "bottom-0 z-10 flex w-full cursor-default items-center justify-center bg-popover py-1 [&_svg:not([class*='size-'])]:size-4",
        className
      )}
      {...props}
    >
      <ChevronDownIcon
      />
    </SelectPrimitive.ScrollDownArrow>
  )
}

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