Primitives
Select
Single-choice picker with a token-matched trigger and floating list.
Composition
Anatomy
- <Select>
- <SelectTrigger>
- <SelectValue />
- <SelectContent>
- <SelectItem />
- <SelectTrigger>
Installation
First install and activate one skin. Core deliberately contains no visual token defaults.
The Control UI source installs this primitive from src/registry/sources/control-ui/ui/select.tsx.
npx shadcn@latest add https://control-ui.dev/r/select.jsonDependencies
Popup item focus
src/registry/lib/popup-item-focus.tsUtilSurface variants
src/registry/sources/control-ui/surface-variants.tsSupportControl variants
Buttonsrc/registry/sources/control-ui/control-variants.tsSupportbuttonPrimitiveScroll areascroll-areaPrimitiveRaw code
Primary installed source
"use client";import { mergeProps } from "@base-ui/react/merge-props";import { Select as SelectPrimitive } from "@base-ui/react/select";import type { ComponentProps, CSSProperties, ReactNode } from "react";import type { ControlledChoice } from "@/components/control-ui/control-props";import type { ControlSize } from "@/components/control-ui/control-variants";import type { ButtonKnobStyle } from "@/components/control-ui/knob-contracts/button-knobs";import type { FieldKnobStyle } from "@/components/control-ui/knob-contracts/field-knobs";import type { PopupKnobStyle } from "@/components/control-ui/knob-contracts/popup-knobs";import { cn } from "@/components/control-ui/lib/cn";import { popupItemFocusProps } from "@/components/control-ui/lib/popup-item-focus";import { controlEffectsAttribute } from "@/components/control-ui/skin";import { useSkin } from "@/components/control-ui/skin-provider";import { popupItemStructureClasses } from "@/components/control-ui/surface-variants";import { Button } from "@/components/control-ui/ui/button";import { ScrollArea } from "@/components/control-ui/ui/scroll-area";export type SelectProps<TValue extends string = string> = Omit<ControlledChoice<TValue>, "value"> & { value?: TValue | null; open?: boolean; defaultOpen?: boolean; onOpenChange?: (open: boolean) => void; disabled?: boolean; items?: Record<string, ReactNode> | readonly { value: TValue; label: ReactNode }[]; name?: string; children?: ReactNode;};export const selectTriggerVariants = ["surface", "ghost"] as const;export type SelectTriggerVariant = (typeof selectTriggerVariants)[number];export type SelectTriggerProps = Omit<ComponentProps<"button">, "style"> & { style?: CSSProperties & FieldKnobStyle & ButtonKnobStyle } & { size?: ControlSize; variant?: SelectTriggerVariant;};export type SelectValueProps = { placeholder?: ReactNode; children?: ReactNode | ((value: string) => ReactNode);};export type SelectContentProps = Omit<ComponentProps<"div">, "style"> & Pick<ComponentProps<typeof SelectPrimitive.Positioner>, "alignItemWithTrigger"> & { style?: CSSProperties & PopupKnobStyle };export type SelectItemProps = Omit<ComponentProps<"div">, "style"> & { style?: CSSProperties & PopupKnobStyle } & { value: string; label?: string; disabled?: boolean;};type RefinedSelectTriggerProps = SelectTriggerProps & Pick<ComponentProps<typeof SelectPrimitive.Trigger>, "nativeButton" | "render"> & { style?: CSSProperties & PopupKnobStyle };export function Select<TValue extends string = string>({ children, onValueChange, ...props }: SelectProps<TValue>) { return ( <SelectPrimitive.Root<TValue> {...props} onValueChange={(value) => { if (value !== null) onValueChange?.(value); }} > {children} </SelectPrimitive.Root> );}const selectTriggerLayoutClasses = "group relative isolate inline-flex shrink-0 items-center justify-between overflow-visible";function SelectIcon() { return ( <SelectPrimitive.Icon data-control-ui="select" data-control-family="popup" data-popup-kind="select" data-slot="icon" className="relative z-[1]" > <svg viewBox="0 0 12 12" className="size-3" aria-hidden="true" fill="none"> <path d="M3 4.5 6 7.5 9 4.5" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" /> </svg> </SelectPrimitive.Icon> );}export function SelectTrigger({ size = "sm", variant = "surface", className, children, disabled, ...props }: RefinedSelectTriggerProps) { if (variant === "ghost") { return ( <SelectPrimitive.Trigger render={<Button variant="ghost" size={size} />} className={cn(selectTriggerLayoutClasses, className)} disabled={disabled} {...props} > {children} <SelectIcon /> </SelectPrimitive.Trigger> ); } return ( <SelectPrimitive.Trigger data-control-ui="select" data-control-family="field" data-field-kind="select" data-slot="trigger" data-control="true" data-size={size} className={cn(selectTriggerLayoutClasses, className)} disabled={disabled} {...props} > <span data-control-ui="select" data-control-family="field" data-field-kind="select" data-slot="value" className="inline-flex min-w-0 items-center gap-[inherit]" > {children} </span> <SelectIcon /> </SelectPrimitive.Trigger> );}export function SelectValue({ children, ...props }: SelectValueProps) { return ( <SelectPrimitive.Value className="min-w-0 truncate" {...props}> {children} </SelectPrimitive.Value> );}export function SelectContent({ className, children, alignItemWithTrigger = false, ...props }: SelectContentProps) { const skin = useSkin(); return ( <SelectPrimitive.Portal> <SelectPrimitive.Positioner data-skin={skin.id} data-effects={controlEffectsAttribute(skin.effects)} side="bottom" align="start" sideOffset={6} alignItemWithTrigger={alignItemWithTrigger} className="z-(--z-popup)" > <SelectPrimitive.Popup data-control-ui="select" data-popup-kind="select" data-slot="content" data-surface="floating" data-control-family="popup" data-popup-part="list-surface" className={cn("grid max-h-[min(20rem,var(--available-height))] min-w-[var(--anchor-width)] grid-rows-[minmax(0,1fr)]", className)} {...props} > <ScrollArea lockAxis="x" viewportProps={{ render: renderListAsScrollViewport }}> {children} </ScrollArea> </SelectPrimitive.Popup> </SelectPrimitive.Positioner> </SelectPrimitive.Portal> );}// Base UI measures and scrolls Select.List itself; the viewport's role/tabIndex would mask the listbox.function renderListAsScrollViewport({ role: _role, tabIndex: _tabIndex, ...viewportProps }: ComponentProps<"div">) { return <SelectPrimitive.List {...viewportProps} />;}export function SelectItem({ className, children, disabled, ...props }: SelectItemProps) { return ( <SelectPrimitive.Item data-control-ui="select" data-popup-kind="select" data-slot="item" data-control-family="popup" data-popup-part="item" disabled={disabled} className={cn(popupItemStructureClasses, className)} {...mergeProps(popupItemFocusProps, props)} > <SelectPrimitive.ItemText data-control-ui="select" data-control-family="popup" data-popup-kind="select" data-slot="item-text" className="flex min-w-0 flex-1 items-center" > {children} </SelectPrimitive.ItemText> <span data-control-ui="select" data-control-family="popup" data-popup-kind="select" data-slot="item-indicator" className="flex size-3.5 shrink-0 items-center justify-center" > <SelectPrimitive.ItemIndicator> <svg viewBox="0 0 12 12" className="size-3" aria-hidden="true" fill="none"> <path d="M2.5 6.5 5 9l4.5-5" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /> </svg> </SelectPrimitive.ItemIndicator> </span> </SelectPrimitive.Item> );}Knobs
Typed custom properties the recipe paints with. Set one on the root — style, a utility class, or a skin — and every slot inherits it.
--cui-field-* · 15 knobsHow the cascade resolves--cui-field-radius<length-percentage>var(--nest-radius, var(--radius-control))--cui-field-background<color>var(--control-fill)--cui-field-hover-background<color>var(--hover-fill)--cui-field-affordance-background<color>transparent--cui-field-affordance-shadow*0 0 transparent--cui-field-affordance-foreground<color>var(--muted-foreground)--cui-field-affordance-hover-background<color>var(--hover-fill)--cui-field-foreground<color>var(--foreground)--cui-field-border-color<color>var(--control-rim)--cui-field-border-width<length>var(--control-rim-width)--cui-field-shadow*var(--shadow-inset)--cui-field-backdrop-filter*blur(0px)--cui-field-height<length-percentage>var(--control-h-md)--cui-field-padding-inline<length-percentage>var(--padding-x)--cui-field-font-size<length-percentage>var(--text-body)--cui-popup-* · 44 knobsHow the cascade resolves--cui-popup-title-foreground<color>var(--cui-popup-foreground)--cui-popup-title-font-size<length-percentage>var(--text-heading-3)--cui-popup-title-font-weight<number>var(--font-weight-semibold)--cui-popup-title-line-height<number>var(--text-heading-3--line-height)--cui-popup-title-letter-spacing<length>-0.025em--cui-popup-description-foreground<color>var(--muted-foreground)--cui-popup-close-inset<length-percentage>calc(var(--spacing) * 3)--cui-popup-item-inset<length-percentage>calc(var(--spacing) * 8)--cui-popup-label-padding-block<length-percentage>var(--cui-popup-item-padding-block)--cui-popup-label-padding-inline<length-percentage>var(--cui-popup-item-padding-inline)--cui-popup-label-font-size<length-percentage>var(--text-micro)--cui-popup-label-font-weight<number>var(--font-weight-medium)--cui-popup-label-letter-spacing<length>0.08em--cui-popup-label-text-transformnone | uppercase | lowercase | capitalizeuppercase--cui-popup-separator-inset<length-percentage>calc(var(--popover-padding) * -1)--cui-popup-shortcut-font-size<length-percentage>var(--text-body)--cui-popup-shortcut-letter-spacing<length>0.02em--cui-popup-status-success-foreground<color>var(--success-text)--cui-popup-status-warning-foreground<color>var(--warning-text)--cui-popup-status-info-foreground<color>var(--info-text)--cui-popup-status-error-foreground<color>var(--destructive)--cui-popup-radius<length-percentage>var(--radius-popover)--cui-popup-background<color>var(--popover)--cui-popup-foreground<color>var(--popover-foreground)--cui-popup-border-color<color>var(--control-rim)--cui-popup-border-width<length>var(--control-rim-width)--cui-popup-shadow*var(--shadow-pop)--cui-popup-backdrop-filter*blur(var(--backdrop-blur-popover))--cui-popup-backdrop-background<color>oklch(from var(--foreground) l c h / var(--overlay-opacity))--cui-popup-backdrop-blur<length>var(--backdrop-blur-overlay)--cui-popup-item-radius<length-percentage>var(--radius-popup-item-fit)--cui-popup-item-font-size<length-percentage>var(--text-body)--cui-popup-item-icon-size<length-percentage>1rem--cui-popup-item-padding-block<length-percentage>calc(var(--spacing) * 1)--cui-popup-item-padding-inline<length-percentage>calc(var(--padding-x) * 0.5)--cui-popup-item-min-height<length-percentage>max(var(--target-min), var(--control-h-xs))--cui-popup-item-highlight-shadow*inset 0 0 0 1px oklch(from var(--foreground) l c h / 0.35)--cui-popup-item-foreground<color>var(--foreground)--cui-popup-item-highlight-background<color>var(--hover-fill)--cui-popup-item-highlight-foreground<color>var(--cui-popup-item-foreground)--cui-popup-item-highlight-muted-foreground<color>var(--muted-foreground)--cui-popup-item-disabled-opacity<number>var(--disabled-opacity)--cui-popup-separator-color<color>var(--border)--cui-popup-shortcut-foreground<color>var(--muted-foreground)