Annotation Pin
Overlays

Annotation Pin

Numbered pins placed over artwork that pulse once on mount, open origin-aware note cards, and cycle with arrow keys inside an AnnotationGroup.

Install

npx shadcn@latest add @paragon/annotation-pin

Also installs: popover

annotation-pin.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";
import {
  Popover,
  PopoverContent,
  PopoverTrigger,
} from "@/registry/paragon/ui/popover";

const pinStyles = `
@keyframes pg-annotation-pulse {
  from { opacity: 0.5; scale: 1; }
  to { opacity: 0; scale: 2.25; }
}
@media (prefers-reduced-motion: reduce) {
  @keyframes pg-annotation-pulse {
    from { opacity: 0.4; scale: 1; }
    to { opacity: 0; scale: 1; }
  }
}
`;

type PinRegistry = {
  register: (order: number, el: HTMLButtonElement) => () => void;
  focusRelative: (order: number, delta: 1 | -1) => void;
  focusEdge: (edge: "first" | "last") => void;
};

const AnnotationContext = React.createContext<PinRegistry | null>(null);

export interface AnnotationGroupProps extends React.ComponentProps<"div"> {}

/**
 * Positioning context for annotation pins. Wrap the annotated artwork
 * (screenshot, chart, mock) and drop `AnnotationPin`s alongside it; the
 * group provides the relative frame and makes the set keyboard-cyclable —
 * arrow keys move between pins in number order, Home/End jump to the edges.
 */
export function AnnotationGroup({
  className,
  children,
  ...props
}: AnnotationGroupProps) {
  const pins = React.useRef(new Map<number, HTMLButtonElement>());

  const registry = React.useMemo<PinRegistry>(() => {
    const ordered = () =>
      [...pins.current.entries()].sort(([a], [b]) => a - b).map(([, el]) => el);
    return {
      register: (order, el) => {
        pins.current.set(order, el);
        return () => {
          pins.current.delete(order);
        };
      },
      focusRelative: (order, delta) => {
        const keys = [...pins.current.keys()].sort((a, b) => a - b);
        const index = keys.indexOf(order);
        if (index === -1) return;
        const next = keys[(index + delta + keys.length) % keys.length];
        if (next !== undefined) pins.current.get(next)?.focus();
      },
      focusEdge: (edge) => {
        const els = ordered();
        (edge === "first" ? els[0] : els[els.length - 1])?.focus();
      },
    };
  }, []);

  return (
    <AnnotationContext.Provider value={registry}>
      <div
        data-slot="annotation-group"
        className={cn("relative", className)}
        {...props}
      >
        {children}
      </div>
    </AnnotationContext.Provider>
  );
}

export interface AnnotationPinProps {
  /** Number shown inside the pin; also its position in the cycle order. */
  number?: number;
  /** Horizontal position within the group, in percent. */
  x?: number;
  /** Vertical position within the group, in percent. */
  y?: number;
  /** Heading of the note card. */
  title?: React.ReactNode;
  /** Note body. */
  children?: React.ReactNode;
  /** Play the one-shot pulse ring on mount. */
  pulse?: boolean;
  /** Disables the press-scale feedback. */
  static?: boolean;
  className?: string;
}

/**
 * A numbered pin placed over a container's children (inside an
 * `AnnotationGroup`). Each pin announces itself with a one-shot pulse ring
 * on mount (staggered by number, gone forever after — it's a "look here"
 * cue, not ambient noise), and opens an origin-aware note card on click.
 * Pins are real buttons: focusable, arrow-key cyclable, Esc closes.
 */
export function AnnotationPin({
  number = 1,
  x = 50,
  y = 50,
  title,
  children,
  pulse = true,
  static: isStatic = false,
  className,
}: AnnotationPinProps) {
  const registry = React.useContext(AnnotationContext);
  const ref = React.useRef<HTMLButtonElement>(null);
  const [open, setOpen] = React.useState(false);

  React.useEffect(() => {
    const el = ref.current;
    if (!registry || !el) return;
    return registry.register(number, el);
  }, [registry, number]);

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <style href="paragon-annotation-pin" precedence="paragon">
        {pinStyles}
      </style>
      <PopoverTrigger asChild>
        <button
          ref={ref}
          type="button"
          aria-label={`Annotation ${number}${
            typeof title === "string" ? `: ${title}` : ""
          }`}
          onKeyDown={(event) => {
            if (!registry) return;
            if (event.key === "ArrowRight" || event.key === "ArrowDown") {
              event.preventDefault();
              registry.focusRelative(number, 1);
            } else if (event.key === "ArrowLeft" || event.key === "ArrowUp") {
              event.preventDefault();
              registry.focusRelative(number, -1);
            } else if (event.key === "Home") {
              event.preventDefault();
              registry.focusEdge("first");
            } else if (event.key === "End") {
              event.preventDefault();
              registry.focusEdge("last");
            }
          }}
          className={cn(
            "group absolute z-10 flex size-6 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full",
            "bg-primary text-[11px] font-semibold text-primary-foreground tabular-nums",
            "ring-2 ring-background",
            "transition-[scale,box-shadow] duration-150 ease-[var(--ease-out)]",
            "outline-none focus-visible:ring-ring",
            "after:absolute after:top-1/2 after:left-1/2 after:size-10 after:-translate-1/2",
            !isStatic &&
              "active:scale-95 data-[state=open]:scale-110 hover:scale-110 motion-reduce:hover:scale-100 motion-reduce:data-[state=open]:scale-100",
            className,
          )}
          style={{ left: `${x}%`, top: `${y}%` }}
        >
          {pulse && (
            <span
              aria-hidden
              className="pointer-events-none absolute inset-0 rounded-full bg-primary"
              style={{
                animation: `pg-annotation-pulse 1100ms var(--ease-out) ${number * 150}ms 1 both`,
              }}
            />
          )}
          <span className="relative">{number}</span>
        </button>
      </PopoverTrigger>
      <PopoverContent side="top" sideOffset={10} className="w-64 p-3.5">
        <div className="flex items-start gap-2.5">
          <span
            aria-hidden
            className="flex size-5 shrink-0 items-center justify-center rounded-full bg-primary text-[10px] font-semibold text-primary-foreground tabular-nums"
          >
            {number}
          </span>
          <div className="min-w-0 flex-1 pt-px">
            {title && (
              <p className="text-[13px] leading-4 font-medium">{title}</p>
            )}
            <div
              className={cn(
                "text-[13px] leading-5 text-muted-foreground",
                title && "mt-1",
              )}
            >
              {children}
            </div>
          </div>
        </div>
      </PopoverContent>
    </Popover>
  );
}