The problem
Most websites use CSS :hover for hover effects. You can see them in the stylesheet, copy them, done.
Framer doesn't do that. Framer uses a React animation library (Framer Motion) with a whileHover prop. When you mouse over an element, JavaScript calls element.style.setProperty() to apply inline styles in real time. When you mouse away, it reverts them. There's no CSS rule anywhere. The hover state only exists while your mouse is physically over the element.
We ran into this while building FramerExport. After stripping out Framer's React runtime, the exported pages had no hover effects at all. Buttons didn't change color. Cards didn't lift. The site felt dead.
We needed a way to capture these runtime hover styles and convert them into real CSS :hover rules.
What properties we capture
Not every CSS property is animated on hover. Framer Motion sticks to a predictable set:
These cover about 95% of what Framer sites animate on hover. Tracking only these keeps the diff clean and avoids noise from unrelated style changes.
Step 1: Find hover candidates
Framer marks elements that have hover animations with data-highlight="true". This is how their own runtime knows which elements to watch for pointer events. We use the same marker:
We filter out anything smaller than 5x5 pixels, sort by vertical position so the mouse moves naturally down the page, and cap at 60 elements. More than that and the capture time gets too long.
Step 2: Snapshot the baseline
Before hovering over anything, we park the mouse at coordinates (0, 0) so nothing is in a hover state. Then for each candidate, we record the current inline style values:
This gives us the "before" snapshot. Whatever changes after hovering is the hover effect.
We use style.getPropertyValue() instead of getComputedStyle() on purpose. Computed style includes inherited and stylesheet values, which makes the diff noisy. Inline style only returns what JavaScript set directly, which is exactly what Framer Motion does.
Step 3: Set up MutationObserver before hovering
This is the part that took the most iteration. We set up a MutationObserver on the element's parent before moving the mouse:
Why track how many elements mutate? Because we need to tell apart two very different things:
- A real hover effect: the element itself changes
background-colororbox-shadow. Only the hovered element mutates. - A variant transition: the hover triggers a full layout swap, with 20-40 child elements all changing at once. This is a different mechanism (CSS class swap, not inline styles).
If more than 5 child elements mutate during hover, we skip the inline style capture and handle it through the variant path instead.
Step 4: Trigger the hover with CDP
We use Puppeteer's page.mouse.move() which dispatches real mouse events through Chrome DevTools Protocol. This triggers Framer Motion's gesture system the same way a real user's mouse would:
The 350ms wait matters. Framer Motion uses spring-based animations, so the hover state doesn't apply instantly. It eases in over 200-300ms. If you check too early, you capture intermediate values instead of the final state.
Step 5: Diff the styles
After the animation settles, compare each property against the baseline:
If background-color went from "" to "rgb(59, 130, 246)", that's a hover effect. If nothing changed, the element doesn't have a real hover animation.
Step 6: Verify by un-hovering
This step catches false positives. We move the mouse away and check if the styles revert:
If the styles don't revert, it wasn't a hover effect. It was a click handler, a permanent state change, or some other one-time mutation. This verification filters out about 15% of false positives on a typical Framer site.
Step 7: Convert to CSS :hover
Once verified, we store the hover properties as data attributes on the element:
Later, the URL rewriter reads these attributes and generates real CSS:
We add a transition so the hover doesn't snap on and off. Framer Motion uses spring animations, but a CSS ease transition is close enough for exported sites.
The third type: CSS variant hovers
Beyond inline-style hovers, Framer has a second hover mechanism that works through CSS class swaps. On hover, Framer swaps a class like framer-v-abc123 to framer-v-xyz789. Each class maps to a different set of CSS rules already in the stylesheet.
The MutationObserver catches this because it watches for class attribute changes too. When we detect a framer-v-* class swap during hover:
- Record the base class and the hover class
- Move mouse away and verify the class reverts to the base
- Store both class names as data attributes
- The rewriter generates a CSS rule that swaps classes on
:hover
This handles hover effects that change layout, typography, or anything too complex for inline style manipulation.
Numbers from real sites
On a typical Framer site with 30-40 hoverable elements:
- Capture time: 15-25 seconds (350ms hover + 350ms un-hover per element, plus overhead)
- Inline style hovers detected: 5-15
- Variant hovers detected: 2-8
- False positives filtered: 3-6
- Total CSS rules generated: 10-20
The entire hover capture function is about 200 lines of JS, part of a ~1,100 line crawler that handles the full Framer export pipeline.
See the hover capture in action
Export any Framer site and check the hover effects in the preview. No account needed.
Try FramerExportFrequently Asked Questions
Framer Motion doesn't use CSS :hover. It listens for pointer events in JavaScript and applies inline styles using element.style.setProperty(). The hover state only exists while the mouse is physically over the element. There are no hover rules in any stylesheet to read.
Yes. Any site that applies hover effects through JavaScript (React Spring, GSAP, custom handlers) has the same problem. The MutationObserver + CDP approach works regardless of which library sets the inline styles.
On a typical Framer site with 30-40 hoverable elements, about 15-25 seconds. Each element needs ~350ms for the hover animation plus ~350ms for the un-hover verification, plus overhead for scrolling and JS evaluation.