SelectNew
pnpm dlx shadcn@latest add …/r/select.jsonAn accessible React select with a gliding highlight, groups, labels and a scrollable list, built on Radix Select.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
export function PlanPicker() {
return (
<Select defaultValue="pro">
<SelectTrigger aria-label="Plan">
<SelectValue placeholder="Choose a plan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
</SelectContent>
</Select>
)
}import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { useId } from "react";
export default function SelectDefault() {
const id = useId();
return (
<div className="grid w-full max-w-56 gap-2">
<label htmlFor={id} className="text-sm font-medium">
Plan
</label>
<Select defaultValue="pro">
<SelectTrigger id={id}>
<SelectValue placeholder="Choose a plan" />
</SelectTrigger>
<SelectContent>
<SelectItem value="free">Free</SelectItem>
<SelectItem value="pro">Pro</SelectItem>
<SelectItem value="team">Team</SelectItem>
<SelectItem value="enterprise">Enterprise</SelectItem>
</SelectContent>
</Select>
</div>
);
}Installation
Install the dependencies.
$ pnpm add lucide-react motion radix-uiAdd the FewTone UI tokens to your global CSS file. Skip any variable your project already defines.
:root { --size-control-sm: 2rem; --size-control-md: 2.25rem; --size-control-lg: 2.5rem; --size-icon-sm: 0.875rem; --size-icon-md: 1rem; --size-icon-lg: 1.25rem; --duration-instant: 80ms; --duration-fast: 150ms; --duration-base: 220ms; --duration-slow: 350ms; --duration-slower: 500ms; --z-popover: 1400; --focus-ring-width: 2px; --focus-ring-offset: 2px; --opacity-disabled: 0.5; --scale-press: 0.97; --ease-spring: linear(0, 0.157, 0.48, 0.77, 0.97, 1.07, 1.1, 1.09, 1.05, 1.02, 1, 0.995, 1); --destructive-foreground: oklch(1 0 0); } .dark { --destructive-foreground: oklch(1 0 0); } @theme inline { --animate-popover-in: popover-in var(--duration-fast) cubic-bezier(0.22, 1, 0.36, 1) both; --animate-popover-out: popover-out var(--duration-instant) cubic-bezier(0.55, 0, 1, 0.45) both; --color-destructive-foreground: var(--destructive-foreground); @keyframes popover-in { from { opacity: 0; transform: scale(0.96); } to { opacity: 1; transform: scale(1); } } @keyframes popover-out { from { opacity: 1; transform: scale(1); } to { opacity: 0; transform: scale(0.96); } } } @media (prefers-reduced-motion: reduce) { :root { --duration-instant: 1ms; --duration-fast: 1ms; --duration-base: 1ms; --duration-slow: 1ms; --duration-slower: 1ms; } }Add the motion presets.
import type { Transition } from "motion/react"; /* * Motion tokens for JavaScript animations (motion/react). * Durations and easings mirror the CSS tokens in src/styles/tokens.css; * `pnpm check:tokens` fails if the two drift apart. */ /** Durations in seconds. Mirrors --duration-* in tokens.css. */ export const duration = { instant: 0.08, fast: 0.15, base: 0.22, slow: 0.35, slower: 0.5, } as const; /** Cubic-bezier easings. Mirrors --ease-* in tokens.css. */ export const ease = { out: [0.22, 1, 0.36, 1], in: [0.55, 0, 1, 0.45], inOut: [0.65, 0, 0.35, 1], } as const; /** * Named springs: the library's motion voice. * - snappy: quick, almost no overshoot. Toggles, tabs, small controls. * - smooth: slow and settled. Panels, sidebars, page sections. * - bouncy: playful overshoot. Rare, for moments of delight. */ export const spring = { snappy: { type: "spring", stiffness: 520, damping: 38, mass: 1 }, smooth: { type: "spring", stiffness: 120, damping: 20, mass: 1 }, bouncy: { type: "spring", stiffness: 300, damping: 15, mass: 1 }, } as const satisfies Record<string, Transition>; export type SpringName = keyof typeof spring;Copy the source into your project.
"use client"; import { spring } from "@/lib/motion"; import { cn } from "@/lib/utils"; import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"; import { LayoutGroup, motion, useReducedMotion } from "motion/react"; import { Select as SelectPrimitive } from "radix-ui"; import { type ComponentProps, createContext, type ReactNode, useContext, useId, useState } from "react"; /* * Select, built on Radix Select (a select-only combobox: arrow keys, Home/End, typeahead, Escape). * Opening and closing are CSS keyframes from the trigger's side; the highlight is a pill that glides * between options. Radix moves focus on hover and on arrow keys, so the pill follows both. */ function Select(props: ComponentProps<typeof SelectPrimitive.Root>) { return <SelectPrimitive.Root data-slot="select" {...props} />; } function SelectGroup(props: ComponentProps<typeof SelectPrimitive.Group>) { return <SelectPrimitive.Group data-slot="select-group" {...props} />; } function SelectValue(props: ComponentProps<typeof SelectPrimitive.Value>) { return <SelectPrimitive.Value data-slot="select-value" {...props} />; } const triggerSizes = { sm: "h-(--size-control-sm) rounded-md px-2.5", md: "h-(--size-control-md) px-3", } as const; function SelectTrigger({ className, size = "md", children, ...props }: ComponentProps<typeof SelectPrimitive.Trigger> & { size?: keyof typeof triggerSizes }) { return ( <SelectPrimitive.Trigger data-slot="select-trigger" data-size={size} className={cn( "group flex w-full min-w-0 items-center justify-between gap-2 rounded-lg border border-input bg-transparent text-sm whitespace-nowrap shadow-xs", "transition duration-(--duration-fast) ease-out", "data-placeholder:text-muted-foreground *:data-[slot=select-value]:truncate", "outline-ring focus-visible:border-ring focus-visible:outline-(length:--focus-ring-width) focus-visible:outline-offset-(--focus-ring-offset)", "aria-invalid:border-destructive aria-invalid:outline-destructive", "disabled:cursor-not-allowed disabled:opacity-(--opacity-disabled)", triggerSizes[size], className, )} {...props} > {children} <SelectPrimitive.Icon asChild> <ChevronDownIcon className="size-(--size-icon-md) shrink-0 text-muted-foreground transition-transform duration-(--duration-base) ease-out group-data-[state=open]:rotate-180" /> </SelectPrimitive.Icon> </SelectPrimitive.Trigger> ); } /** Which option the gliding pill sits on. It lives inside the content, so every opening starts fresh. */ const HighlightContext = createContext<{ active: string | null; setActive: (value: string | null) => void; reduceMotion: boolean; } | null>(null); function SelectViewport({ children }: { children: ReactNode }) { const [active, setActive] = useState<string | null>(null); const groupId = useId(); const reduceMotion = useReducedMotion() ?? false; return ( <HighlightContext.Provider value={{ active, setActive, reduceMotion }}> <SelectPrimitive.Viewport asChild> {/* layoutScroll keeps the pill in place while the list scrolls */} <motion.div layoutScroll data-slot="select-viewport" className="w-full min-w-(--radix-select-trigger-width) scroll-my-1 p-1" > <LayoutGroup id={groupId}>{children}</LayoutGroup> </motion.div> </SelectPrimitive.Viewport> </HighlightContext.Provider> ); } function SelectContent({ className, children, position = "popper", sideOffset = 4, ...props }: ComponentProps<typeof SelectPrimitive.Content>) { return ( <SelectPrimitive.Portal> <SelectPrimitive.Content data-slot="select-content" position={position} sideOffset={sideOffset} className={cn( "relative isolate z-(--z-popover) max-h-(--radix-select-content-available-height) min-w-(--radix-select-trigger-width) overflow-hidden rounded-lg border border-border bg-popover text-popover-foreground shadow-md", "origin-(--radix-select-content-transform-origin) data-[state=open]:animate-popover-in data-[state=closed]:animate-popover-out", className, )} {...props} > <SelectScrollUpButton /> <SelectViewport>{children}</SelectViewport> <SelectScrollDownButton /> </SelectPrimitive.Content> </SelectPrimitive.Portal> ); } function SelectLabel({ className, ...props }: ComponentProps<typeof SelectPrimitive.Label>) { return ( <SelectPrimitive.Label data-slot="select-label" className={cn("px-2 py-1.5 text-xs text-muted-foreground", className)} {...props} /> ); } function SelectItem({ className, children, value, onFocus, onBlur, ...props }: ComponentProps<typeof SelectPrimitive.Item>) { const highlight = useContext(HighlightContext); const active = highlight?.active === value; return ( <SelectPrimitive.Item data-slot="select-item" value={value} onFocus={(event) => { highlight?.setActive(value); onFocus?.(event); }} onBlur={(event) => { // Moving to another option keeps the pill (it glides); leaving the list clears it const next = event.relatedTarget; if (!(next instanceof HTMLElement && next.closest("[data-slot=select-item]"))) highlight?.setActive(null); onBlur?.(event); }} className={cn( "relative flex w-full cursor-default items-center gap-2 rounded-md py-1.5 ps-2 pe-8 text-sm outline-hidden select-none", "data-highlighted:text-accent-foreground", "data-disabled:pointer-events-none data-disabled:opacity-(--opacity-disabled)", "[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-(--size-icon-md)", className, )} {...props} > {/* Options are not stacking contexts: the pill joins the list's own layer (the content is `isolate`), so it passes behind every option's text, whichever way it glides */} {active ? ( <motion.span layoutId="select-highlight" aria-hidden className="absolute inset-0 -z-10 rounded-md bg-accent" transition={highlight?.reduceMotion ? { duration: 0 } : spring.snappy} /> ) : null} <span className="absolute end-2 flex size-(--size-icon-md) items-center justify-center"> <SelectPrimitive.ItemIndicator> <CheckIcon className="size-(--size-icon-sm)" /> </SelectPrimitive.ItemIndicator> </span> <SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText> </SelectPrimitive.Item> ); } function SelectSeparator({ className, ...props }: ComponentProps<typeof SelectPrimitive.Separator>) { return ( <SelectPrimitive.Separator data-slot="select-separator" className={cn("pointer-events-none -mx-1 my-1 h-px bg-border", className)} {...props} /> ); } function SelectScrollUpButton({ className, ...props }: ComponentProps<typeof SelectPrimitive.ScrollUpButton>) { return ( <SelectPrimitive.ScrollUpButton data-slot="select-scroll-up-button" className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)} {...props} > <ChevronUpIcon className="size-(--size-icon-sm)" /> </SelectPrimitive.ScrollUpButton> ); } function SelectScrollDownButton({ className, ...props }: ComponentProps<typeof SelectPrimitive.ScrollDownButton>) { return ( <SelectPrimitive.ScrollDownButton data-slot="select-scroll-down-button" className={cn("flex cursor-default items-center justify-center py-1 text-muted-foreground", className)} {...props} > <ChevronDownIcon className="size-(--size-icon-sm)" /> </SelectPrimitive.ScrollDownButton> ); } export { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectScrollDownButton, SelectScrollUpButton, SelectSeparator, SelectTrigger, SelectValue, };Update the import paths to match your project.
Examples
Groups
Group related options under a label, with a separator between groups. Options can be disabled.
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectSeparator,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export default function SelectGroups() {
return (
<Select>
<SelectTrigger className="max-w-56" aria-label="Font">
<SelectValue placeholder="Choose a font" />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel>Sans serif</SelectLabel>
<SelectItem value="geist">Geist</SelectItem>
<SelectItem value="inter">Inter</SelectItem>
</SelectGroup>
<SelectSeparator />
<SelectGroup>
<SelectLabel>Monospace</SelectLabel>
<SelectItem value="geist-mono">Geist Mono</SelectItem>
<SelectItem value="jetbrains-mono">JetBrains Mono</SelectItem>
<SelectItem value="courier" disabled>
Courier (not installed)
</SelectItem>
</SelectGroup>
</SelectContent>
</Select>
);
}Long lists
Long lists scroll, with arrows at the ends. Type a few letters to jump to an option.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const timezones = [
"Pacific/Honolulu",
"America/Los_Angeles",
"America/Denver",
"America/Chicago",
"America/New_York",
"America/Sao_Paulo",
"Europe/London",
"Europe/Berlin",
"Africa/Cairo",
"Europe/Moscow",
"Asia/Dubai",
"Asia/Kolkata",
"Asia/Singapore",
"Asia/Tokyo",
"Australia/Sydney",
"Pacific/Auckland",
];
export default function SelectScrollable() {
return (
<Select defaultValue="Asia/Kolkata">
<SelectTrigger className="max-w-64" aria-label="Time zone">
<SelectValue />
</SelectTrigger>
<SelectContent className="max-h-64">
{timezones.map((zone) => (
<SelectItem key={zone} value={zone}>
{zone.replace("_", " ")}
</SelectItem>
))}
</SelectContent>
</Select>
);
}Sizes
The trigger comes in Button's small and medium heights.
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
const options = ["Daily", "Weekly", "Monthly"];
export default function SelectSizes() {
return (
<div className="flex flex-wrap items-center justify-center gap-3">
<Select defaultValue="Weekly">
<SelectTrigger size="sm" className="w-32" aria-label="Frequency, small">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
<Select defaultValue="Weekly">
<SelectTrigger className="w-32" aria-label="Frequency, medium">
<SelectValue />
</SelectTrigger>
<SelectContent>
{options.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
}API reference
Select
The root. Also accepts every Radix Select Root prop.
| Prop | Type | Default | Description |
|---|---|---|---|
value? | string | – | The selected value, when you control it. |
defaultValue? | string | – | The initially selected value. |
onValueChange? | (value: string) => void | – | Called when the user picks an option. |
name? | string | – | Submits the value with a form. |
disabled? | boolean | false | Prevents opening. |
SelectTrigger
The button that opens the list. Give it a label with aria-label or a <label htmlFor>.
| Prop | Type | Default | Description |
|---|---|---|---|
size? | "sm" | "md" | "md" | The height. |
aria-invalid? | boolean | – | Marks the value as wrong. |
SelectContent
The list. Opens below the trigger and flips when there is no room.
| Prop | Type | Default | Description |
|---|---|---|---|
position? | "popper" | "item-aligned" | "popper" | Popper sits below the trigger; item-aligned puts the selected option over it, like a native select. |
sideOffset? | number | 4 | Gap between the trigger and the list, in px. |
SelectItem
| Prop | Type | Default | Description |
|---|---|---|---|
value | string | – | The value this option selects. |
disabled? | boolean | false | Skips the option. |
Related components
- InputNew
An accessible React text input with three sizes and clear invalid, disabled and file states, built on a native input.
- SwitchNew
An accessible React switch for on/off settings, with a spring-animated thumb, built on Radix Switch.
- ButtonNew
An accessible React button with six variants, three sizes, icon support and a spring press, built on a native button.