MynduiMyndui
0

Anatomy of the Comment Pin

A pin that springs onto a surface and a thread panel that hangs off its corner — two independent springs, one anchor, no coordinate math for the popover.

01/04One anchor, one panel hanging off it
Pin
rounded-full rounded-bl-sm, presence color, initials or a dot
Thread panel
absolute left-0 top-9, origin-top-left, anchored to the pin
tsx
<divclassName="absolute z-raised"style={{ left: `${x}%`, top: `${y}%` }}><motion.button className="rounded-full rounded-bl-sm ..." /><motion.div className="absolute left-0 top-9 z-popover w-72 origin-top-left ...">  {/* comment list + reply form */}</motion.div></div>
01

One anchor, one panel hanging off it

Figma-style annotation pins look like they need real positioning logic for the popover — where does the thread go so it doesn't run off the pin? The answer CommentPin picks is to not solve that problem: the panel isn't positioned against the viewport or the pin's measured box, it's absolute inside the same relatively-positioned wrapper the pin already lives in.

The outer div is the only element that knows about x/y — it's placed with a left/top percentage inside whatever container the caller renders (a canvas, a screenshot, a Figma-style frame). The pin button and the panel are both plain children with absolute left-0 top-*, measured relative to that wrapper, not the page. Move the wrapper and everything inside it moves together; there's no separate "where should the popover go" calculation to keep in sync. rounded-full rounded-bl-sm on the button is the one detail that sells "pin" over "avatar" — leaving a single square corner reads as a speech-bubble tail pointing at the exact spot being annotated.

02/04Two springs, one anchored to the other

{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }

Pin spring
stiffness: 520, damping: 32 — snaps in first
Panel spring
stiffness: 320, damping: 32, mass: 0.9 — softer, follows
tsx
<motion.buttonlayoutinitial={{ scale: 0 }}animate={{ scale: resolved ? 0.85 : 1 }}transition={{ type: "spring", stiffness: 520, damping: 32 }}/><AnimatePresence>{open ? (  <motion.div    initial={{ opacity: 0, scale: 0.85, y: 4 }}    animate={{ opacity: 1, scale: 1, y: 0 }}    exit={{ opacity: 0, scale: 0.85, y: 4 }}    transition={{ type: "spring", stiffness: 320, damping: 32, mass: 0.9 }}    className="... origin-top-left"  />) : null}</AnimatePresence>
02

Two springs, one anchored to the other

The pin's own spring (520/32) is tuned tighter than the panel's (320/32, plus mass: 0.9 to add a beat of inertia) — it should feel like the more immediate object, since it's what the pointer is actually clicking. Setting origin-top-left matters as much as the transition values: without it the panel would scale from its own center and visibly drift away from the pin on entry. Anchoring the transform origin to the same corner the panel is positioned from makes it read as growing out of the pin, not popping up independently beside it.

The pin button carries a layout prop; the panel doesn't. That's because resolved can flip while the pin's own size only ever changes by a spring scale (a transform, cheap for Framer to interpolate with layout tracking). The panel's size is driven by its comment list growing or shrinking — real height changes — and it's already animating in and out via AnimatePresence's own initial/animate/exit, so adding layout there would fight the mount/unmount transition instead of helping it.

03/04Active vs resolved: two systems, same element

scale: 1

scale: 0.85

Active
animate spring only, full color, scale 1
Resolved
spring scale 0.85 + CSS transition-[filter,opacity]
tsx
className={`... transition-[filter,opacity] ${resolved ? "opacity-60 grayscale" : ""}`}style={{ backgroundColor: pinColor }}
03

Active vs resolved: two systems, same element

resolved doesn't get its own spring — the scale: 0.85 above already covers the size change. Desaturating the pin is a second, entirely separate animation system: a plain CSS transition-[filter,opacity] toggled by the grayscale/opacity-60 classes. Framer never sees the color change at all. Splitting it this way means the grayscale fade rides the browser's own compositor timeline independently of whatever Framer's spring is doing to scale in the same frame — two cheap, unrelated transitions landing on one element instead of one animation system trying to own both.

04/04Result
AR
Ana Reyes2m

Can we tighten this spacing?

MB
Marco Bell1m

Agreed — bumping to 8px.

04

The result

One relatively-positioned wrapper carrying x/y, a tighter spring on the pin than on the panel it anchors, and a resolved state that never touches the spring at all — it just lets a CSS transition run alongside it.

Accessibility

tsx
 
aria-label={resolved ? "Resolved comment" : "Open comment thread"}
aria-expanded={open}

The pin is a real <button> with an aria-label that changes with resolved, so a screen reader announces the pin's state, not just its existence. aria-expanded mirrors open — the same boolean the spring above reads to decide the panel's initial/exit — so assistive tech and the visual animation can never disagree about whether the thread is open. The reply field, when present, is a real <form> with a labeled input and a submit button disabled until there's text to send:

tsx
 
<input aria-label="Reply" ... />
<button type="submit" disabled={!reply.trim()} aria-label="Send reply" />

Motion Score

Comment PinSSCompositor-only
SopacityFade / cross-fade
Each property is graded by how the browser runs it, from S (composited off the main thread) down to F (layout thrashing); the component takes the worst. MotionScore methodology →