Select

Picks one value from a short list of options.

Installation

Terminal
pnpm dlx @dowel-ui/cli add select

npm 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

PropTypeDefault
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

PropTypeDefault
label

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.

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

  • Testedpasses
  • axe assertionpasses
  • Keyboard testeddoes not apply
  • Storybook examplespasses
  • Accessibility documentedpasses
  • Semantic tokens onlypasses
  • Motion from tokenspasses
  • className mergedpasses
  • Visible focusdoes not apply
  • No fixed widthspasses

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.

ui/select.tsx
"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 };