Component
Select
Single-choice select with field, compact, ghost and chip treatments.
Text referenceSource and examples as JSON
npx -y github:anwarlemu/opencommerce-brand-cli#release add select --surface siteRun from the target React 19.2+ app root with Tailwind CSS 4 and the @/* source alias.
Usage
Use the field appearance inside Field for forms. Use compact in dense rows and ghost or chip for filters. Field required is inherited by Select. Give Select a name for FormData; keep value and onValueChange on the root. Use compact on SelectContent for compact fields and toolbar menus.
- Pill-shaped triggers
- Empty string item values
- Long lists without a search (use a command)
Exports: Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue. This component includes a client boundary. Keep event handlers and state in a client component.
value from the form's onReset handler. Field displays your current error; validation and submitting to a backend belong to the form. A disabled item cannot be chosen. Prefer a segmented control when a small set of choices should remain visible.Dependencies: surface, field, @radix-ui/react-select, lucide-react, class-variance-authority.
Example
"use client";
import * as React from "react";
import { Button } from "@/components/ui/button";
import { Field } from "@/components/ui/field";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export function Site() {
return (
<div className="grid max-w-170 gap-4 sm:grid-cols-2">
<Field label="Stage" htmlFor="select-stage">
<Select>
<SelectTrigger id="select-stage">
<SelectValue placeholder="Choose one" />
</SelectTrigger>
<SelectContent>
<SelectItem value="pre">Pre-launch</SelectItem>
<SelectItem value="launched">Launched</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Money raised" htmlFor="select-raised">
<Select defaultValue="seed">
<SelectTrigger id="select-raised">
<SelectValue placeholder="Choose one" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Bootstrapped</SelectItem>
<SelectItem value="seed">Under $1M</SelectItem>
<SelectItem value="a">$1M to $5M</SelectItem>
<SelectItem value="b">$5M to $20M</SelectItem>
<SelectItem value="c">More than $20M</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
);
}
export function Controlled() {
const [value, setValue] = React.useState("a");
return (
<div className="h-80 max-w-83">
<Field label="Money raised" htmlFor="select-open">
<Select value={value} onValueChange={setValue}>
<SelectTrigger id="select-open">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">Bootstrapped</SelectItem>
<SelectItem value="seed">Under $1M</SelectItem>
<SelectItem value="a">$1M to $5M</SelectItem>
<SelectItem value="b">$5M to $20M</SelectItem>
</SelectContent>
</Select>
</Field>
</div>
);
}
export function Toolbar() {
return (
<div className="flex flex-wrap items-center gap-3">
<Select defaultValue="30">
<SelectTrigger appearance="ghost" aria-label="Period">
<SelectValue />
</SelectTrigger>
<SelectContent compact>
<SelectItem value="7">Last 7 days</SelectItem>
<SelectItem value="30">Last 30 days</SelectItem>
<SelectItem value="90">Last 90 days</SelectItem>
</SelectContent>
</Select>
<Select defaultValue="all">
<SelectTrigger appearance="chip" aria-label="Card">
<SelectValue />
</SelectTrigger>
<SelectContent compact>
<SelectItem value="all">All cards</SelectItem>
<SelectItem value="travel">Travel agent</SelectItem>
<SelectItem value="ops">Ops agent</SelectItem>
</SelectContent>
</Select>
<div className="w-56">
<Select defaultValue="usd">
<SelectTrigger compact aria-label="Currency">
<SelectValue />
</SelectTrigger>
<SelectContent compact>
<SelectItem value="usd">US dollar</SelectItem>
<SelectItem value="eur">Euro</SelectItem>
</SelectContent>
</Select>
</div>
</div>
);
}
export function Form() {
const [value, setValue] = React.useState("");
const [error, setError] = React.useState("");
const [result, setResult] = React.useState("");
return (
<form className="grid min-w-0 max-w-83 gap-5" noValidate onSubmit={event => {
event.preventDefault();
if (!value) { setError("Choose a card before continuing."); setResult(""); return; }
setError("");
setResult(`Selected: ${new FormData(event.currentTarget).get("card")}.`);
}} onReset={() => { setValue(""); setError(""); setResult(""); }}>
<Field label="Card" required error={error} help="Choose the card this agent can use.">
<Select name="card" value={value} onValueChange={next => { setValue(next); setError(""); setResult(""); }}>
<SelectTrigger><SelectValue placeholder="Choose a card" /></SelectTrigger>
<SelectContent>
<SelectItem value="travel" description="Flights and hotels">Travel agent</SelectItem>
<SelectItem value="operations">Operations and recurring software subscriptions</SelectItem>
<SelectItem value="archived" disabled>Archived card (unavailable)</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Billing currency" help="The currency is set when the card is created.">
<Select defaultValue="usd" disabled>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent><SelectItem value="usd">US dollar</SelectItem></SelectContent>
</Select>
</Field>
<div className="flex gap-3"><Button type="submit">Continue</Button><Button type="reset" variant="secondary">Reset</Button></div>
<p role="status" className="text-body-sm text-fg-muted empty:hidden">{result}</p>
</form>
);
}Source
"use client";
import * as React from "react";
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown, ChevronUp } from "lucide-react";
import { cva, type VariantProps } from "class-variance-authority";
import { usePortalContainer } from "@/lib/surface";
import { useFieldControl } from "@/components/ui/field";
import { cn } from "@/lib/utils";
// A single choice. `field` is the brand's field (the input's 36px box with 8px corners on the 16 percent ink edge, 14px
// Inter) with a drawn chevron 12px in from the right; focus and an open list turn the edge blue, with no glow. `compact`
// is the same at 32px for dense rows. `ghost` is the white key for a toolbar, `chip` a quiet label on a wash. The list is
// the brand's floating panel (white, a 9 percent ink edge, 8px corners) growing from its trigger in a quick ease-out;
// the row under the pointer or the keys takes the pale blue, the chosen one a blue tick.
const triggerVariants = cva(
"flex min-w-0 max-w-full items-center gap-2 whitespace-nowrap outline-none transition-[background-color,border-color,color,scale] duration-160 ease-out disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none [&_svg]:shrink-0 [&>[data-slot=select-value]]:min-w-0 [&>[data-slot=select-value]]:truncate [&>[data-slot=select-value]]:text-left",
{
variants: {
appearance: {
field:
"w-full gap-3 rounded-control border border-hairline bg-surface font-sans text-fg hover:border-line-field-hover focus-visible:border-line-brand data-[state=open]:border-line-brand data-placeholder:text-fg-faint aria-invalid:border-danger [&>span]:flex-1 [&>span]:truncate [&>span]:text-left",
ghost:
"h-8 rounded-control border border-hairline bg-surface px-3 font-sans text-body-sm text-fg hover:bg-subtle focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring active:scale-97 data-[state=open]:bg-subtle motion-reduce:active:scale-100",
chip: "h-7 rounded-sm bg-wash-pill px-2.5 font-sans text-caption text-fg-muted hover:text-fg focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-solid focus-visible:outline-focus-ring data-[state=open]:text-fg",
},
// A shorter field, for dense rows.
compact: { true: "", false: "" },
},
compoundVariants: [
{ appearance: "field", compact: false, className: "h-9 px-3 text-body-sm" },
{ appearance: "field", compact: true, className: "h-8 px-2.5 text-body-sm" },
],
defaultVariants: { appearance: "field", compact: false },
},
);
/** The select's mark: a small open chevron, drawn in a 1.5px line. */
function SelectMark({ className }: { className?: string }) {
return (
<svg aria-hidden viewBox="0 0 10 6" className={cn("h-1.5 w-2.5 fill-none stroke-current [stroke-linecap:round] [stroke-linejoin:round] [stroke-width:1.5]", className)}>
<path d="M1 1l4 4 4-4" />
</svg>
);
}
function Select(props: React.ComponentProps<typeof SelectPrimitive.Root>) {
const { required } = useFieldControl({ required: props.required });
return <SelectPrimitive.Root data-slot="select" {...props} required={required} />;
}
function SelectGroup(props: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
}
function SelectValue(props: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
}
function SelectTrigger({ className, appearance, compact, children, ...props }: React.ComponentProps<typeof SelectPrimitive.Trigger> & VariantProps<typeof triggerVariants>) {
const { required, ...control } = useFieldControl(props);
return (
<SelectPrimitive.Trigger data-slot="select-trigger" className={cn(triggerVariants({ appearance, compact }), className)} aria-required={required || undefined} {...control}>
{children}
<SelectPrimitive.Icon asChild>
<SelectMark className={appearance === "field" || appearance == null ? "ml-auto text-fg-muted" : "text-fg-muted"} />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
function SelectContent({ className, children, compact = false, position = "popper", collisionPadding = 8, ...props }: React.ComponentProps<typeof SelectPrimitive.Content> & { compact?: boolean }) {
return (
<SelectPrimitive.Portal container={usePortalContainer()}>
<SelectPrimitive.Content
data-slot="select-content"
data-compact={compact || undefined}
position={position}
collisionPadding={collisionPadding}
sideOffset={4}
align="start"
className={cn(
"relative z-50 max-h-[min(20rem,var(--radix-select-content-available-height))] min-w-0 max-w-(--radix-select-content-available-width) origin-(--radix-select-content-transform-origin) animate-pop-in motion-reduce:animate-none overflow-hidden rounded-control border border-line bg-surface p-1 font-sans text-body-sm leading-snug text-fg shadow-floating",
position === "popper" && "w-[max(11rem,var(--radix-select-trigger-width))]",
className,
)}
{...props}
>
<SelectPrimitive.ScrollUpButton className="flex h-6 items-center justify-center text-fg-muted">
<ChevronUp className="size-3.5" strokeWidth={1.5} />
</SelectPrimitive.ScrollUpButton>
<SelectPrimitive.Viewport className="flex w-full flex-col gap-0.5">{children}</SelectPrimitive.Viewport>
<SelectPrimitive.ScrollDownButton className="flex h-6 items-center justify-center text-fg-muted">
<ChevronDown className="size-3.5" strokeWidth={1.5} />
</SelectPrimitive.ScrollDownButton>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
function SelectLabel({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Label>) {
return <SelectPrimitive.Label data-slot="select-label" className={cn("px-2.5 pt-1.5 pb-1 font-sans text-caption text-fg-muted", className)} {...props} />;
}
function SelectItem({ className, children, description, ...props }: React.ComponentProps<typeof SelectPrimitive.Item> & { description?: React.ReactNode }) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-start gap-2 rounded-menu py-2 pr-8 pl-2.5 break-words text-fg-muted outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-40 data-[highlighted]:bg-search-selected data-[highlighted]:text-fg data-[state=checked]:text-fg",
className,
)}
{...props}
>
<span className="min-w-0 flex-1">
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
{description && <span className="mt-0.5 block font-sans text-caption text-fg-muted">{description}</span>}
</span>
<SelectPrimitive.ItemIndicator className="absolute top-1/2 right-2.5 -translate-y-1/2 text-fg-brand">
<Check className="size-3.5" strokeWidth={1.75} />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
function SelectSeparator({ className, ...props }: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return <SelectPrimitive.Separator data-slot="select-separator" className={cn("-mx-1 my-1 h-px bg-line", className)} {...props} />;
}
export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue };Reference: Extension from opencommerce-agentcard-web src/components/monitoring.tsx (#merchant-filter).