GodUIGodUI
129Follow on X

Anatomy of the Comment Pin

A pin springs onto a surface and a thread panel hangs off its corner. Two independent springs share one anchor, and the popover needs no coordinate math.

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 seem to need positioning logic for the popover: where does the thread go so it doesn't run off the pin? CommentPin sidesteps the question. The panel is absolute inside the same relatively-positioned wrapper the pin already lives in, so it never measures the viewport or the pin's box.

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 instead of the page. Move the wrapper and everything inside it moves together, with no separate "where should the popover go" calculation to keep in sync. rounded-full rounded-bl-sm on the button makes it read as a pin rather than an avatar: the single square corner works as a speech-bubble tail pointing at the annotated spot.

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). The pin is what the pointer clicks, so it should feel more immediate. 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. With the transform origin on the same corner the panel is positioned from, the panel grows out of the pin instead of popping up beside it.

The pin button carries a layout prop; the panel doesn't. When resolved flips, the pin's size only changes by a spring scale (a transform, cheap for Framer to interpolate with layout tracking). The panel's size follows its comment list as it grows or shrinks, which means actual height changes. The panel also already animates in and out via AnimatePresence's own initial/animate/exit, so adding layout there would fight the mount/unmount transition.

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 has no spring of its own: the scale: 0.85 above already covers the size change. Desaturating the pin uses a separate animation system, a plain CSS transition-[filter,opacity] toggled by the grayscale/opacity-60 classes. Framer never sees the color change. The grayscale fade runs on the browser's compositor timeline, independent of whatever Framer's spring does to scale in the same frame. Two cheap, unrelated transitions land on one element, and neither animation system has 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 carries x/y. The pin's spring is tighter than the panel's. The resolved state never touches the spring; a CSS transition runs alongside it.

Accessibility

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

The pin is a native <button> with an aria-label that changes with resolved, so a screen reader announces the pin's state as well as its presence. 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 always agree about whether the thread is open. The reply field, when present, is a <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 PinSS: Compositor-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 →