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:

const HOVER_PROPS = [ 'background-color', 'color', 'opacity', 'transform', 'box-shadow', 'border-color', 'border-radius', 'filter', 'scale', 'text-decoration-color', ];

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:

const candidates = document.querySelectorAll('[data-highlight="true"]'); // Filter out tiny elements, sort top-to-bottom, cap at 60

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:

const baseline = {}; for (const prop of HOVER_PROPS) { baseline[prop] = el.style.getPropertyValue(prop); }

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:

const mutatedEls = new Set(); let classChanged = false; const obs = new MutationObserver((muts) => { for (const m of muts) { if (m.attributeName === 'style') { mutatedEls.add(m.target); } if (m.attributeName === 'class' && m.target === el) { classChanged = true; } } }); obs.observe(parentElement, { attributes: true, attributeFilter: ['style', 'class'], subtree: true, });

Why track how many elements mutate? Because we need to tell apart two very different things:

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:

await page.mouse.move(centerX, centerY); // Wait 350ms for the spring animation to settle await new Promise(r => setTimeout(r, 350));

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:

const changed = {}; for (const prop of HOVER_PROPS) { const val = el.style.getPropertyValue(prop); if (val && val !== baseline[prop]) { changed[prop] = val; } }

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:

await page.mouse.move(0, 0); await new Promise(r => setTimeout(r, 350)); let reverted = 0; for (const prop of Object.keys(changed)) { if (el.style.getPropertyValue(prop) !== changed[prop]) { reverted++; } } if (reverted === 0) return false; // not a hover effect

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:

el.setAttribute('data-framer-hover-id', 'fh42'); el.setAttribute('data-framer-hover-self', JSON.stringify(changed));

Later, the URL rewriter reads these attributes and generates real CSS:

/* Generated CSS hover rule */ [data-framer-hover-id="fh42"]:hover { background-color: rgb(59, 130, 246); box-shadow: 0 4px 12px rgba(0,0,0,0.15); transition: all 0.2s ease; }

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:

  1. Record the base class and the hover class
  2. Move mouse away and verify the class reverts to the base
  3. Store both class names as data attributes
  4. 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:

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.

This works beyond Framer. Any site that applies hover effects through JavaScript (React Spring, GSAP, custom event handlers) has the same problem. The MutationObserver + CDP approach works regardless of which library sets the inline styles.

See the hover capture in action

Export any Framer site and check the hover effects in the preview. No account needed.

Try FramerExport

Frequently 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.