Components
ComponentsTier 4 · Composites
Select
A styleable listbox, plus NativeSelect for long lists on touch.
bunx --bun shadcn@latest add @woohl/selectPreview
Reference
Installs this item, its registry dependencies and the token layer.
bunx --bun shadcn@latest add @woohl/selectregistry/tier-4-composites/select.tsx
"use client";
import type { ComponentProps, ReactNode } from "react";
import { Select as SelectPrimitive } from "radix-ui";
import { cn } from "@/registry/tier-2-primitives/lib/utils";
import {
cva,
disabledState,
focusField,
type VariantProps,
} from "@/registry/tier-2-primitives/lib/cva";
import {
CheckGlyph,
ChevronDownGlyph,
} from "@/registry/tier-2-primitives/glyphs";
/**
* Select.
*
* Radix Select — a listbox the design system can actually style, which a native
* `<select>` popup never was. It brings typeahead, arrow-key navigation that
* wraps, collision-aware positioning, and a portal so the list isn't clipped by
* an `overflow: hidden` ancestor.
*
* WHAT THIS COST, AND WHEN TO TAKE IT BACK
* A native `<select>` opens the OS picker on a phone — the wheel on iOS, the
* sheet on Android — and that is genuinely better than anything rendered in
* the page. This is not that. For a long list on a mobile-first surface (a
* state picker in buyer checkout), a plain `<select>` is still the right
* control; reach for `NativeSelect` below rather than bending this one.
*
* Radix needs a real value for every item, so unlike the old native version
* there is no empty-string option: `placeholder` renders through
* `SelectValue`, which shows nothing selected until something is.
*/
const selectVariants = cva(
cn(
"flex w-full items-center justify-between gap-2 rounded-md border bg-surface-raised",
"border-border-strong font-ui text-body text-text-primary",
"transition-[border-color,box-shadow] motion-instant",
"data-[placeholder]:text-text-muted",
focusField,
disabledState,
),
{
variants: {
size: {
sm: "h-[var(--control-h-sm)] px-3 text-label",
md: "h-[var(--control-h-md)] px-3.5",
lg: "h-[var(--control-h-lg)] px-4",
},
invalid: { true: "border-danger", false: "" },
},
defaultVariants: { size: "md", invalid: false },
},
);
export type SelectOption =
string | { value: string; label: string; disabled?: boolean };
export type SelectProps = Omit<
ComponentProps<typeof SelectPrimitive.Root>,
"children"
> &
VariantProps<typeof selectVariants> & {
/**
* Convenience for the common case. Omit and pass composition children when
* you need groups or per-item content.
*/
options?: readonly SelectOption[];
placeholder?: string;
className?: string;
wrapperClassName?: string;
children?: ReactNode;
id?: string;
"aria-invalid"?: boolean | "true" | "false";
"aria-describedby"?: string;
};
export function Select({
size,
options,
placeholder,
className,
wrapperClassName,
children,
id,
"aria-invalid": ariaInvalid,
"aria-describedby": ariaDescribedBy,
...props
}: SelectProps) {
const invalid = ariaInvalid === true || ariaInvalid === "true";
return (
<SelectPrimitive.Root {...props}>
<SelectTrigger
id={id}
size={size}
invalid={invalid}
aria-invalid={ariaInvalid}
aria-describedby={ariaDescribedBy}
className={cn(wrapperClassName, className)}
>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
{options?.map((option) => {
const value = typeof option === "string" ? option : option.value;
const label = typeof option === "string" ? option : option.label;
const disabled =
typeof option === "string" ? undefined : option.disabled;
return (
<SelectItem key={value} value={value} disabled={disabled}>
{label}
</SelectItem>
);
})}
{children}
</SelectContent>
</SelectPrimitive.Root>
);
}
/* ------------------------------------------------------------ composition */
export const SelectRoot = SelectPrimitive.Root;
export const SelectValue = SelectPrimitive.Value;
export const SelectGroup = SelectPrimitive.Group;
export function SelectTrigger({
className,
size,
invalid,
children,
...props
}: ComponentProps<typeof SelectPrimitive.Trigger> &
VariantProps<typeof selectVariants>) {
return (
<SelectPrimitive.Trigger
data-slot="select"
className={cn(selectVariants({ size, invalid }), className)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownGlyph size={16} className="text-text-muted" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
export function SelectContent({
className,
position = "popper",
...props
}: ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
position={position}
sideOffset={6}
className={cn(
"z-popover border-border-subtle bg-surface-raised overflow-hidden rounded-xl border",
"font-ui shadow-e2 py-1.5",
"animate-woohl-rise origin-[var(--radix-select-content-transform-origin)]",
position === "popper" && "w-[var(--radix-select-trigger-width)]",
className,
)}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport className="max-h-[300px]">
{props.children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
);
}
export function SelectItem({
className,
children,
...props
}: ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"text-body flex cursor-pointer items-center justify-between gap-2.5 px-3.5 py-2 outline-none",
"text-text-primary motion-instant transition-colors",
"data-[highlighted]:bg-surface-sunken",
"data-[disabled]:cursor-not-allowed data-[disabled]:opacity-45",
className,
)}
{...props}
>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
<SelectPrimitive.ItemIndicator>
<CheckGlyph size={14} className="text-text-accent" />
</SelectPrimitive.ItemIndicator>
</SelectPrimitive.Item>
);
}
export function SelectLabel({
className,
...props
}: ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
className={cn(
"font-ui text-caption text-text-muted px-3.5 py-1.5",
className,
)}
{...props}
/>
);
}
export function SelectSeparator({
className,
...props
}: ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
className={cn("bg-border-subtle my-1.5 h-px", className)}
{...props}
/>
);
}
/**
* The native control, kept deliberately.
*
* On a phone this opens the OS picker, which beats any listbox drawn in the
* page. Use it for long lists on mobile-first surfaces.
*/
export function NativeSelect({
className,
size,
options,
placeholder,
children,
...props
}: Omit<ComponentProps<"select">, "size"> &
VariantProps<typeof selectVariants> & {
options?: readonly SelectOption[];
placeholder?: string;
}) {
return (
<div className="relative">
<select
data-slot="native-select"
className={cn(
selectVariants({ size }),
"appearance-none pr-10",
className,
)}
{...props}
>
{placeholder && (
<option value="" disabled>
{placeholder}
</option>
)}
{options?.map((option) => {
const value = typeof option === "string" ? option : option.value;
const label = typeof option === "string" ? option : option.label;
const disabled =
typeof option === "string" ? undefined : option.disabled;
return (
<option key={value} value={value} disabled={disabled}>
{label}
</option>
);
})}
{children}
</select>
<ChevronDownGlyph
size={16}
className="text-text-muted pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2"
/>
</div>
);
}
export { selectVariants };
/**
* Scroll affordances for a list taller than its viewport.
*
* Radix only renders these when the list actually overflows, so they cost
* nothing on a short list and appear exactly when a person needs to know there
* is more below.
*/
export function SelectScrollUpButton({
className,
...props
}: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownGlyph size={14} className="text-text-muted rotate-180" />
</SelectPrimitive.ScrollUpButton>
);
}
export function SelectScrollDownButton({
className,
...props
}: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
className={cn(
"flex cursor-default items-center justify-center py-1",
className,
)}
{...props}
>
<ChevronDownGlyph size={14} className="text-text-muted" />
</SelectPrimitive.ScrollDownButton>
);
}
export function NativeSelectOption(props: ComponentProps<"option">) {
return <option {...props} />;
}
export function NativeSelectOptGroup(props: ComponentProps<"optgroup">) {
return <optgroup {...props} />;
}
SelectProps
Also accepts everything from Omit< ComponentProps<typeof SelectPrimitive.Root>, "children" >.
| Prop | Type | Default |
|---|---|---|
aria-describedby | string | — |
aria-invalid | boolean | "true" | "false" | — |
children | ReactNode | — |
className | string | — |
id | string | — |
invalid | boolean | false |
optionsConvenience for the common case. Omit and pass composition children when you need groups or per-item content. | readonly SelectOption[] | — |
placeholder | string | — |
size | "sm" | "md" | "lg" | "md" |
wrapperClassName | string | — |
Registry items
npm packages
- radix-ui