Multi-select label picker with color-dot checkbox rows, a stacked-dots count trigger, and an inline create row that morphs into a name and color mini-form.
npx shadcn@latest add @paragon/label-picker"use client";
import * as React from "react";
import * as PopoverPrimitive from "@radix-ui/react-popover";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { Check, Plus, Search, Tag } from "lucide-react";
import { cn } from "@/lib/utils";
export interface LabelOption {
id: string;
name: string;
/** Any CSS color for the dot. */
color: string;
}
const DEFAULT_LABELS: LabelOption[] = [
{ id: "bug", name: "Bug", color: "#e5484d" },
{ id: "feature", name: "Feature", color: "#6e56cf" },
{ id: "design", name: "Design", color: "#0091ff" },
{ id: "performance", name: "Performance", color: "#f76b15" },
{ id: "docs", name: "Docs", color: "#12a594" },
{ id: "infra", name: "Infra", color: "#ffb224" },
];
const SWATCHES = [
"#e5484d",
"#f76b15",
"#ffb224",
"#46a758",
"#12a594",
"#0091ff",
"#6e56cf",
"#e93d82",
];
const labelPickerStyles = `
@keyframes pg-label-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-label-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
@keyframes pg-label-in { from { opacity: 0; } }
@keyframes pg-label-out { to { opacity: 0; } }
}
`;
function slugOf(name: string): string {
return name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
}
export interface LabelPickerProps
extends Omit<
React.ComponentProps<"button">,
"value" | "defaultValue" | "onChange" | "onDrag" | "onDragStart" | "onDragEnd" | "onAnimationStart"
> {
/** Available labels. Created labels are appended internally. */
labels?: LabelOption[];
/** Controlled selected label ids. */
value?: string[];
defaultValue?: string[];
onValueChange?: (ids: string[]) => void;
/** Called when the inline mini-form creates a label. */
onCreate?: (label: LabelOption) => void;
/** Show the inline "Create label" row. */
allowCreate?: boolean;
/** Show the count next to the stacked dots. */
showCount?: boolean;
/** Disables trigger swap motion. */
static?: boolean;
}
/**
* Multi-select label picker: checkbox rows with color dots that toggle
* without closing, and an inline "Create label" row that morphs in place
* into a name + color mini-form (the swatch rail unfolds via the
* grid-rows trick). The trigger stacks the selected dots and counts them.
*/
export function LabelPicker({
labels = DEFAULT_LABELS,
value,
defaultValue,
onValueChange,
onCreate,
allowCreate = true,
showCount = true,
static: isStatic = false,
className,
disabled,
...props
}: LabelPickerProps) {
const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
const reduced = useReducedMotion();
const [open, setOpen] = React.useState(false);
const [query, setQuery] = React.useState("");
const [created, setCreated] = React.useState<LabelOption[]>([]);
const [internal, setInternal] = React.useState<string[]>(defaultValue ?? []);
const ids = value ?? internal;
const listRef = React.useRef<HTMLDivElement>(null);
// Inline create form state.
const [creating, setCreating] = React.useState(false);
const [draftName, setDraftName] = React.useState("");
const [draftColor, setDraftColor] = React.useState(SWATCHES[5]);
const searchRef = React.useRef<HTMLInputElement>(null);
const all = React.useMemo(() => [...labels, ...created], [labels, created]);
const commit = (next: string[]) => {
setInternal(next);
onValueChange?.(next);
};
const visible = React.useMemo(() => {
const q = query.trim().toLowerCase();
return q ? all.filter((l) => l.name.toLowerCase().includes(q)) : all;
}, [all, query]);
const [active, setActive] = React.useState<string>("");
const activeIndex = visible.findIndex((l) => l.id === active);
React.useEffect(() => {
if (!open) return;
listRef.current
?.querySelector('[data-active="true"]')
?.scrollIntoView({ block: "nearest" });
}, [active, open]);
const toggle = (id: string) => {
commit(ids.includes(id) ? ids.filter((i) => i !== id) : [...ids, id]);
};
const startCreate = () => {
setDraftName(query.trim());
const used = new Set(all.map((l) => l.color.toLowerCase()));
setDraftColor(SWATCHES.find((s) => !used.has(s)) ?? SWATCHES[5]);
setCreating(true);
};
const cancelCreate = () => {
setCreating(false);
setDraftName("");
searchRef.current?.focus();
};
const submitCreate = () => {
const name = draftName.trim();
if (!name) return;
const base = slugOf(name) || `label-${created.length + 1}`;
const taken = new Set(all.map((l) => l.id));
let id = base;
let n = 2;
while (taken.has(id)) id = `${base}-${n++}`;
const label: LabelOption = { id, name, color: draftColor };
setCreated((prev) => [...prev, label]);
onCreate?.(label);
commit([...ids, id]);
setQuery("");
cancelCreate();
};
const move = (delta: number) => {
if (visible.length === 0) return;
const next =
(Math.max(0, activeIndex) + delta + visible.length) % visible.length;
setActive(visible[next].id);
};
const onSearchKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowDown") {
e.preventDefault();
move(1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
move(-1);
} else if (e.key === "Enter") {
e.preventDefault();
const target = visible[activeIndex] ?? visible[0];
if (target) toggle(target.id);
else if (allowCreate && query.trim()) startCreate();
}
};
const selectedLabels = ids
.map((id) => all.find((l) => l.id === id))
.filter((l): l is LabelOption => Boolean(l));
const exactMatch = all.some(
(l) => l.name.toLowerCase() === query.trim().toLowerCase(),
);
const animate = !isStatic && !reduced;
const blur = animate ? "blur(4px)" : "blur(0px)";
return (
<PopoverPrimitive.Root
open={open}
onOpenChange={(next) => {
setOpen(next);
if (next) {
setQuery("");
setActive(selectedLabels[0]?.id ?? "");
} else {
setCreating(false);
}
}}
>
<PopoverPrimitive.Trigger asChild disabled={disabled}>
<motion.button
type="button"
layout={animate}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
data-slot="label-picker"
aria-label={
selectedLabels.length === 0
? "Add labels"
: `Labels: ${selectedLabels.map((l) => l.name).join(", ")}`
}
className={cn(
"group inline-flex h-7 items-center gap-1.5 rounded-md border border-input bg-transparent px-2 text-xs font-medium whitespace-nowrap",
"transition-[background-color,border-color,box-shadow,scale] duration-150 ease-(--ease-out)",
"outline-none hover:bg-accent/50 focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/25",
"data-[state=open]:border-ring disabled:pointer-events-none disabled:opacity-50",
!isStatic && "active:not-disabled:scale-[0.97]",
className,
)}
{...props}
>
{selectedLabels.length === 0 ? (
<>
<Tag className="size-3.5 text-muted-foreground" aria-hidden />
<span className="text-muted-foreground">Labels</span>
</>
) : (
<>
<span aria-hidden className="flex items-center -space-x-1">
<AnimatePresence mode="popLayout" initial={false}>
{selectedLabels.slice(0, 4).map((l) => (
<motion.span
key={l.id}
layout={animate}
initial={{ opacity: 0, scale: 0.4, filter: blur }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, scale: 0.4, filter: blur }}
transition={{ type: "spring", duration: 0.3, bounce: 0 }}
className="size-2.5 rounded-full ring-2 ring-background"
style={{ backgroundColor: l.color }}
/>
))}
</AnimatePresence>
</span>
{showCount && (
<AnimatePresence mode="popLayout" initial={false}>
<motion.span
key={selectedLabels.length === 1 ? selectedLabels[0].id : selectedLabels.length}
layout={animate ? "position" : false}
initial={{ opacity: 0, filter: blur }}
animate={{ opacity: 1, filter: "blur(0px)" }}
exit={{ opacity: 0, filter: blur, transition: { duration: 0.1 } }}
transition={{ duration: 0.18, ease: [0.22, 1, 0.36, 1] }}
className="tabular-nums"
>
{selectedLabels.length === 1
? selectedLabels[0].name
: `${selectedLabels.length} labels`}
</motion.span>
</AnimatePresence>
)}
</>
)}
</motion.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-label-picker" precedence="paragon">
{labelPickerStyles}
</style>
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
align="start"
sideOffset={6}
collisionPadding={8}
className={cn(
"z-50 w-60 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover text-popover-foreground shadow-overlay outline-none",
"data-[state=open]:animate-[pg-label-in_180ms_var(--ease-out)]",
"data-[state=closed]:animate-[pg-label-out_90ms_var(--ease-exit)_forwards]",
)}
>
<div className="flex items-center gap-2 border-b border-border px-2.5">
<Search className="size-3.5 shrink-0 text-muted-foreground" aria-hidden />
<input
ref={searchRef}
autoFocus
value={query}
onChange={(e) => {
setQuery(e.target.value);
setActive("");
}}
onKeyDown={onSearchKeyDown}
placeholder="Add labels…"
role="combobox"
aria-expanded="true"
aria-controls={`label-list-${uid}`}
aria-activedescendant={
activeIndex >= 0 ? `label-opt-${uid}-${active}` : undefined
}
aria-label="Filter labels"
className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
<div
ref={listRef}
id={`label-list-${uid}`}
role="listbox"
aria-multiselectable
aria-label="Labels"
className="max-h-64 overflow-y-auto p-1"
onPointerLeave={() => setActive("")}
>
{visible.length === 0 && !allowCreate && (
<p className="px-2 py-4 text-center text-xs text-muted-foreground">
No labels match.
</p>
)}
{visible.map((l) => {
const isActive = l.id === active;
const isSelected = ids.includes(l.id);
return (
<button
key={l.id}
type="button"
role="option"
id={`label-opt-${uid}-${l.id}`}
aria-selected={isSelected}
data-active={isActive || undefined}
tabIndex={-1}
onPointerMove={() => setActive(l.id)}
onClick={() => toggle(l.id)}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs outline-none",
"transition-[background-color] duration-100 ease-(--ease-out)",
isActive && "bg-accent text-accent-foreground",
)}
>
<span
aria-hidden
className={cn(
"flex size-3.5 shrink-0 items-center justify-center rounded-[4px] border",
"transition-[background-color,border-color] duration-150 ease-(--ease-out)",
isSelected
? "border-primary bg-primary text-primary-foreground"
: "border-input bg-transparent",
)}
>
<Check
className={cn(
"size-2.5 transition-[scale,opacity] duration-150 ease-(--ease-out) motion-reduce:scale-100",
isSelected ? "scale-100 opacity-100" : "scale-50 opacity-0",
)}
strokeWidth={3}
/>
</span>
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full"
style={{ backgroundColor: l.color }}
/>
<span className="min-w-0 flex-1 truncate">{l.name}</span>
</button>
);
})}
</div>
{allowCreate && (
<div className="border-t border-border p-1">
{!creating ? (
<button
type="button"
onClick={startCreate}
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-xs text-muted-foreground outline-none",
"transition-[background-color,color] duration-100 ease-(--ease-out)",
"hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground",
)}
>
<Plus className="size-3.5 shrink-0" aria-hidden />
<span className="min-w-0 flex-1 truncate">
{query.trim() && !exactMatch
? `Create label "${query.trim()}"`
: "Create label"}
</span>
</button>
) : (
<div className="px-1 py-0.5">
{/* Row morph: the label text becomes an input in place. */}
<div className="flex items-center gap-2 px-1 py-1">
<span
aria-hidden
className="size-2.5 shrink-0 rounded-full transition-[background-color] duration-150 ease-(--ease-out)"
style={{ backgroundColor: draftColor }}
/>
<input
autoFocus
value={draftName}
onChange={(e) => setDraftName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submitCreate();
} else if (e.key === "Escape") {
// Cancel the form only — keep the popover open.
e.preventDefault();
e.stopPropagation();
cancelCreate();
}
}}
placeholder="Label name…"
aria-label="New label name"
className="h-6 w-full min-w-0 flex-1 bg-transparent text-xs outline-none placeholder:text-muted-foreground"
/>
</div>
{/* Swatch rail unfolds via the grid-rows accordion trick. */}
<div
className="grid grid-rows-[1fr] motion-safe:starting:grid-rows-[0fr] transition-[grid-template-rows] duration-200 ease-(--ease-out)"
>
<div className="overflow-hidden">
<div className="flex items-center gap-1 px-1 pt-1 pb-1.5">
<span role="radiogroup" aria-label="Label color" className="flex flex-1 items-center gap-1">
{SWATCHES.map((color) => {
const activeSwatch = color === draftColor;
return (
<button
key={color}
type="button"
role="radio"
aria-checked={activeSwatch}
aria-label={`Color ${color}`}
onClick={() => setDraftColor(color)}
className={cn(
"relative flex size-4 items-center justify-center rounded-full outline-none",
"transition-[scale] duration-150 ease-(--ease-out) motion-reduce:scale-100",
"after:absolute after:top-1/2 after:left-1/2 after:size-6 after:-translate-1/2",
"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-popover",
activeSwatch ? "scale-110" : "hover:scale-110 active:scale-95",
)}
>
<span
aria-hidden
className="size-3 rounded-full"
style={{ backgroundColor: color }}
/>
<span
aria-hidden
className={cn(
"absolute inset-0 rounded-full ring-1 ring-foreground/40 transition-opacity duration-150 ease-(--ease-out)",
activeSwatch ? "opacity-100" : "opacity-0",
)}
/>
</button>
);
})}
</span>
<button
type="button"
onClick={submitCreate}
disabled={!draftName.trim()}
className={cn(
"pressable flex h-5 shrink-0 items-center rounded-[5px] bg-primary px-1.5 text-[10px] font-medium text-primary-foreground",
"transition-[opacity] duration-150 ease-(--ease-out)",
"outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-popover",
"disabled:pointer-events-none disabled:opacity-40",
)}
>
Create
</button>
</div>
</div>
</div>
</div>
)}
</div>
)}
</PopoverPrimitive.Content>
</PopoverPrimitive.Portal>
</PopoverPrimitive.Root>
);
}