research/rebuilding-a-figma-frame-as-a-scaled-canvas

Rebuilding a Figma frame as a scaled canvas

Engineering Note · October 29, 2026 · 6 minute read · Front-end · Design to code · CSS

A scaled canvas is a way to turn a design frame into a web page without translating it: build the frame at its original pixel size, such as 1440 by 810, put it in a stage that keeps the frame’s aspect ratio, and scale the whole thing uniformly with one CSS variable. Turning a polished design into a responsive page usually means translating a composition into rules: breakpoints, flex layouts, fluid type. For hero-style frames where the composition itself is the design, with overlapping images, tight type, and absolutely placed elements, that translation loses what made the frame good.

Versions for the measurements below: playwright-core 1.63.0 driving Chromium 141, Node 22.

How does the scaled canvas technique work?

The page contains a stage that fills the available width and holds the frame’s aspect ratio. Inside it sits a canvas at the design’s exact pixel size, scaled uniformly to fit.

.stage  { position: relative; width: 100%; aspect-ratio: 1440 / 810; overflow: hidden; }
.canvas { position: absolute; top: 0; left: 0; width: 1440px; height: 810px;
          transform: scale(var(--scale, 1)); transform-origin: top left; }

A few lines of script keep --scale current:

const update = () => stage.style.setProperty('--scale', stage.getBoundingClientRect().width / 1440);
new ResizeObserver(update).observe(stage);

Everything inside the canvas is positioned with the numbers straight from the design file: left, top, width, height in pixels. There is no reinterpretation to get wrong, and a visual comparison against the source is a direct overlay.

viewport width W
   |
   v
.stage   width = W, height = W * 810/1440   (aspect-ratio keeps the shape)
   |  ResizeObserver: --scale = stage.width / 1440
   v
.canvas  1440 x 810 px, transform: scale(--scale), origin top left
   |
   v
children at design coordinates (left, top, width, height in px)

Do element positions really scale linearly?

We built a 1440 by 810 demo page with a headline block at 120,200 (640 by 160) and a card at 900,420 (360 by 240), then measured the rendered rectangles with Playwright at three viewport widths. The demo is a minimal page written for this note, not one of our production frames. “Expected” is the design coordinate multiplied by the measured scale:

Viewport width—scaleStage (w x h)Card left, top (measured)Expected (900 x s, 420 x s)
3900.2708390.00 x 219.38243.75, 113.75243.75, 113.75
8200.5694820.00 x 461.25512.50, 239.17512.50, 239.17
14401.00001440.00 x 810.00900.00, 420.00900.00, 420.00

We also divided each measured rectangle by the scale to get back to design units. At all three widths the card came back as left 900, top 420, 360 by 240, and the headline block as left 120, top 200. Nothing drifted. The page did not scroll horizontally at any width.

You can reproduce this with getBoundingClientRect(), which reports the post-transform rectangle, and waitForFunction on the --scale value, because the ResizeObserver callback runs after the resize and a read taken too early sees the old scale.

Why does it work?

The design was made at one size, and every proportion in it was a decision. Uniform scaling preserves all of them at once: the overlap between the headline and the product image, the gap between a stat card and its edge, the optical balance of the whole frame. It is the only approach where a designer looking at a phone-width render sees their own composition, only smaller.

How do you handle text in a scaled canvas?

Two details need care, both about type.

Vertical metrics. Design tools position text by its visible bounds, while browsers add line-height space above and below. Placing text at the design’s coordinates therefore drifts by a few pixels unless you trim that space. text-box: trim-both cap alphabetic does exactly that in current browsers, so a heading’s top edge lands where the designer put it. CSS.supports('text-box', 'trim-both cap alphabetic') returned true in Chromium 141. In the demo, a 100px heading with line-height: 1.5 had a 150px tall box; with the trim it measured 68.80px, the cap-to-baseline height in whichever font Arial resolved to in our sandbox. The exact number depends on the font, so measure with yours.

Fonts you can’t ship. Designs often use typefaces with no web license or hosting. When a face isn’t available, we have two honest options: substitute the nearest available font and note it in the code, or export the text as a transparent image for the few elements, such as a giant wordmark, where the letterforms are the design. We do the latter for display type and the former for body copy, and we record which is which at the top of each component so the next person doesn’t have to rediscover it.

What are the fine details?

  • Shapes that are strokes, such as construction lines and rings, are better as inline SVG than exported images. They stay sharp at any scale and can be animated.
  • Exported images should be sized to the largest scale they’ll be shown at, not the design’s display size, or desktop renders look soft.
  • Motion belongs in a layer that can be switched off. We reproduce a design’s entrance animations with CSS and disable all of it under prefers-reduced-motion.
  • Text inside a scaled canvas shrinks with it, so a paragraph that is readable on desktop becomes tiny on a phone. For a showcase of a design that is acceptable. For a page people must read on a phone, it isn’t, and the scaled technique is the wrong tool.

Failure modes we reproduced

Body text becomes unreadable. The demo has a 16px paragraph. Rendered size is 16 times the scale: 9.11px at 820 wide and 4.33px at 390 wide. At 4.33px a paragraph is a texture, not text. This is the number behind the rule in the next section.

No script, no scale. The var(--scale, 1) fallback means the canvas renders at full size when the variable is never set. We loaded the demo with JavaScript disabled at 390px wide. The stage measured 390 by 219.38, and the card sat at left 900, outside the visible width, clipped by overflow: hidden. Anything essential, such as a headline a search engine or a screen reader needs, should also exist as ordinary content in the page flow.

Reading the scale too early. A test that measures right after a resize can read the previous scale, because the observer fires after layout. Wait on the variable, not on time.

When should you stop using a scaled canvas?

Scaling is for frames where fidelity is the point: portfolio pieces, template previews, hero compositions, marketing screens. It isn’t for application interfaces, long-form reading, or forms, where reflowing to fit the device is what users need. The best use of the technique is knowing where its boundary is, and keeping the rest of the site responsive in the ordinary way.

Frequently asked questions

How do you make a Figma frame responsive without rewriting it in flexbox?

Rebuild the frame at its original pixel size inside a fixed-size canvas, wrap it in a stage that fills the width and keeps the aspect ratio, and scale the canvas uniformly with transform: scale(var(--scale)). A ResizeObserver sets --scale to the stage width divided by the design width. All positions stay the numbers from the design file.

How do you calculate the scale factor for a 1440px design?

Divide the stage's rendered width by the design width. In our measured demo, a 390px-wide viewport gave 0.2708, 820px gave 0.5694 and 1440px gave 1. Because the scale is uniform, a card placed at 900,420 in the design rendered at 243.75,113.75 on the 390px viewport, exactly 900 and 420 times 0.2708.

Does scaling a canvas make text unreadable on phones?

Yes, text scales with everything else. In our demo, 16px body copy rendered at about 4.33px on a 390px viewport. That is fine for showing off a composition, but wrong for content people must read. Use the scaled canvas for hero frames and previews, and keep reading content in ordinary responsive layout.

How do you match Figma text positions in CSS?

Design tools position text by its visible bounds, while browsers add line-height space above and below. The CSS property text-box with the value trim-both cap alphabetic removes that space. In our Chromium 141 test, a 100px heading with line-height 1.5 shrank from a 150px box to a 68.80px box, so its top edge matched the design.

What happens if the scaling script does not run?

The variable never gets set, so the canvas stays at scale 1. In our no-JavaScript test at 390px, the stage was 390 by 219.38 and the card sat at left 900px, outside the visible area. The stage clips its overflow, so the content disappears. Plan a fallback for anything essential.

← All research