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/selectNeeds the @vibra registry in your components.json — set it up once.
Examples
"use client"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PERIODS = [
{ value: "7d", label: "Last 7 days" },
{ value: "30d", label: "Last 30 days" },
{ value: "90d", label: "Last 90 days" },
{ value: "12m", label: "Last 12 months" },
]
export default function SelectDemo() {
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id="report-period">Report period</Label>
<Select items={PERIODS} defaultValue="30d">
<SelectTrigger aria-labelledby="report-period" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERIODS.map((period) => (
<SelectItem key={period.value} value={period.value}>
{period.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}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.
"use client"
import * as React from "react"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const GROUPINGS = [
{ value: "day", label: "Day" },
{ value: "week", label: "Week" },
{ value: "month", label: "Month" },
]
const SORTS = [
{ value: "newest", label: "Newest first" },
{ value: "oldest", label: "Oldest first" },
{ value: "total", label: "Largest total" },
]
const label = (items: { value: string; label: string }[], value: string) =>
items.find((item) => item.value === value)?.label.toLowerCase()
// Two small selects in a table's toolbar: 28px tall to sit beside the search
// and the filters, only as wide as what they say, and dropping below the
// trigger rather than over it, so the reader keeps the label in view.
export default function SelectToolbar() {
const id = React.useId()
const [grouping, setGrouping] = React.useState("week")
const [sort, setSort] = React.useState("newest")
return (
<div className="flex w-full max-w-md flex-col gap-3">
<div role="group" aria-label="Orders view" className="flex flex-wrap items-center gap-x-4 gap-y-2">
<div className="flex items-center gap-2">
<span id={`${id}-grouping`} className="text-xs text-muted-foreground">
Group by
</span>
<Select items={GROUPINGS} value={grouping} onValueChange={(next) => next && setGrouping(next)}>
<SelectTrigger size="sm" aria-labelledby={`${id}-grouping`}>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} align="start">
{GROUPINGS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex items-center gap-2">
<span id={`${id}-sort`} className="text-xs text-muted-foreground">
Sort
</span>
<Select items={SORTS} value={sort} onValueChange={(next) => next && setSort(next)}>
<SelectTrigger size="sm" aria-labelledby={`${id}-sort`}>
<SelectValue />
</SelectTrigger>
<SelectContent alignItemWithTrigger={false} align="start">
{SORTS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<p role="status" aria-live="polite" className="text-sm text-muted-foreground">
<span className="tabular-nums">1,284</span> orders by {label(GROUPINGS, grouping)}, {label(SORTS, sort)}.
</p>
</div>
)
}With icons
Each option leads with its picture and the trigger repeats the chosen one; the words carry the choice.
"use client"
import * as React from "react"
import { ChartAreaIcon, ChartColumnIcon, ChartLineIcon, TableIcon } from "lucide-react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const VIEWS = [
{ value: "line", label: "Line chart", icon: ChartLineIcon },
{ value: "bar", label: "Bar chart", icon: ChartColumnIcon },
{ value: "area", label: "Area chart", icon: ChartAreaIcon },
{ value: "table", label: "Table", icon: TableIcon },
]
// How a widget draws its numbers. Each option leads with its picture, and the
// trigger repeats the chosen one; the words carry the choice, so every icon
// is hidden from a screen reader.
export default function SelectIcons() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>Show revenue as</Label>
<Select items={VIEWS} defaultValue="bar">
<SelectTrigger aria-labelledby={id} className="w-full">
<SelectValue>
{(value: string) => {
const view = VIEWS.find((item) => item.value === value)
if (!view) return null
return (
<>
<view.icon aria-hidden="true" className="text-muted-foreground" />
{view.label}
</>
)
}}
</SelectValue>
</SelectTrigger>
<SelectContent>
{VIEWS.map((view) => (
<SelectItem key={view.value} value={view.value}>
<view.icon aria-hidden="true" className="text-muted-foreground" />
{view.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Grouped
Regions under their continents, with the region code at each option's end in the code face.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
const CONTINENTS = [
{
name: "Europe",
regions: [
{ value: "eu-central-1", label: "Frankfurt" },
{ value: "eu-west-1", label: "Dublin" },
{ value: "eu-north-1", label: "Stockholm" },
],
},
{
name: "North America",
regions: [
{ value: "us-east-1", label: "N. Virginia" },
{ value: "us-west-2", label: "Oregon" },
{ value: "ca-central-1", label: "Montréal" },
],
},
{
name: "Asia Pacific",
regions: [
{ value: "ap-southeast-1", label: "Singapore" },
{ value: "ap-southeast-2", label: "Sydney" },
],
},
]
const REGIONS = CONTINENTS.flatMap((continent) => continent.regions)
// Where a new workspace keeps its data, grouped by continent. The city names
// the option; the region code sits at its end in the code face, for the
// reader who knows their infrastructure by it.
export default function SelectGroups() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>Data region</Label>
<Select items={REGIONS} defaultValue="eu-central-1">
<SelectTrigger aria-labelledby={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{CONTINENTS.map((continent, index) => (
<React.Fragment key={continent.name}>
{index > 0 ? <SelectSeparator /> : null}
<SelectGroup>
<SelectLabel>{continent.name}</SelectLabel>
{continent.regions.map((region) => (
<SelectItem key={region.value} value={region.value}>
{region.label}{" "}
<span className="ms-auto font-mono text-xs text-muted-foreground">{region.value}</span>
</SelectItem>
))}
</SelectGroup>
</React.Fragment>
))}
</SelectContent>
</Select>
</div>
)
}With descriptions
A second line under each plan while the list is open; the trigger keeps to the plan's name.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PLANS = [
{ value: "free", label: "Free", price: "$0", detail: "3 seats and 1 GB of storage" },
{ value: "starter", label: "Starter", price: "$19 a month", detail: "10 seats and email support" },
{ value: "team", label: "Team", price: "$49 a month", detail: "30 seats and custom roles" },
{ value: "enterprise", label: "Enterprise", price: "$149 a month", detail: "Unlimited seats, SSO and audit logs" },
]
// Each plan says what it costs and what it holds while the list is open; the
// trigger keeps to the plan's name, so the field stays one line wide.
export default function SelectDescriptions() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>Plan</Label>
<Select items={PLANS} defaultValue="team">
<SelectTrigger aria-labelledby={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLANS.map((plan) => (
<SelectItem key={plan.value} value={plan.value} label={plan.label} className="py-1.5">
<span className="flex min-w-0 flex-col gap-0.5">
<span className="flex items-baseline gap-2">
<span className="font-medium">{plan.label}</span>{" "}
<span className="text-xs text-muted-foreground tabular-nums">{plan.price}</span>
</span>{" "}
<span className="text-xs text-muted-foreground">{plan.detail}</span>
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Several at once
multiple keeps the list open while the reader ticks, and the trigger names the choices rather than counting them.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const CHANNELS = [
{ value: "email", label: "Email" },
{ value: "in-app", label: "In-app" },
{ value: "sms", label: "SMS" },
{ value: "webhook", label: "Webhook" },
]
/** "Email", "Email and SMS", "Email, SMS and Webhook" — never a bare count. */
function listOf(values: string[]) {
const labels = CHANNELS.filter((channel) => values.includes(channel.value)).map((channel) => channel.label)
if (labels.length <= 1) return labels[0] ?? ""
return `${labels.slice(0, -1).join(", ")} and ${labels[labels.length - 1]}`
}
// `multiple` keeps the list open while the reader ticks what they want. The
// trigger names the choices rather than counting them, and the line under it
// says what happens when none is ticked.
export default function SelectMultiple() {
const id = React.useId()
const [channels, setChannels] = React.useState<string[]>(["email", "in-app"])
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={`${id}-label`}>Send failed-payment alerts by</Label>
<Select items={CHANNELS} multiple value={channels} onValueChange={setChannels}>
<SelectTrigger aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} className="w-full">
{/* A render function replaces the placeholder, so it says "No channel" itself. */}
<SelectValue>{(values: string[]) => (values.length > 0 ? listOf(values) : "No channel")}</SelectValue>
</SelectTrigger>
<SelectContent>
{CHANNELS.map((channel) => (
<SelectItem key={channel.value} value={channel.value}>
{channel.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
{channels.length > 0 ? "Each alert goes out once per channel." : "With none ticked, alerts wait in the inbox."}
</p>
</div>
)
}Long list
Thirty-nine countries: the list scrolls with arrows at its ends, and typing a name jumps to it.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const COUNTRIES = [
"Argentina", "Australia", "Austria", "Belgium", "Brazil", "Canada", "Chile", "Czechia", "Denmark",
"Estonia", "Finland", "France", "Germany", "Greece", "Hungary", "Iceland", "India", "Ireland",
"Israel", "Italy", "Japan", "Latvia", "Lithuania", "Luxembourg", "Mexico", "Netherlands",
"New Zealand", "Norway", "Poland", "Portugal", "Singapore", "Slovakia", "South Africa",
"South Korea", "Spain", "Sweden", "Switzerland", "United Kingdom", "United States",
].map((country) => ({ value: country, label: country }))
// Thirty-nine countries: the list opens over the field with the chosen one
// in its place, stops at the screen's edge and scrolls, with arrows at
// either end; typing a name jumps to it — the hint says so, since nothing
// else on the field does.
export default function SelectLongList() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={`${id}-label`}>Billing country</Label>
<Select items={COUNTRIES} defaultValue="Ireland">
<SelectTrigger aria-labelledby={`${id}-label`} aria-describedby={`${id}-hint`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{COUNTRIES.map((country) => (
<SelectItem key={country.value} value={country.value}>
{country.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p id={`${id}-hint`} className="text-xs text-muted-foreground">
Sets the VAT on each invoice. With the list open, type a country to jump to it.
</p>
</div>
)
}Required, with an error
Sending without a choice marks the trigger invalid, points it at the message and moves the focus to it.
"use client"
import * as React from "react"
import { Button } from "@/components/ui/button"
import { Field, FieldDescription, FieldError, FieldLabel } from "@/components/ui/field"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const COST_CENTRES = [
{ value: "1040", label: "1040 · Sales" },
{ value: "2210", label: "2210 · Marketing" },
{ value: "3100", label: "3100 · Engineering" },
{ value: "4400", label: "4400 · Customer success" },
]
// A required select in a form. It starts on its placeholder; sending without
// a choice marks it invalid, points it at the message and moves the focus to
// it, so the message is read with the field rather than as a second alert.
export default function SelectInvalid() {
const id = React.useId()
const trigger = React.useRef<HTMLButtonElement>(null)
const [value, setValue] = React.useState<string | null>(null)
const [tried, setTried] = React.useState(false)
const invalid = tried && value === null
return (
<form
noValidate
className="flex w-full max-w-xs flex-col gap-4"
onSubmit={(event) => {
event.preventDefault()
setTried(true)
if (value === null) trigger.current?.focus()
}}
>
<Field data-invalid={invalid || undefined}>
<FieldLabel id={`${id}-label`}>Cost centre</FieldLabel>
<Select items={COST_CENTRES} value={value} onValueChange={setValue}>
<SelectTrigger
ref={trigger}
aria-labelledby={`${id}-label`}
aria-describedby={invalid ? `${id}-error ${id}-hint` : `${id}-hint`}
aria-invalid={invalid || undefined}
aria-required
className="w-full"
>
<SelectValue placeholder="Choose a cost centre" />
</SelectTrigger>
<SelectContent>
{COST_CENTRES.map((centre) => (
<SelectItem key={centre.value} value={centre.value}>
{centre.label}
</SelectItem>
))}
</SelectContent>
</Select>
{invalid ? (
<FieldError id={`${id}-error`} role={undefined}>
Choose the cost centre this card is charged to.
</FieldError>
) : null}
<FieldDescription id={`${id}-hint`}>Every purchase on the new card is booked to it.</FieldDescription>
</Field>
<div className="flex items-center justify-between gap-3">
<p role="status" aria-live="polite" className="text-xs text-muted-foreground">
{tried && value !== null ? "Card requested for finance approval." : null}
</p>
<Button type="submit" size="sm">
Request card
</Button>
</div>
</form>
)
}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.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const SEATS_IN_USE = 14
const PLANS = [
{ value: "free", label: "Free", seats: 3 },
{ value: "starter", label: "Starter", seats: 10 },
{ value: "team", label: "Team", seats: 25 },
{ value: "enterprise", label: "Enterprise", seats: 100 },
]
const CURRENCIES = [{ value: "EUR", label: "Euro (EUR)" }]
// Two ways a choice is off the table, each saying why. A plan too small for
// the workspace stays in the list, dimmed, with the reason in its words — a
// keyboard still lands on it and hears them. A setting that can no longer
// change is disabled whole, and the line under it gives the reason.
export default function SelectDisabled() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-5">
<div className="flex flex-col gap-2">
<Label id={`${id}-plan`}>Change plan</Label>
<Select items={PLANS} defaultValue="team">
<SelectTrigger aria-labelledby={`${id}-plan`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{PLANS.map((plan) => {
const tooSmall = plan.seats < SEATS_IN_USE
return (
<SelectItem
key={plan.value}
value={plan.value}
disabled={tooSmall}
label={plan.label}
// The name dims like any disabled control; the reason stays
// at full strength, since it is what the reader came for.
className="data-disabled:opacity-100"
>
<span className={tooSmall ? "opacity-50" : undefined}>{plan.label}</span>
{tooSmall ? (
<>
{" "}
<span className="text-xs text-muted-foreground">
— holds {plan.seats} seats, you use {SEATS_IN_USE}
</span>
</>
) : null}
</SelectItem>
)
})}
</SelectContent>
</Select>
</div>
<div className="flex flex-col gap-2">
<Label id={`${id}-currency`}>Billing currency</Label>
<Select items={CURRENCIES} defaultValue="EUR" disabled>
<SelectTrigger aria-labelledby={`${id}-currency`} aria-describedby={`${id}-why`} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EUR">Euro (EUR)</SelectItem>
</SelectContent>
</Select>
<p id={`${id}-why`} className="text-xs text-muted-foreground">
Fixed once the first invoice went out. Support can move you to a new currency.
</p>
</div>
</div>
)
}With faces
Teammates with their face and role in the list, and the face beside the name once chosen.
"use client"
import * as React from "react"
import { UserRoundIcon } from "lucide-react"
import { avatarFor } from "@/lib/avatars"
import { getInitials } from "@/lib/format"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PEOPLE = [
{ value: "hiro", label: "Hiro Tanaka", role: "Support lead" },
{ value: "saoirse", label: "Saoirse Byrne", role: "Account manager" },
{ value: "uma", label: "Uma Raghavan", role: "Solutions engineer" },
{ value: "femi", label: "Femi Adebayo", role: "Billing specialist" },
]
const ITEMS = [{ value: "none", label: "Unassigned" }, ...PEOPLE]
/** The face for a teammate, or an empty outline for nobody. Hidden: the name beside it says who. */
function Face({ value }: { value: string }) {
const person = PEOPLE.find((candidate) => candidate.value === value)
if (!person) return <UserRoundIcon aria-hidden="true" className="size-6 rounded-full border border-dashed border-input p-1 text-muted-foreground" />
return (
<Avatar size="sm" aria-hidden="true">
<AvatarImage src={avatarFor(person.label)} alt="" />
<AvatarFallback>{getInitials(person.label)}</AvatarFallback>
</Avatar>
)
}
// Who a ticket goes to: each teammate with their face and role while the list
// is open, and the face beside the name once chosen.
export default function SelectPeople() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>Assignee</Label>
<Select items={ITEMS} defaultValue="saoirse">
<SelectTrigger aria-labelledby={id} className="h-9 w-full">
<SelectValue>
{(value: string) => (
<>
<Face value={value} />
{ITEMS.find((item) => item.value === value)?.label}
</>
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value} label={item.label}>
<Face value={item.value} />
<span className="flex min-w-0 flex-col">
<span>{item.label}</span>{" "}
{"role" in item ? <span className="text-xs text-muted-foreground">{item.role}</span> : null}
</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Priority
The kit's PriorityBadge in the list and the trigger, each level saying what it promises the customer.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { PriorityBadge, PRIORITY_LABELS, type Priority } from "@/components/ui/priority-badge"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const LEVELS: { value: Priority; reply: string }[] = [
{ value: "urgent", reply: "First reply within 1 hour" },
{ value: "high", reply: "Within 4 hours" },
{ value: "medium", reply: "Within 1 business day" },
{ value: "low", reply: "Within 3 business days" },
]
const ITEMS = LEVELS.map((level) => ({ value: level.value, label: PRIORITY_LABELS[level.value] }))
// A ticket's priority drawn with the kit's own badge — tone, arrow and word —
// in the list and in the trigger, and each level says what it promises the
// customer, since that is what the agent is really choosing.
export default function SelectPriority() {
const id = React.useId()
return (
<div className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>Priority</Label>
<Select items={ITEMS} defaultValue="high">
<SelectTrigger aria-labelledby={id} className="w-full">
<SelectValue>{(value: Priority) => <PriorityBadge priority={value} />}</SelectValue>
</SelectTrigger>
<SelectContent>
{LEVELS.map((level) => (
<SelectItem key={level.value} value={level.value} label={PRIORITY_LABELS[level.value]} className="py-1.5">
<PriorityBadge priority={level.value} />{" "}
<span className="text-xs text-muted-foreground">{level.reply}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Right to left
The demo's period in Arabic: the value starts at the right, the chevron and the check keep the inline end.
"use client"
import * as React from "react"
import { Label } from "@/components/ui/label"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
const PERIODS = [
{ value: "7d", label: "آخر 7 أيام" },
{ value: "30d", label: "آخر 30 يومًا" },
{ value: "90d", label: "آخر 90 يومًا" },
{ value: "12m", label: "آخر 12 شهرًا" },
]
// The demo's report period in Arabic. The value starts at the right and the
// chevron keeps the inline end, on the left; the list is portalled out of
// the page, so it carries the direction and the language itself.
export default function SelectRtl() {
const id = React.useId()
return (
<div dir="rtl" lang="ar" className="flex w-full max-w-xs flex-col gap-2">
<Label id={id}>فترة التقرير</Label>
<Select items={PERIODS} defaultValue="30d">
<SelectTrigger aria-labelledby={id} className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent dir="rtl" lang="ar">
{PERIODS.map((period) => (
<SelectItem key={period.value} value={period.value}>
{period.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
| 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. |
| multiple | boolean | false | Keeps the list open while the reader ticks several; the value is then an array. |
| disabled | boolean | false | Disables 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.placeholder | React.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.alignItemWithTrigger | boolean | true | Opens the list over the trigger with the chosen option in its place; false drops it below. |
| SelectItem.disabled | boolean | false | Dims the option and refuses it; the keyboard still lands on it, so a reason in its words is heard. |
| SelectItem.label | string | — | The text typing matches against when the option holds more than its name. |
Dependencies
Registry
Source
"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,
}