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.
↓Scroll to step through it
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.
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.
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.
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.
- 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
<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>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.
- 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
<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>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.
{ 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
<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>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.
scale: 1
scale: 0.85
- Active
- animate spring only, full color, scale 1
- Resolved
- spring scale 0.85 + CSS transition-[filter,opacity]
className={`... transition-[filter,opacity] ${resolved ? "opacity-60 grayscale" : ""}`}style={{ backgroundColor: pinColor }}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.
Can we tighten this spacing?
Agreed, bumping to 8px.
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
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:
<input aria-label="Reply" ... />
<button type="submit" disabled={!reply.trim()} aria-label="Send reply" />Motion Score
opacityFade / cross-fade