Skip to main content

Command Palette

Search for a command to run...

Capturing hover effects that only exist in JavaScript (Puppeteer + MutationObserver)

Published
7 min read
Capturing hover effects that only exist in JavaScript (Puppeteer + MutationObserver)

Most websites use CSS :hover for hover effects. You can see them in the stylesheet, copy them, move on.

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. The hover state only exists while your mouse is physically over the element, enforced by JS.

I ran into this while building an export tool for Framer sites (letaiworkforme.com). The exported pages had no hover effects at all because the React runtime was stripped out. Buttons didn't change color. Cards didn't lift on hover. The site felt dead.

I needed a way to capture these runtime hover styles and convert them into real CSS :hover rules. Here's how I did it.

The setup

The tool uses Puppeteer (headless Chrome) to crawl Framer sites. After loading a page, it strips out React's hydration scripts and replaces interactive components with vanilla JS. But hover effects need to be captured BEFORE stripping React, while the runtime is still active.

The properties I care about capturing:

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

These cover 95% of what Framer sites animate on hover.

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.

const candidates = document.querySelectorAll('[data-highlight="true"]');  

I filter out anything smaller than 5x5 pixels (invisible or decorative elements), sort by vertical position (top to bottom, so the mouse path is natural), and cap at 60 elements per page. More than that and the capture time gets too long.

Step 2: Snapshot the baseline

Before hovering over anything, I park the mouse at (0, 0) to make sure nothing is in a hover state. Then for each candidate, I record the current inline style values for all the properties I care about:

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

This gives me the "before" state. Whatever changes after hovering is the hover effect.

Step 3: Set up MutationObserver before hovering

This is the part that took the most iteration. I set up a MutationObserver on the element's parent BEFORE moving the mouse. The observer watches for style and class attribute changes on all descendants:

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 mutation counts? Because I need to distinguish between two very different things:

1. A real hover effect (the element itself changes background-color, box-shadow, etc.)
2. A variant transition (the hover triggers a full layout swap, with 20-40 child elements all changing at once)

If more than 5 child elements mutate during hover, it's probably a variant transition (handled by CSS class swaps, not inline styles). I skip those and handle them differently.

Step 4: Move the mouse with CDP

I use Puppeteer's page.mouse.move() which dispatches real mouse events through Chrome DevTools Protocol. This triggers Framer Motion's gesture detection 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 is important. 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 get intermediate values instead of the final state.

Step 5: Diff the styles

After the animation settles, I 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 (maybe data-highlight was set for a different reason).

Step 6: Verify by un-hovering

This is the step that catches false positives. I 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 state change, or some other one-time mutation. This verification step filters out about 15% of false positives on a typical Framer site.

Step 7: Convert to CSS :hover rules

Once I have the verified hover properties, I store them 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:

[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; 
} 

The transition is added so the hover doesn't snap on/off. Framer Motion uses spring animations for hover, 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. When hovering, Framer swaps a class like framer-v-abc123 to framer-v-xyz789. Each class points to a different set of CSS rules already in the stylesheet.

The MutationObserver catches this because it also watches for class attribute changes. When I 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 then 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

On a typical Framer site with 30-40 hoverable elements:

  • Capture time: 15-25 seconds (350ms per element for hover + 350ms for un-hover + overhead)

  • Inline style hovers detected: 5-15

  • Variant hovers detected: 2-8

  • False positives filtered by revert check: 3-6

  • Generated CSS rules: 10-20

What I'd do differently

The 350ms wait per element is the biggest bottleneck. I could batch elements that are far apart on the page and hover them in parallel (one mouse can only be in one place, but elements that don't overlap could theoretically be tested concurrently with separate CDP sessions). Haven't tried this yet.

I also considered using getComputedStyle() instead of reading inline style properties. The problem is getComputedStyle includes inherited and stylesheet styles, making the diff noisy. Inline style.getPropertyValue() only returns what JavaScript explicitly set, which is exactly what Framer Motion does.


The full crawler is part of FramerExport, which exports Framer sites to static HTML/CSS/JS. The hover capture is about 200 lines of the ~1,100 line crawler.

If you've run into similar problems with capturing runtime-generated styles, I'd like to hear what approach you took.