Window-drag jitter, and why animation libraries fight drag libraries
Framer Motion + react-rnd both want to write to the same `transform` property. The result is a window-drag jitter that takes a session to debug. Here is the bug, the diagnosis, and the structural fix.
The bug nobody could see except me
I shipped Garden OS mode in late spring with traffic-light window controls, drag-to-move, and snap-to-tile. It looked great in the PR screenshots. It felt subtly broken in actual use.
When you grabbed a window's title bar and dragged, the window jittered — every few frames it would briefly snap back, then forward again, like the cursor was tugging it through molasses. Worse, opening a new window had no animation; it just appeared. Closing it just vanished. Minimising barely showed anything at all.
It took me a while to even articulate what was wrong, because each issue in isolation was small. But the cumulative effect was that the whole OS metaphor felt unconvincing — like a screenshot of an OS, not an actual OS.
This post is the debug story. It's mostly relevant if you're
combining Framer Motion with react-rnd (or any drag library
that owns its element's transform style), because that
combination has a non-obvious foot-gun.
The setup
Each window in my OS is structured like this:
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ scale: 0.8, opacity: 0, y: 20 }}
animate={
minimized
? { scale: 0.1, opacity: 0, y: 600 }
: { scale: 1, opacity: 1, y: 0 }
}
exit={{ scale: 0.8, opacity: 0, y: 20 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
style={{ zIndex, position: 'absolute' }}
>
<Rnd dragHandleClassName={...} bounds="parent" {...}>
<div className={styles.windowInner}>
{/* title bar + iframe content */}
</div>
</Rnd>
</motion.div>
)}
</AnimatePresence>
Reasonable, right? Framer wraps the window for entry / exit / minimise animations. Rnd lives inside and handles the drag.
That's the bug. Both libraries write to the element's transform
property. Framer applies its current animate value (e.g.
scale(1) translateY(0)) on every render. Rnd reads its own
x / y state and translates the child element to match.
When you grab the window and drag, three things happen on every mouse-move tick:
- Rnd updates its internal x/y → re-renders.
- The re-render bubbles up; Framer's motion.div sees
animateis the same object literal it had last render, but its children tree changed. It schedules a relayout. - Framer's transform (the identity) is composed with Rnd's transform via the parent → child transform chain. The composition isn't quite stable across the relayout — frame N has Rnd's transform applied to a settled parent, frame N+1 has Rnd's transform applied to a parent that's still mid-spring.
Net effect: the window's actual rendered position drifts by a few pixels per frame, then snaps back when Framer settles. Jitter.
The fix
Stop using both libraries to drive transforms. Pick a layer:
- Framer for mount/unmount animations only. Animate
opacity, not transform. - CSS for the entry pop (the scale-up-on-mount feel).
- CSS for the minimise transition (scale-down-and-drop-to-dock).
- Rnd for drag and resize, undisturbed.
Restructured:
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: minimized ? 0 : 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.22, ease: 'easeOut' }}
style={{
zIndex: minimized ? -1 : zIndex,
position: 'absolute',
pointerEvents: minimized ? 'none' : 'auto',
}}
>
<Rnd ...>
<div
className={`${styles.windowInner} ${minimized ? styles.windowMinimized : ''}`}
>
{/* title bar + content */}
</div>
</Rnd>
</motion.div>
)}
</AnimatePresence>
And in CSS:
.windowInner {
/* Pop in on mount via CSS @keyframes — no Framer transform. */
animation: windowEnter 280ms cubic-bezier(0.34, 1.56, 0.64, 1) both;
/* Smooth transition for minimize / restore. */
transition: transform 320ms cubic-bezier(0.34, 1.56, 0.64, 1),
opacity 280ms ease;
transform-origin: 50% 100%;
}
@keyframes windowEnter {
from { transform: scale(0.86) translateY(18px); opacity: 0; }
to { transform: scale(1) translateY(0); opacity: 1; }
}
.windowMinimized {
transform: scale(0.06) translateY(72vh);
opacity: 0;
pointer-events: none;
}
The windowInner lives inside Rnd's wrapper, so Rnd's drag
transforms apply to the parent and windowInner's transforms apply
to the child. Two separate transform contexts, no conflict.
Result: drag is buttery smooth. The pop-in is visible. Minimise shows the window genuinely shrinking toward the dock area.
The lesson
The temptation when you have an animation library is to do everything with it. Framer can do this, can't it? Yes. But CSS @keyframes + transitions are also still animations, and the cost of mixing both libraries on the same property is the kind of bug that takes you a session to find because nothing throws an error — the page just feels slightly off.
The general principle: when two systems both want to write to the same DOM property, don't make them fight. Either pick one, or give them separate elements to write to.
Bonus: visible resize handles
While I was in there, I fixed a related discoverability problem. react-rnd's default resize handles are 10×10 transparent hit zones. Functional but invisible — every user I tested with had no idea the windows were resizable.
Two-line fix: pass resizeHandleStyles and put a tiny chevron in
the bottom-right corner.
<Rnd
resizeHandleStyles={{
bottomRight: {
width: 18,
height: 18,
bottom: 2,
right: 2,
cursor: 'nwse-resize',
background:
'linear-gradient(135deg, transparent 50%, rgba(255,255,255,0.55) 50% 65%, transparent 65% 75%, rgba(255,255,255,0.55) 75% 90%, transparent 90%)',
borderRadius: 2,
opacity: 0.8,
},
// wider edges for easier grabbing on touch
right: { width: 6 },
bottom: { height: 6 },
left: { width: 6 },
top: { height: 6 },
}}
>
The chevron is just a CSS gradient — two diagonal stripes that read as a "drag me to grow" affordance. Costs nothing, ships clarity.
If you're combining Framer Motion with react-rnd (or React DnD, or any drag library with its own transform), the rule is: animate on opposite sides of the drag handle. Keep the animation library above OR below the drag library, never wrapping it. Put any property both libraries want to control on different DOM elements.