A popover multi-select with checkbox rows, an overflowing-chip summary with a rolling +N counter, and an apply/clear footer.
npx shadcn@latest add @paragon/multi-selectAlso installs: button
"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import * as CheckboxPrimitive from "@radix-ui/react-checkbox";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, ChevronsUpDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { Button } from "@/registry/paragon/ui/button";
const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
const EASE_EXIT: [number, number, number, number] = [0.4, 0, 1, 1];
export interface MultiSelectOption {
value: string;
label: string;
}
export interface MultiSelectProps {
options: MultiSelectOption[];
/** Controlled committed selection. */
value?: string[];
/** Initial selection when uncontrolled. */
defaultValue?: string[];
/** Fires when the selection is applied. */
onValueChange?: (values: string[]) => void;
placeholder?: string;
/** How many chips to show before collapsing into "+N more". */
maxVisible?: number;
disabled?: boolean;
/** Applied to the trigger for `htmlFor` wiring. */
id?: string;
/** Form field name; renders hidden inputs for each selected value. */
name?: string;
className?: string;
}
/** The overflow counter digit, rolling vertically when the count changes. */
function RollingCount({ value }: { value: number }) {
const reduced = useReducedMotion();
const prev = React.useRef(value);
const dir = value >= prev.current ? 1 : -1;
React.useEffect(() => {
prev.current = value;
});
const chars = String(value).split("");
return (
<span className="inline-flex overflow-hidden tabular-nums">
{chars.map((ch, i) => (
<span
key={`${chars.length}-${i}`}
className="relative inline-flex justify-center"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={ch}
initial={
reduced
? { opacity: 0 }
: { y: dir > 0 ? "100%" : "-100%", opacity: 0.25 }
}
animate={{ y: "0%", opacity: 1 }}
exit={
reduced
? { opacity: 0, transition: { duration: 0.1 } }
: {
y: dir > 0 ? "-100%" : "100%",
opacity: 0.25,
transition: { duration: 0.15, ease: EASE_EXIT },
}
}
transition={{ duration: 0.15, ease: EASE_OUT }}
className="inline-block"
>
{ch}
</motion.span>
</AnimatePresence>
</span>
))}
</span>
);
}
/**
* A multi-select in a popover with checkbox rows. Selected items summarize
* as chips on the trigger with a rolling "+N more" counter, and changes
* commit through an apply/clear footer.
*/
export function MultiSelect({
options,
value: valueProp,
defaultValue,
onValueChange,
placeholder = "Select options…",
maxVisible = 2,
disabled = false,
id,
name,
className,
}: MultiSelectProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const [open, setOpen] = React.useState(false);
const [uncontrolled, setUncontrolled] = React.useState<string[]>(
defaultValue ?? [],
);
const committed = valueProp ?? uncontrolled;
const [draft, setDraft] = React.useState<string[]>(committed);
const labelFor = (v: string) =>
options.find((opt) => opt.value === v)?.label ?? v;
const visible = committed.slice(0, maxVisible);
const overflow = committed.length - visible.length;
const apply = () => {
if (valueProp === undefined) setUncontrolled(draft);
onValueChange?.(draft);
setOpen(false);
};
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) setDraft(committed);
}}
>
<PopoverPrimitive.Trigger asChild>
<button
type="button"
id={id}
disabled={disabled}
className={cn(
"flex h-9 w-64 items-center justify-between gap-2 rounded-lg border border-input bg-transparent px-3 text-sm transition-[background-color,border-color] duration-150 ease-out hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50",
className,
)}
>
{committed.length === 0 ? (
<span className="truncate text-muted-foreground">
{placeholder}
</span>
) : (
<span className="flex min-w-0 items-center gap-1">
{visible.map((v) => (
<span
key={v}
className="truncate rounded-md bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground"
>
{labelFor(v)}
</span>
))}
{overflow > 0 && (
<span className="flex shrink-0 items-center gap-0.5 rounded-md bg-secondary px-1.5 py-0.5 text-xs font-medium text-secondary-foreground">
+<RollingCount value={overflow} /> more
</span>
)}
</span>
)}
<ChevronsUpDown
className="size-4 shrink-0 text-muted-foreground"
aria-hidden
/>
</button>
</PopoverPrimitive.Trigger>
{/* Hoisted outside the Portal: React 19 keeps a hoistable <style> as a
child node, and the Radix Portal enforces a single child. */}
<style href={`paragon-multi-select-${uid}`} precedence="paragon">{`
@keyframes msel-in-${uid} { from { opacity: 0; transform: scale(0.97); } }
@keyframes msel-out-${uid} { to { opacity: 0; transform: scale(0.99); } }
[data-msel="${uid}"][data-state="open"] { animation: msel-in-${uid} 150ms var(--ease-out); }
[data-msel="${uid}"][data-state="closed"] { animation: msel-out-${uid} 100ms var(--ease-exit) forwards; }
@media (prefers-reduced-motion: reduce) {
@keyframes msel-in-${uid} { from { opacity: 0; } }
@keyframes msel-out-${uid} { to { opacity: 0; } }
}
`}</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-msel={uid}
align="start"
sideOffset={6}
className="z-50 w-[var(--radix-popover-trigger-width)] overflow-hidden rounded-lg bg-popover text-popover-foreground shadow-overlay"
style={{
transformOrigin: "var(--radix-popover-content-transform-origin)",
}}
>
<div className="max-h-64 overflow-y-auto p-1" role="group" aria-label="Options">
{options.map((opt) => {
const checked = draft.includes(opt.value);
return (
<label
key={opt.value}
className="flex cursor-pointer items-center gap-2.5 rounded-md px-2 py-1.5 text-sm transition-colors duration-100 hover:bg-accent"
>
<CheckboxPrimitive.Root
checked={checked}
onCheckedChange={(next) =>
setDraft((prev) =>
next === true
? [...prev, opt.value]
: prev.filter((v) => v !== opt.value),
)
}
className="flex size-4 shrink-0 items-center justify-center rounded-[4px] border border-input bg-transparent transition-[background-color,border-color] duration-100 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground"
>
<CheckboxPrimitive.Indicator className="transition-[scale,opacity] duration-150 ease-out starting:scale-50 starting:opacity-0 motion-reduce:transition-none">
<Check className="size-3" aria-hidden />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
<span className="truncate">{opt.label}</span>
</label>
);
})}
</div>
<div className="flex items-center justify-between gap-2 border-t p-2">
<Button
variant="ghost"
size="sm"
onClick={() => setDraft([])}
disabled={draft.length === 0}
>
Clear
</Button>
<Button size="sm" onClick={apply}>
Apply
{draft.length > 0 && (
<span className="tabular-nums">({draft.length})</span>
)}
</Button>
</div>
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
{name &&
committed.map((v) => (
<input key={v} type="hidden" name={name} value={v} />
))}
</PopoverPrimitive.Root>
);
}