A compact option picker with typeahead, keyboard navigation, a highlight that slides between options, and an ink checkmark.
Workspace
pnpm dlx shadcn@latest add @latheui/selectFirst time? Run npx latheui init once to add the @latheui registry. The CLI then writes the source into your project and installs what it needs. Or add it by hand:
Install the npm dependencies.
pnpm add radix-ui lucide-react motionAdd the Lathe items this component imports.
lib/motion.tsCopy the source into your project.
"use client"
import * as React from "react"
import { Select as Primitive } from "radix-ui"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { motion } from "motion/react"
import { cn } from "@/lib/utils"
import { spring, useMotionSafe } from "@/lib/motion"
export type SelectOption = { value: string; label: string; disabled?: boolean }
export type SelectProps = React.ComponentProps<typeof Primitive.Root> & {
options: SelectOption[]
label: string
placeholder?: string
className?: string
id?: string
/** Ref to the trigger, for focus management in composed workflows. */
ref?: React.Ref<HTMLButtonElement>
}
export function Select({
options,
label,
placeholder = "Choose an option",
className,
id,
ref,
onOpenChange,
...props
}: SelectProps) {
// The highlighted option, followed by one sliding highlight instead of a background per item.
const [highlighted, setHighlighted] = React.useState<string | null>(null)
// A fresh highlight each time the list opens, so it appears in place rather than sliding in
// from where it was when the list last closed.
const [opened, setOpened] = React.useState(0)
const layoutId = `${React.useId()}-${opened}`
const { safe } = useMotionSafe()
const trigger = React.useRef<HTMLButtonElement>(null)
// Whether the list was last used with a pointer. Closing it then puts focus back on the
// trigger without the keyboard focus ring, which would otherwise linger after a click.
const byPointer = React.useRef(false)
const setTrigger = React.useCallback(
(node: HTMLButtonElement | null) => {
trigger.current = node
if (typeof ref === "function") ref(node)
else if (ref) ref.current = node
},
[ref]
)
return (
<Primitive.Root
{...props}
onOpenChange={(open) => {
if (open) setOpened((n) => n + 1)
else setHighlighted(null)
onOpenChange?.(open)
}}
>
<Primitive.Trigger
ref={setTrigger}
onPointerDown={() => (byPointer.current = true)}
onKeyDown={() => (byPointer.current = false)}
id={id}
aria-label={label}
className={cn(
"flex h-9 w-full items-center justify-between gap-2 rounded-md border border-input bg-background px-3 text-sm outline-none focus-visible:ring-3 focus-visible:ring-ring/20 disabled:opacity-50 data-placeholder:text-muted-foreground",
className
)}
>
<Primitive.Value placeholder={placeholder} />
<Primitive.Icon>
<ChevronDownIcon aria-hidden="true" className="size-4" />
</Primitive.Icon>
</Primitive.Trigger>
<Primitive.Portal>
<Primitive.Content
position="popper"
sideOffset={5}
data-slot="select-content"
onPointerDownCapture={() => (byPointer.current = true)}
onKeyDownCapture={() => (byPointer.current = false)}
onPointerDownOutside={() => (byPointer.current = true)}
onEscapeKeyDown={() => (byPointer.current = false)}
onCloseAutoFocus={(event) => {
if (!byPointer.current || !trigger.current) return
event.preventDefault()
trigger.current.focus({ preventScroll: true, focusVisible: false } as FocusOptions)
}}
// Radix focuses the list itself for a moment between two options. The highlight stays
// put through that and only clears once the pointer leaves the list.
onPointerLeave={() => {
const focused = document.activeElement
if (!focused?.closest?.("[data-slot=select-item]")) setHighlighted(null)
}}
className="z-50 max-h-[var(--radix-select-content-available-height)] min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-lg"
>
<Primitive.ScrollUpButton className="flex justify-center py-1">
<ChevronUpIcon aria-hidden="true" className="size-4" />
</Primitive.ScrollUpButton>
<Primitive.Viewport>
{options.map((option) => (
<Primitive.Item
key={option.value}
value={option.value}
disabled={option.disabled}
data-slot="select-item"
// Radix moves focus to the highlighted option, by pointer or keyboard alike.
onFocus={() => setHighlighted(option.value)}
onBlur={(event) => {
// Focus leaving the list altogether clears the highlight. Focus landing on the
// list between two options does not, or the highlight would blink.
const next = event.relatedTarget as Element | null
if (!next?.closest?.("[data-slot=select-content]")) setHighlighted(null)
}}
className="relative isolate flex cursor-pointer items-center rounded-md py-2 pr-8 pl-3 text-sm outline-none data-disabled:pointer-events-none data-disabled:opacity-40"
>
{highlighted === option.value && (
<motion.span
layoutId={layoutId}
aria-hidden="true"
transition={safe(spring.hover)}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
<Primitive.ItemText>{option.label}</Primitive.ItemText>
<Primitive.ItemIndicator className="absolute right-2">
<CheckIcon aria-hidden="true" className="size-4" />
</Primitive.ItemIndicator>
</Primitive.Item>
))}
</Primitive.Viewport>
<Primitive.ScrollDownButton className="flex justify-center py-1">
<ChevronDownIcon aria-hidden="true" className="size-4" />
</Primitive.ScrollDownButton>
</Primitive.Content>
</Primitive.Portal>
</Primitive.Root>
)
}Depends on
| Prop | Type | Default | Description |
|---|---|---|---|
| options* (required) | SelectOption[] | — | Unique nonempty values, visible labels and optional disabled states. |
| label* (required) | string | — | Accessible control name. |
| value / defaultValue | string | — | Controlled selection or initial selection. |
| onValueChange | (value: string) => void | — | Called when an option is selected. |
| ref | React.Ref<HTMLButtonElement> | — | Ref to the trigger for focus management in composed workflows. |
| name / required / disabled | native form props | — | Form submission, validation and disabled state. |
Combine the control with your own layout. Semantic colors follow the active theme.
import { Select } from "@/components/ui/select"
export default function Demo() { return <div className="w-full max-w-xs space-y-2"><p className="text-sm font-medium">Workspace</p><Select label="Workspace" defaultValue="studio" options={[{value:"studio",label:"Studio"},{value:"personal",label:"Personal"},{value:"archive",label:"Archive",disabled:true}]}/></div> }