Numbered pins placed over artwork that pulse once on mount, open origin-aware note cards, and cycle with arrow keys inside an AnnotationGroup.
npx shadcn@latest add @paragon/annotation-pinAlso installs: popover
"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>
);
}