Migrating a Framer site to code, measured rather than eyeballed

24 August 20269 min read

Framer has no code export. Not a degraded one, not an HTML dump you can clean up: the published markup is generated and unusable as a starting point. So migrating a Framer site to code is not a conversion, it is a reconstruction. The design system has to be read back out of the running site.

I did this for my own portfolio, seven pages, and ended up matching the original to the pixel on desktop, including total document height. The method is simple. The traps are not, and three of them cost me hours.

The assets are recoverable, and better than what the site serves

The first useful surprise: Framer serves scaled-down images. Every <img> points at framerusercontent.com/images/<id>.png?scale-down-to=1024 or 2048. Strip the query string and you get the original upload.

In my case that turned six covers at 479px wide into originals up to 7040 by 5280. The rebuilt site is sharper than the one it replaced, which is not the usual direction for a migration.

Read the <img> sources out of the DOM, drop the parameters, and keep a manifest mapping each Framer id to a readable filename. You will want to re-run that download later, so make it a script rather than a session of copy-paste.

Read the design system, do not eyeball it

Open the site, and instead of squinting at it, ask the browser what it is doing. Walk the DOM and collect, for every element that carries text or an image: its bounding box, font family, size, weight, line height, letter spacing, text transform, colour.

That single pass gives you the type scale, the palette, the container width and the grid. Repeat it at three viewport widths and you have the breakpoints too. Mine came out as phone below 810, tablet 810 to 1199, desktop 1200 and up, with the container at 1140 centred on desktop and full width minus a fixed padding below that.

This is dramatically more reliable than reading values off screenshots, and it takes minutes.

Trap one: you are measuring animations, not layout

This is the one that wasted the most of my time, and it is invisible if you are not looking for it.

Framer's appear animations start elements offset and settle them when they enter the viewport. In a headless browser those animations often never fire. So every position you measure includes the initial offset, and the offset is not uniform: mine was 80px on the page heading, 40px on media blocks, 20px elsewhere.

I built a first version that matched my measurements exactly, and was therefore wrong everywhere: I had faithfully reproduced the animated state, using real margins, and compensated the error with other constants that happened to cancel out.

Neutralise every transform before you measure anything:

for (const el of document.querySelectorAll('*')) {
  const cs = getComputedStyle(el)
  if (cs.transform !== 'none') el.style.setProperty('transform', 'none', 'important')
  if (el.hasAttribute('data-framer-appear-id') && parseFloat(cs.opacity) < 1) {
    el.style.setProperty('opacity', '1', 'important')
  }
}

Setting a stylesheet rule is not enough, because Framer writes the transforms as inline styles. You have to walk the elements.

Trap two: letter-spacing in em inherits as an absolute length

Framer expressed the tracking as -0.04em at every size, which is tidy. I declared it once on body and moved on.

That is wrong, and CSS is behaving correctly. A length in em resolves against the font size of the element that declares it, and then inherits as that resolved absolute length. Declared on a body at 18px, -0.04em becomes -0.72px, and the 70px heading inherits -0.72px instead of the -2.8px it needs.

The heading was visibly too loose and I spent a while blaming the font. Every class that changes the font size has to restate the tracking.

Trap three: Framer sets white-space: pre

Framer's rich text renders with white-space: pre or pre-wrap, because line breaks are authored, not computed. Two consequences.

Text you expect to wrap does not. On my site, a two-column table of degree titles overflowed its column rather than breaking, and the original simply clipped the overflow with a hidden overflow on an ancestor. Reproducing the layout meant reproducing the non-wrapping and the clipping, which felt wrong to write and was correct.

And it means you cannot infer a container width from where the text happens to break, because it does not break.

Trap four: values that never come back down

The residual differences on my project pages at narrow widths looked like a font metrics problem. I compared the variable and static builds of the typeface, chased kerning, and got nowhere.

The actual cause was mundane. I had transcribed the outcomes paragraph as max-width: 855px, measured at desktop. In the original it is 75 per cent of the container: 855px at 1140, 578 at tablet, 270 on phone. A frozen pixel value is correct at exactly one viewport and wrong at every other.

Same story for a font size I had pinned at 18px when the original dropped to 16 on phone, and for a grid gutter that goes from 20px to 10.

If a value looks suspiciously round at your reference width, measure it at the other two before you write it down.

Verify with an invariant, not with your eyes

Side-by-side screenshots will not tell you whether you are 20px out on a 9000px page. Total document height will, and it has a useful property: it is a layout measurement, so animation transforms do not affect it.

Load both sites in same-origin iframes at each breakpoint, settle the animations, and compare document.documentElement.scrollHeight. When the height matches and the media positions match, you are done. When the height matches but positions do not, you have two errors cancelling out, which is exactly the state I was in after trap one.

Twenty-one page and breakpoint combinations, eighteen exact, three within 17 pixels. The remaining three are single-line wrap differences inside a two-column block at 390px, and I stopped there deliberately.

What not to chase

Some of what you measure is not design, it is residue. My original carried a trailing empty line in one rich text block, which added 19.2px that the whole page depended on. Another block was 42px taller than its content for the same reason. The contact block used 151px of top padding on the home page and 131 on project pages, for no reason anyone chose.

Reproduce those where the geometry downstream depends on them, comment why, and do not try to rationalise them into a system. They are not a system. They are what a designer nudged on a Tuesday.

And once the copy is faithful, stop being faithful. A pixel-exact reproduction of a two-year-old site is a floor, not a finish line. The first thing I did after matching it was add captions under every image, which changed the page heights and broke the very property I had just spent a day achieving. That was the point.

← all writing

Contact

Ready to bring your vision to life or just want to chat? Reach out, and let's create something memorable together.

I'm here to listen, collaborate, and craft design solutions that resonate.