Estimate Picker
Inputs & Forms

Estimate Picker

Points or t-shirt estimate picker with size-scaled glyphs, scroll-wheel and arrow cycling on the closed chip with a rolling digit, and a team-average hint.

Install

npx shadcn@latest add @paragon/estimate-picker

estimate-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, Search } from "lucide-react";
import { cn } from "@/lib/utils";

export interface EstimateOption {
  id: string;
  label: string;
  /** Numeric points — used for quick keys and the average hint. */
  points?: number;
}

const POINTS: EstimateOption[] = [
  { id: "none", label: "No estimate" },
  { id: "1", label: "1 point", points: 1 },
  { id: "2", label: "2 points", points: 2 },
  { id: "3", label: "3 points", points: 3 },
  { id: "5", label: "5 points", points: 5 },
  { id: "8", label: "8 points", points: 8 },
];

const TSHIRT: EstimateOption[] = [
  { id: "none", label: "No estimate" },
  { id: "xs", label: "XS", points: 1 },
  { id: "s", label: "S", points: 2 },
  { id: "m", label: "M", points: 3 },
  { id: "l", label: "L", points: 5 },
  { id: "xl", label: "XL", points: 8 },
];

/**
 * Size-scaled estimate glyph: a rounded square that grows with the
 * option's magnitude; "no estimate" is a dashed outline.
 */
export function EstimateGlyph({
  step,
  of = 5,
  none = false,
  className,
  ...props
}: React.ComponentProps<"svg"> & {
  /** 1-based magnitude step. */
  step?: number;
  /** Total steps in the scale. */
  of?: number;
  none?: boolean;
}) {
  const idx = Math.max(1, Math.min(of, step ?? 1));
  const side = 5 + ((idx - 1) / Math.max(1, of - 1)) * 8; // 5 → 13
  const off = (16 - side) / 2;
  return (
    <svg
      viewBox="0 0 16 16"
      className={cn("size-4 shrink-0 text-muted-foreground", className)}
      aria-hidden
      {...props}
    >
      {none ? (
        <rect
          x="4.5"
          y="4.5"
          width="7"
          height="7"
          rx="2"
          fill="none"
          stroke="currentColor"
          strokeWidth="1.3"
          strokeDasharray="2 2"
        />
      ) : (
        <rect
          x={off}
          y={off}
          width={side}
          height={side}
          rx={1.5 + idx * 0.3}
          fill="currentColor"
          opacity={0.5 + idx * 0.1}
        />
      )}
    </svg>
  );
}

const estimatePickerStyles = `
@keyframes pg-estimate-in { from { opacity: 0; scale: 0.97; } }
@keyframes pg-estimate-out { to { opacity: 0; scale: 0.99; } }
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-estimate-in { from { opacity: 0; } }
  @keyframes pg-estimate-out { to { opacity: 0; } }
}
`;

export interface EstimatePickerProps
  extends Omit<
    React.ComponentProps<"button">,
    "value" | "defaultValue" | "onChange"
  > {
  /** Estimate scale. Defaults to exponential points. */
  options?: EstimateOption[];
  /** Convenience switch between the built-in scales. */
  scale?: "points" | "tshirt";
  /** Controlled selected option id. */
  value?: string;
  defaultValue?: string;
  onValueChange?: (id: string) => void;
  /** Team average, e.g. 3.2 — renders the hint line. */
  teamAverage?: number;
  /** Cycle values with the scroll wheel over the closed chip. */
  wheelCycle?: boolean;
  /** Disables the digit-roll motion. */
  static?: boolean;
}

/**
 * Estimate picker: option rows with size-scaled glyphs and a team-average
 * hint line. The closed chip cycles with arrow keys or the scroll wheel —
 * the value digit rolls vertically in the direction of change. Digits
 * quick-select matching point values inside the open list.
 */
