Point anchor
Anchors Tooltip, Popover and HoverCard to a point instead of a trigger: a map marker, a chart datum, a graph node or the pointer.
npx prodular-ui add point-anchorMap markers
Hover a marker for a surface tooltip, click for a popover. The same sites are buttons below the map for keyboard and screen reader users.
import * as React from "react"
import { Button } from "@/components/ui/button"import { Popover, PopoverContent, PopoverDescription, PopoverHeader, PopoverTitle } from "@/components/ui/popover"import { pointAnchor, type VirtualAnchor } from "@/components/ui/point-anchor"import { Severity, SeverityIcon, type SeverityLevel } from "@/components/ui/severity"import { Tooltip, TooltipContent } from "@/components/ui/tooltip"
// Stand-in for a MapLibre or canvas map: markers at pixel positions inside the container.const sites: { id: string; name: string; x: number; y: number; level: SeverityLevel }[] = [ { id: "fra", name: "Frankfurt", x: 0.52, y: 0.38, level: "high" }, { id: "ams", name: "Amsterdam", x: 0.46, y: 0.28, level: "routine" }, { id: "dub", name: "Dublin", x: 0.3, y: 0.3, level: "elevated" }, { id: "mad", name: "Madrid", x: 0.33, y: 0.66, level: "critical" },]
export default function PointAnchorMap() { const mapRef = React.useRef<HTMLDivElement>(null) // Anchors are built in event handlers, where reading the map container is allowed. const [hover, setHoverState] = React.useState<{ id: string; anchor: VirtualAnchor } | null>(null) const [open, setOpenState] = React.useState<{ id: string; anchor: VirtualAnchor } | null>(null) const [size, setSize] = React.useState({ w: 0, h: 0 }) React.useEffect(() => { const el = mapRef.current if (!el) return const ro = new ResizeObserver(() => setSize({ w: el.clientWidth, h: el.clientHeight })) ro.observe(el) return () => ro.disconnect() }, []) const px = (s: (typeof sites)[number]) => ({ x: s.x * size.w, y: s.y * size.h }) const anchorFor = (id: string) => { const s = sites.find((x) => x.id === id)! return { id, anchor: pointAnchor(px(s).x, px(s).y, mapRef.current) } } const setHover = (id: string | null) => setHoverState(id ? anchorFor(id) : null) const setOpen = (id: string | null) => setOpenState(id ? anchorFor(id) : null) const hovered = sites.find((s) => s.id === hover?.id) const opened = sites.find((s) => s.id === open?.id)
return ( <div className="flex w-full max-w-xl flex-col gap-2"> <div ref={mapRef} className="relative h-72 overflow-hidden rounded-lg border bg-muted/50" onPointerLeave={() => setHover(null)}> {/* Markers on a real map are drawn in WebGL. Here they are dots; the overlays only need their pixel positions. */} {sites.map((s) => ( <SeverityIcon key={s.id} level={s.level} className="absolute size-3.5 -translate-x-1/2 -translate-y-1/2 cursor-pointer" style={{ left: px(s).x, top: px(s).y }} onPointerEnter={() => setHover(s.id)} onClick={() => setOpen(s.id)} /> ))} </div> {/* The same sites as buttons, so keyboard and screen reader users reach every marker. */} <div className="flex flex-wrap gap-1.5" aria-label="Sites on the map" role="group"> {sites.map((s) => ( <Button key={s.id} variant="outline" size="xs" onClick={() => setOpen(s.id)}> <SeverityIcon level={s.level} /> {s.name} </Button> ))} </div>
<Tooltip open={Boolean(hovered) && !opened}> {hovered && ( <TooltipContent variant="surface" anchor={hover?.anchor} sideOffset={10}> {hovered.name} </TooltipContent> )} </Tooltip>
<Popover open={Boolean(opened)} onOpenChange={(o) => !o && setOpen(null)}> {opened && ( <PopoverContent anchor={open?.anchor} side="right" sideOffset={12} className="w-60"> <PopoverHeader> <PopoverTitle>{opened.name}</PopoverTitle> <PopoverDescription>Edge site, 4 racks</PopoverDescription> </PopoverHeader> <Severity level={opened.level} appearance="pill" size="sm" /> </PopoverContent> )} </Popover> </div> )}Chart point
The tooltip follows the nearest datum of an SVG chart.
import * as React from "react"
import { pointAnchor } from "@/components/ui/point-anchor"import { Tooltip, TooltipContent } from "@/components/ui/tooltip"
const points = [42, 48, 45, 57, 61, 54, 66, 72, 69, 78]const days = ["Oct 12", "Oct 13", "Oct 14", "Oct 15", "Oct 16", "Oct 17", "Oct 18", "Oct 19", "Oct 20", "Oct 21"]
export default function PointAnchorChart() { const svgRef = React.useRef<SVGSVGElement>(null) const [i, setI] = React.useState<number | null>(null) const W = 400 const H = 160 const x = (n: number) => (n / (points.length - 1)) * W const y = (v: number) => H - (v / 80) * H
// The anchor is the datum's position in the SVG viewBox, scaled to the SVG's rendered size // when the tooltip positions itself. const anchor = React.useMemo(() => { if (i === null) return null return { getBoundingClientRect() { const box = svgRef.current!.getBoundingClientRect() return pointAnchor((x(i) / W) * box.width, (y(points[i]) / H) * box.height, svgRef).getBoundingClientRect() }, get contextElement() { return svgRef.current ?? undefined }, } }, [i])
return ( <div className="w-full max-w-lg"> <svg ref={svgRef} viewBox={`0 0 ${W} ${H}`} className="h-44 w-full overflow-visible" role="img" aria-label="Daily collections, Oct 12 to Oct 21" onPointerMove={(e) => { const box = e.currentTarget.getBoundingClientRect() setI(Math.round(((e.clientX - box.left) / box.width) * (points.length - 1))) }} onPointerLeave={() => setI(null)} > <polyline fill="none" stroke="var(--chart-1)" strokeWidth="2" points={points.map((v, n) => `${x(n)},${y(v)}`).join(" ")} /> {i !== null && <circle cx={x(i)} cy={y(points[i])} r="4" fill="var(--chart-1)" stroke="var(--background)" strokeWidth="2" />} </svg> <Tooltip open={i !== null}> {i !== null && ( <TooltipContent variant="surface" anchor={anchor} side="top" sideOffset={10}> <span className="text-muted-foreground">{days[i]}</span> <span className="font-medium tabular-nums">${points[i]}k</span> </TooltipContent> )} </Tooltip> </div> )}Anatomy
<Popover open={Boolean(site)} onOpenChange={(o) => !o && setSite(null)}> <PopoverContent anchor={pointAnchor(x, y, mapRef.current)}>…</PopoverContent></Popover>Guidance
Use it when
- Overlays over canvas, WebGL, SVG or map content that has no DOM element per item.
- pointAnchor(x, y, container) with coordinates relative to the canvas or map container.
- A new anchor when the point moves, for example on the map's move event.
Avoid
- Hand-positioned divs that copy popover styles. They miss collision handling, focus and dismissal.
- Hover as the only way to reach the information. Offer the same items as a list or in an inspector.
Rules for AI agents
Published in llms-full.txt, the registry and the MCP server, so coding agents follow them when they build with this component.
- Pass anchor to TooltipContent, PopoverContent or HoverCardContent; keep the Root controlled with open.
- Use variant="surface" on tooltips over maps and dark canvases.
- Provide a keyboard path to every marker: a list of buttons, the command palette or an inspector.
- Build anchors with pointAnchor, rectAnchor or pointerAnchor from @/components/ui/point-anchor, not by hand.
API
pointAnchor
| Prop | Type | Default | Description |
|---|---|---|---|
| (x, y, relativeTo?) | VirtualAnchor | A point, relative to an element when given. |
rectAnchor
| Prop | Type | Default | Description |
|---|---|---|---|
| (rect, relativeTo?) | VirtualAnchor | A rectangle such as a node's bounds. |
pointerAnchor
| Prop | Type | Default | Description |
|---|---|---|---|
| (event) | VirtualAnchor | The pointer position of a mouse or pointer event. |
TooltipContent
| Prop | Type | Default | Description |
|---|---|---|---|
| anchor | Element | VirtualAnchor | RefObject | Also on PopoverContent and HoverCardContent. |
Installation
The Prodular UI CLI adds the source to components/ui with everything it depends on. In a project that already uses shadcn/ui, the shadcn CLI installs it from the Prodular registry too. See Installation for the first setup.
npx prodular-ui add point-anchor# or, with the registry in components.jsonnpx shadcn@latest add @prodular/point-anchor