Forms
Combobox
Searchable select, single or multiple with chips.
pnpm dlx shadcn@latest add @paragon/comboboxBrings in@paragon/button@paragon/input-group@base-ui/reactlucide-reactcn
Multiple
Chosen values become removable chips inside the field.
registry/paragon/ui/combobox.tsx
"use client"
import * as React from "react"
import { Combobox as ComboboxPrimitive } from "@base-ui/react"
import { CheckIcon, ChevronDownIcon, XIcon } from "lucide-react"
import { cn } from "cn"
import { Button } from "@/registry/paragon/ui/button"
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from "@/registry/paragon/ui/input-group"
const Combobox = ComboboxPrimitive.Root
function ComboboxValue({ ...props }: ComboboxPrimitive.Value.Props) {
return <ComboboxPrimitive.Value data-slot="combobox-value" {...props} />
}
function ComboboxTrigger({ className, children, ...props }: ComboboxPrimitive.Trigger.Props) {
return (
<ComboboxPrimitive.Trigger
data-slot="combobox-trigger"
className={cn("group/combobox-trigger [&_svg:not([class*='size-'])]:size-4", className)}
{...props}
>
{children}
<ChevronDownIcon className="pointer-events-none size-4 text-muted-foreground transition-transform duration-(--duration-base) ease-paragon group-data-popup-open/combobox-trigger:rotate-180" />
</ComboboxPrimitive.Trigger>
)
}
function ComboboxClear({ className, ...props }: ComboboxPrimitive.Clear.Props) {
return (
<ComboboxPrimitive.Clear
data-slot="combobox-clear"
render={<InputGroupButton variant="ghost" size="icon-xs" aria-label="Clear" />}
className={cn(className)}
{...props}
>
<XIcon className="pointer-events-none" />
</ComboboxPrimitive.Clear>
)
}
/** A searchable select: the field is an InputGroup, so it shares Input's surface and states. */
function ComboboxInput({
className,
children,
disabled = false,
showTrigger = true,
showClear = false,
...props
}: ComboboxPrimitive.Input.Props & {
showTrigger?: boolean
showClear?: boolean
}) {
return (
<InputGroup className={cn("w-auto", className)}>
<ComboboxPrimitive.Input render={<InputGroupInput disabled={disabled} />} {...props} />
<InputGroupAddon align="inline-end">
{showTrigger && (
<InputGroupButton
size="icon-xs"
variant="ghost"
render={<ComboboxTrigger />}
data-slot="input-group-button"
className="group-has-data-[slot=combobox-clear]/input-group:hidden data-pressed:bg-transparent"
disabled={disabled}
/>
)}
{showClear && <ComboboxClear disabled={disabled} />}
</InputGroupAddon>
{children}
</InputGroup>
)
}
function ComboboxContent({
className,
side = "bottom",
sideOffset = 6,
align = "start",
alignOffset = 0,
anchor,
...props
}: ComboboxPrimitive.Popup.Props &
Pick<ComboboxPrimitive.Positioner.Props, "side" | "align" | "sideOffset" | "alignOffset" | "anchor">) {
return (
<ComboboxPrimitive.Portal>
<ComboboxPrimitive.Positioner
side={side}
sideOffset={sideOffset}
align={align}
alignOffset={alignOffset}
anchor={anchor}
className="isolate z-50"
>
<ComboboxPrimitive.Popup
data-slot="combobox-content"
data-chips={!!anchor}
className={cn(
"group/combobox-content relative max-h-(--available-height) w-(--anchor-width) max-w-(--available-width) min-w-(--anchor-width) origin-(--transform-origin) overflow-hidden rounded-card bg-popover text-popover-foreground ring-1 ring-border",
"shadow-[0_1px_2px_color-mix(in_oklab,var(--ink),transparent_92%),0_16px_36px_-12px_color-mix(in_oklab,var(--ink),transparent_78%)] dark:shadow-[0_16px_36px_-12px_rgb(0_0_0/0.65)]",
"duration-(--duration-base) ease-paragon data-open:animate-in data-open:fade-in-0 data-open:zoom-in-95 data-closed:animate-out data-closed:duration-(--duration-fast) data-closed:fade-out-0 data-closed:zoom-out-95 motion-reduce:animate-none",
"data-[side=bottom]:slide-in-from-top-1 data-[side=top]:slide-in-from-bottom-1",
className
)}
{...props}
/>
</ComboboxPrimitive.Positioner>
</ComboboxPrimitive.Portal>
)
}
function ComboboxList({ className, ...props }: ComboboxPrimitive.List.Props) {
return (
<ComboboxPrimitive.List
data-slot="combobox-list"
className={cn(
"max-h-[min(20rem,calc(var(--available-height)-2rem))] scroll-py-1.5 overflow-y-auto overscroll-contain p-1.5 data-empty:p-0",
className
)}
{...props}
/>
)
}
function ComboboxItem({ className, children, ...props }: ComboboxPrimitive.Item.Props) {
return (
<ComboboxPrimitive.Item
data-slot="combobox-item"
className={cn(
"relative flex min-h-9 w-full cursor-default items-center gap-2 rounded-[calc(var(--radius-button)-4px)] py-1.5 ps-2.5 pe-9 text-[0.9375rem] outline-hidden transition-colors duration-(--duration-fast) select-none",
"data-highlighted:bg-foreground/6 data-selected:font-medium data-disabled:pointer-events-none data-disabled:opacity-50",
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ComboboxPrimitive.ItemIndicator
render={<span className="pointer-events-none absolute end-2.5 flex size-4 items-center justify-center" />}
>
<CheckIcon className="pointer-events-none text-foreground!" />
</ComboboxPrimitive.ItemIndicator>
</ComboboxPrimitive.Item>
)
}
function ComboboxGroup({ className, ...props }: ComboboxPrimitive.Group.Props) {
return <ComboboxPrimitive.Group data-slot="combobox-group" className={cn(className)} {...props} />
}
function ComboboxLabel({ className, ...props }: ComboboxPrimitive.GroupLabel.Props) {
return (
<ComboboxPrimitive.GroupLabel
data-slot="combobox-label"
className={cn(
"px-2.5 pt-2 pb-1 text-[0.6875rem] font-medium tracking-[0.08em] text-muted-foreground uppercase",
className
)}
{...props}
/>
)
}
function ComboboxCollection({ ...props }: ComboboxPrimitive.Collection.Props) {
return <ComboboxPrimitive.Collection data-slot="combobox-collection" {...props} />
}
function ComboboxEmpty({ className, ...props }: ComboboxPrimitive.Empty.Props) {
return (
<ComboboxPrimitive.Empty
data-slot="combobox-empty"
className={cn(
"hidden w-full justify-center px-3 py-6 text-center text-[0.9375rem] text-muted-foreground group-data-empty/combobox-content:flex",
className
)}
{...props}
/>
)
}
function ComboboxSeparator({ className, ...props }: ComboboxPrimitive.Separator.Props) {
return (
<ComboboxPrimitive.Separator
data-slot="combobox-separator"
className={cn("-mx-1.5 my-1.5 h-px bg-border", className)}
{...props}
/>
)
}
/** Multi-select field: chosen values sit inside the field as removable chips. */
function ComboboxChips({
className,
...props
}: React.ComponentPropsWithRef<typeof ComboboxPrimitive.Chips> & ComboboxPrimitive.Chips.Props) {
return (
<ComboboxPrimitive.Chips
data-slot="combobox-chips"
className={cn(
"flex min-h-[2.625rem] flex-wrap items-center gap-1.5 rounded-button border border-input bg-card/70 bg-clip-padding px-3.5 py-1.5 text-[0.9375rem]",
"transition-[border-color,box-shadow,background-color] duration-(--duration-base) ease-paragon hover:border-foreground/35",
"focus-within:border-ring focus-within:bg-card focus-within:ring-4 focus-within:ring-ring/15 has-aria-invalid:border-destructive has-aria-invalid:focus-within:ring-destructive/15",
"has-data-[slot=combobox-chip]:ps-1.5 dark:bg-input/15",
className
)}
{...props}
/>
)
}
function ComboboxChip({
className,
children,
showRemove = true,
...props
}: ComboboxPrimitive.Chip.Props & {
showRemove?: boolean
}) {
return (
<ComboboxPrimitive.Chip
data-slot="combobox-chip"
className={cn(
"flex h-7 w-fit animate-in items-center justify-center gap-1 rounded-full bg-secondary ps-2.5 text-[0.8125rem] font-medium whitespace-nowrap text-foreground duration-(--duration-base) zoom-in-90 fade-in has-disabled:pointer-events-none has-disabled:opacity-50 has-data-[slot=combobox-chip-remove]:pe-0.5 motion-reduce:animate-none",
className
)}
{...props}
>
{children}
{showRemove && (
<ComboboxPrimitive.ChipRemove
render={<Button variant="ghost" size="icon-xs" className="size-6 rounded-full" />}
className="opacity-60 hover:opacity-100"
data-slot="combobox-chip-remove"
aria-label="Remove"
>
<XIcon className="pointer-events-none size-3.5" />
</ComboboxPrimitive.ChipRemove>
)}
</ComboboxPrimitive.Chip>
)
}
function ComboboxChipsInput({ className, ...props }: ComboboxPrimitive.Input.Props) {
return (
<ComboboxPrimitive.Input
data-slot="combobox-chip-input"
className={cn("h-7 min-w-24 flex-1 bg-transparent outline-none placeholder:text-muted-foreground", className)}
{...props}
/>
)
}
function useComboboxAnchor() {
return React.useRef<HTMLDivElement | null>(null)
}
export {
Combobox,
ComboboxInput,
ComboboxContent,
ComboboxList,
ComboboxItem,
ComboboxGroup,
ComboboxLabel,
ComboboxCollection,
ComboboxEmpty,
ComboboxSeparator,
ComboboxChips,
ComboboxChip,
ComboboxChipsInput,
ComboboxTrigger,
ComboboxValue,
useComboboxAnchor,
}