export function EstimatePicker({
  options,
  scale = "points",
  value,
  defaultValue = "none",
  onValueChange,
  teamAverage,
  wheelCycle = true,
  static: isStatic = false,
  className,
  disabled,
  ...props
}: EstimatePickerProps) {
  const uid = React.useId().replace(/[^a-zA-Z0-9-]/g, "");
  const reduced = useReducedMotion();
  const opts = options ?? (scale === "tshirt" ? TSHIRT : POINTS);
  const [open, setOpen] = React.useState(false);
  const [query, setQuery] = React.useState("");
  const [internal, setInternal] = React.useState(defaultValue);
  const selectedId = value ?? internal;
  const selectedIndex = Math.max(0, opts.findIndex((o) => o.id === selectedId));
  const selected = opts[selectedIndex];
  const [dir, setDir] = React.useState(1);
  const listRef = React.useRef<HTMLDivElement>(null);
  const triggerRef = React.useRef<HTMLButtonElement>(null);

  const commit = React.useCallback(
    (id: string, close = false) => {
      setInternal(id);
      onValueChange?.(id);
      if (close) setOpen(false);
    },
    [onValueChange],
  );

  const cycle = React.useCallback(
    (delta: number) => {
      const next = Math.max(0, Math.min(opts.length - 1, selectedIndex + delta));
      if (next === selectedIndex) return;
      setDir(delta > 0 ? 1 : -1);
      commit(opts[next].id);
    },
    [opts, selectedIndex, commit],
  );

  // Scroll-wheel cycling on the closed chip. Native listener: React wheel
  // handlers are passive, so preventDefault must be attached manually.
  const cycleRef = React.useRef(cycle);
  cycleRef.current = cycle;
  const lastWheel = React.useRef(0);
  React.useEffect(() => {
    const el = triggerRef.current;
    if (!el || !wheelCycle || isStatic) return;
    const onWheel = (e: WheelEvent) => {
      e.preventDefault();
      const t = performance.now();
      if (t - lastWheel.current < 90) return;
      lastWheel.current = t;
      cycleRef.current(e.deltaY > 0 ? 1 : -1);
    };
    el.addEventListener("wheel", onWheel, { passive: false });
    return () => el.removeEventListener("wheel", onWheel);
  }, [wheelCycle, isStatic]);

  const visible = React.useMemo(() => {
    const q = query.trim().toLowerCase();
    return q ? opts.filter((o) => o.label.toLowerCase().includes(q)) : opts;
  }, [opts, query]);

  const [active, setActive] = React.useState(selectedId);
  const activeIndex = visible.findIndex((o) => o.id === active);

  React.useEffect(() => {
    if (!open) return;
    listRef.current
      ?.querySelector('[data-active="true"]')
      ?.scrollIntoView({ block: "nearest" });
  }, [active, open]);

  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) {
        setDir(opts.indexOf(target) > selectedIndex ? 1 : -1);
        commit(target.id, true);
      }
    } else if (/^[0-9]$/.test(e.key) && query === "") {
      // Digits match point values; 0 selects "no estimate".
      e.preventDefault();
      const n = Number(e.key);
      const target =
        n === 0
          ? opts.find((o) => o.points === undefined)
          : opts.find((o) => o.points === n);
      if (target) {
        setDir(opts.indexOf(target) > selectedIndex ? 1 : -1);
        commit(target.id, true);
      }
    }
  };

  const magnitudes = opts.filter((o) => o.points !== undefined);
  const stepOf = (o: EstimateOption) =>
    o.points === undefined ? 0 : magnitudes.indexOf(o) + 1;

  const animate = !isStatic && !reduced;
  const unit = scale === "tshirt" ? "" : " pts";

  return (
    <PopoverPrimitive.Root
      open={open}
      onOpenChange={(next) => {
        setOpen(next);
        if (next) {
          setQuery("");
          setActive(selectedId);
        }
      }}
    >
      <PopoverPrimitive.Trigger asChild disabled={disabled}>
        <button
          ref={triggerRef}
          type="button"
          data-slot="estimate-picker"
          aria-label={`Estimate: ${selected?.label ?? "none"}`}
          onKeyDown={(e) => {
            if (open || isStatic) return;
            if (e.key === "ArrowUp" || e.key === "ArrowDown") {
              e.preventDefault();
              cycle(e.key === "ArrowDown" ? 1 : -1);
            }
          }}
          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}
        >
          <EstimateGlyph
            step={stepOf(selected)}
            of={magnitudes.length}
            none={selected?.points === undefined}
          />
          <span className="relative overflow-hidden">
            <AnimatePresence mode="popLayout" initial={false}>
              <motion.span
                key={selected?.id ?? "none"}
                initial={{
                  opacity: 0,
                  y: animate ? dir * 9 : 0,
                  filter: animate ? "blur(2px)" : "blur(0px)",
                }}
                animate={{ opacity: 1, y: 0, filter: "blur(0px)" }}
                exit={{
                  opacity: 0,
                  y: animate ? dir * -9 : 0,
                  filter: animate ? "blur(2px)" : "blur(0px)",
                  transition: { duration: 0.1 },
                }}
                transition={{ type: "spring", duration: 0.25, bounce: 0 }}
                className={cn(
                  "block tabular-nums",
                  selected?.points === undefined && "text-muted-foreground",
                )}
              >
                {selected?.points === undefined
                  ? "Estimate"
                  : scale === "tshirt"
                    ? selected.label
                    : `${selected.points}${unit}`}
              </motion.span>
            </AnimatePresence>
          </span>
          <span aria-live="polite" className="sr-only">
            {selected?.label}
          </span>
        </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-estimate-picker" precedence="paragon">
        {estimatePickerStyles}
      </style>
      <PopoverPrimitive.Portal>
        <PopoverPrimitive.Content
          align="start"
          sideOffset={6}
          collisionPadding={8}
          className={cn(
            "z-50 w-52 origin-(--radix-popover-content-transform-origin) rounded-lg bg-popover text-popover-foreground shadow-overlay outline-none",
            "data-[state=open]:animate-[pg-estimate-in_170ms_var(--ease-out)]",
            "data-[state=closed]:animate-[pg-estimate-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
              autoFocus
              value={query}
              onChange={(e) => {
                setQuery(e.target.value);
                setActive("");
              }}
              onKeyDown={onSearchKeyDown}
              placeholder="Change estimate…"
              role="combobox"
              aria-expanded="true"
              aria-controls={`estimate-list-${uid}`}
              aria-activedescendant={
                activeIndex >= 0 ? `estimate-opt-${uid}-${active}` : undefined
              }
              aria-label="Filter estimates"
              className="h-8 w-full bg-transparent text-xs outline-none placeholder:text-muted-foreground"
            />
          </div>
          <div
            ref={listRef}
            id={`estimate-list-${uid}`}
            role="listbox"
            aria-label="Estimate"
            className="p-1"
            onPointerLeave={() => setActive("")}
          >
            {visible.length === 0 && (
              <p className="px-2 py-4 text-center text-xs text-muted-foreground">
                No matching estimate.
              </p>
            )}
            {visible.map((o) => {
              const isActive = o.id === active;
              const isSelected = o.id === selectedId;
              return (
                <button
                  key={o.id}
                  type="button"
                  role="option"
                  id={`estimate-opt-${uid}-${o.id}`}
                  aria-selected={isSelected}
                  data-active={isActive || undefined}
                  tabIndex={-1}
                  onPointerMove={() => setActive(o.id)}
                  onClick={() => {
                    setDir(opts.indexOf(o) > selectedIndex ? 1 : -1);
                    commit(o.id, true);
                  }}
                  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",
                  )}
                >
                  <EstimateGlyph
                    step={stepOf(o)}
                    of={magnitudes.length}
                    none={o.points === undefined}
                  />
                  <span className="min-w-0 flex-1 truncate">{o.label}</span>
                  <span className="flex size-3.5 shrink-0 items-center justify-center">
                    {isSelected && <Check className="size-3.5" aria-hidden />}
                  </span>
                  <span
                    aria-hidden
                    className="w-3 shrink-0 text-right font-mono text-[10px] text-muted-foreground/70 tabular-nums"
                  >
                    {o.points ?? 0}
                  </span>
                </button>
              );
            })}
          </div>
          {teamAverage !== undefined && (
            <div className="border-t border-border px-3 py-2">
              <p className="flex items-center justify-between text-[11px] text-muted-foreground">
                <span>Team average</span>
                <span className="tabular-nums">
                  {teamAverage.toFixed(1)}
                  {scale === "tshirt" ? "" : " pts"}
                </span>
              </p>
            </div>
          )}
        </PopoverPrimitive.Content>
      </PopoverPrimitive.Portal>
    </PopoverPrimitive.Root>
  );
}