Select
Picks one value from a short list of options.
Installation
pnpm dlx @dowel-ui/cli add selectnpm packages installed: class-variance-authority, radix-ui.
Accessibility
Full listbox keyboard model: arrows move, typeahead jumps, Enter selects, Escape closes and restores focus. Give the trigger a name with aria-labelledby or a Label. Past about a dozen options prefer Combobox — scrolling a long listbox by keyboard is slow. The opening pop, option stagger and tick pop are decoration and stop under reduced motion.
Props
SelectTrigger
| Prop | Type | Default |
|---|---|---|
triggerSize | "sm" | "md" | "lg" | "md" |
Every prop of Radix UI’s Select.Trigger, plus className.
SelectContent
Every prop of Radix UI’s Select.Content, plus className.
SelectItem
| Prop | Type | Default |
|---|---|---|
labelWhat the trigger shows once this option is chosen. The primitive clones the item's text into the trigger, so a rich option — a name with a description under it — would otherwise drag all of that into a one-line trigger. Give the label here and the rest as children. | ReactNode | — |
Every prop of Radix UI’s Select.Item, plus className.
SelectLabel
Every prop of Radix UI’s Select.Label, plus className.
SelectSeparator
Every prop of Radix UI’s Select.Separator, plus className.
Quality
8/8 checks, measured from the source and its tests
- Tested — passes
- axe assertion — passes
- Keyboard tested — does not apply
- Storybook examples — passes
- Accessibility documented — passes
- Semantic tokens only — passes
- Motion from tokens — passes
- className merged — passes
- Visible focus — does not apply
- No fixed widths — passes
Used in
Whole screens assembled from this component. Installing one brings this and everything else it needs with it.
Source
This is exactly what dowel add select writes into your project, with imports rewritten to your own path alias.
"use client";
// Motion from SmoothUI Select (MIT, © 2024 Eduardo Calvo). See THIRD_PARTY_NOTICES.md.
import { cva, type VariantProps } from "class-variance-authority";
import { Select as SelectPrimitive } from "radix-ui";
import type { ComponentPropsWithRef, ReactNode } from "react";
import { focusRing, invalidStyles } from "@/lib/styles";
import { cn } from "@/lib/utils";
/**
* Picks one value from a list.
*
* For a short, known list. Past roughly a dozen options people want to type
* rather than scroll — reach for Combobox there. Unlike a native `<select>`,
* the options are real elements, so they can carry icons and descriptions.
*
* The list opens with the same motion as DropdownMenu and ContextMenu — a pop
* from the trigger with a slight overshoot, options dropping in 20ms apart —
* and the selected option's tick pops in. All of it is decoration and stops
* under reduced motion.
*/
export const Select = SelectPrimitive.Root;
export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
const selectTriggerVariants = cva(
cn(
"flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background",
"whitespace-nowrap text-foreground shadow-xs",
"transition-[border-color,box-shadow] duration-[var(--duration-fast)]",
"data-[placeholder]:text-muted-foreground",
"focus-visible:border-ring",
"disabled:cursor-not-allowed disabled:opacity-55",
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
"*:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2",
focusRing,
invalidStyles,
),
{
variants: {
triggerSize: {
sm: "h-8 px-2.5 text-sm",
md: "h-9 px-3 text-sm",
lg: "h-10 px-3.5 text-base",
},
},
defaultVariants: {
triggerSize: "md",
},
},
);
export interface SelectTriggerProps
extends
ComponentPropsWithRef<typeof SelectPrimitive.Trigger>,
VariantProps<typeof selectTriggerVariants> {}
export function SelectTrigger({
className,
triggerSize,
children,
...props
}: SelectTriggerProps) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
className={cn(selectTriggerVariants({ triggerSize }), className)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-4 opacity-60">
<path
d="m7 10 5 5 5-5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
);
}
/** Scroll affordance shown only when the list overflows. Decorative. */
function ScrollButton({
direction,
className,
...props
}: {
direction: "up" | "down";
} & ComponentPropsWithRef<typeof SelectPrimitive.ScrollUpButton>) {
const Comp =
direction === "up" ? SelectPrimitive.ScrollUpButton : SelectPrimitive.ScrollDownButton;
return (
<Comp
data-slot={`select-scroll-${direction}`}
className={cn("flex cursor-default items-center justify-center py-1", className)}
{...props}
>
<svg viewBox="0 0 24 24" fill="none" aria-hidden="true" className="size-4 opacity-60">
<path
d={direction === "up" ? "m7 14 5-5 5 5" : "m7 10 5 5 5-5"}
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</Comp>
);
}
const PREFIX = "dowel-select";
/** Stagger delays for the first dozen entries; later ones arrive with the twelfth. */
const STAGGER = Array.from(
{ length: 11 },
(_, i) =>
`[data-slot=select-viewport]>:nth-child(${String(i + 2)}){animation-delay:calc(${String((i + 1) * 20)}ms * var(--motion-scale))}`,
).join("\n");
const STYLES = `
@keyframes ${PREFIX}-in{from{opacity:0;transform:translateY(-4px) scale(.95)}}
@keyframes ${PREFIX}-item-in{from{opacity:0;transform:translateY(-4px)}}
@keyframes ${PREFIX}-check-in{from{opacity:0;transform:scale(0)}}
${STAGGER}
[data-slot=select-viewport]>:nth-child(n+13){animation-delay:calc(240ms * var(--motion-scale))}
`;
function Styles() {
return (
<style href={PREFIX} precedence="dowel">
{STYLES}
</style>
);
}
export type SelectContentProps = ComponentPropsWithRef<typeof SelectPrimitive.Content>;
export function SelectContent({
className,
children,
position = "popper",
sideOffset = 6,
...props
}: SelectContentProps) {
return (
<>
<Styles />
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
position={position}
sideOffset={position === "popper" ? sideOffset : undefined}
className={cn(
"relative z-[var(--z-popover)] max-h-[var(--radix-select-content-available-height)]",
"min-w-32 overflow-x-hidden overflow-y-auto rounded-lg border border-border bg-popover",
"p-1 text-popover-foreground shadow-lg",
"origin-[var(--radix-select-content-transform-origin)]",
// Keyframe names spelled out because Tailwind reads class names from the source.
"data-[state=closed]:animate-float-out",
"data-[state=open]:animate-[dowel-select-in_calc(250ms*var(--motion-scale))_var(--ease-overshoot)]",
position === "popper" && "w-full min-w-[var(--radix-select-trigger-width)]",
className,
)}
{...props}
>
<ScrollButton direction="up" />
<SelectPrimitive.Viewport
data-slot="select-viewport"
className="p-0 [&>*]:animate-[dowel-select-item-in_calc(250ms*var(--motion-scale))_var(--ease-overshoot)_both]"
>
{children}
</SelectPrimitive.Viewport>
<ScrollButton direction="down" />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
</>
);
}
export interface SelectItemProps extends ComponentPropsWithRef<typeof SelectPrimitive.Item> {
/**
* What the trigger shows once this option is chosen.
*
* The primitive clones the item's text into the trigger, so a rich option —
* a name with a description under it — would otherwise drag all of that into
* a one-line trigger. Give the label here and the rest as children.
*/
label?: ReactNode;
}
export function SelectItem({ className, label, children, ...props }: SelectItemProps) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-none select-none",
"transition-colors duration-[var(--duration-instant)]",
"data-[highlighted]:bg-accent data-[highlighted]:text-accent-foreground",
"data-[disabled]:pointer-events-none data-[disabled]:opacity-55",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className,
)}
{...props}
>
{label === undefined ? (
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
) : (
<>
<SelectPrimitive.ItemText>{label}</SelectPrimitive.ItemText>
{children}
</>
)}
{/* Decorative: selection is already announced through aria-selected. */}
<span className="absolute end-2 grid size-4 place-items-center">
<SelectPrimitive.ItemIndicator>
<svg
viewBox="0 0 24 24"
fill="none"
aria-hidden="true"
data-slot="select-item-check"
className="size-3.5 animate-[dowel-select-check-in_calc(200ms*var(--motion-scale))_var(--ease-overshoot)]"
>
<path
d="m5 13 4 4L19 7"
stroke="currentColor"
strokeWidth="2.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</SelectPrimitive.ItemIndicator>
</span>
</SelectPrimitive.Item>
);
}
export function SelectLabel({
className,
...props
}: ComponentPropsWithRef<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("px-2 py-1.5 text-xs font-medium text-muted-foreground", className)}
{...props}
/>
);
}
export function SelectSeparator({
className,
...props
}: ComponentPropsWithRef<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
);
}
export { selectTriggerVariants };