Label Picker
Inputs & Forms

Label Picker

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.

Install

npx shadcn@latest add @paragon/label-picker

label-picker.tsx

"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>
  );
}