Turn a Figma File Into a Live Prototype You Can Actually Click
My case studies were just screenshots. Nice screenshots, but you can't click a screenshot.
So I rebuilt four of my Figma designs as live prototypes you can use right inside the case study: a clinic queue (Appendix), a task board (Jobflo), a money app (Hiro), and a property app (Velta).
That came to 47 files and 17,621 lines of React. Here's how the setup works, and the Figma quirks that cost me the most time.
TL;DR
- Render at the Figma artboard's native size, then scale it down. Every measurement stays 1:1 with the design file.
- Anything that follows the pointer has to divide by the scale. Menus, tooltips, drag and drop.
- Take the motion from Figma too. Sample the spring curves into CSS
linear(). No animation library needed. - Build every flow, including the off-script clicks. The empty, error, and "what happens next" states are the prototype.
- Figma exports lie a little. Inside strokes, lost icon colors, a missing minus sign, and asset links that expire.
Step 1: Render at native size, then scale
The Appendix design is 1366 × 1024. My case study column is a lot narrower than that.
The easy way is to rebuild everything responsive. The problem: every value drifts a little from the design file, and you spend days checking padding.
So I don't. The artboard renders at exactly 1366 × 1024, and one CSS scale shrinks it to fit:
const ARTBOARD_W = 1366;
const ARTBOARD_H = 1024;
// Fit the native artboard to the column width
const fitRef = (el: HTMLDivElement | null) => {
if (!el) return;
const ro = new ResizeObserver(([entry]) =>
setScale(entry.contentRect.width / ARTBOARD_W)
);
ro.observe(el);
return () => ro.disconnect();
};/* Native 1366x1024 artboard, scaled to fit the story column */
.artboard {
transform-origin: top left;
scale: var(--ax-scale, 1);
}Two small details:
- The outer frame uses
aspect-ratio: 1366 / 1024, so the space is reserved before anything loads. No layout jump. - The artboard stays
visibility: hiddenuntil the scale is known. Otherwise you see one frame at full size.
Now a 24px gap in Figma is a 24px gap in code. I can check any value against the file without doing math.
Each prototype has its own native size: Appendix 1366 × 1024, Jobflo 1440 × 1024, Velta 390 × 844, Hiro 375 × 812.
Step 2: Divide everything by the scale
Here's the catch. The pointer lives in screen pixels. The artboard lives in design pixels. At a 0.6 scale, those don't match.
So anything positioned from a mouse event (dropdowns, tooltips, the drag ghost, right-click menus) converts first:
const a = artboardRef.current?.getBoundingClientRect();
return {
left: (r.left - a.left) / scale,
top: (r.top - a.top) / scale,
width: r.width / scale,
height: r.height / scale,
};Forget this once and your dropdown opens somewhere far from its button.
One trick for animations: when rows slide around in a table, measure them with offsetTop instead of getBoundingClientRect(). offsetTop is already in design pixels, so the scale doesn't matter at all.
Step 3: Load the design's fonts inside the demo only
The Appendix design uses Inter. My site uses a different font. I don't want Inter loading on every page just for one prototype.
So the font is loaded with next/font and applied only to the artboard:
const inter = Inter({ subsets: ["latin"], weight: ["400", "500", "600", "700"], display: "swap" });
<div className={cn(s.artboard, inter.className)} />Velta needed three fonts (Inter, Poppins, Roboto), so those load as CSS variables with preload: false. They only download when you open that case study.
Step 4: Take the motion from Figma too
The Appendix file has Motion timelines for the home flow. So I copied the motion into code instead of guessing.
The springs get sampled at 32 steps into CSS linear(). So the exact bounce from Figma runs in plain CSS:
export const EASE = {
// Button release after a press (spring, bounce 0.45)
press: "linear(0, 0.0754, 0.2522, 0.468, 0.6783, 0.8556, 0.9874, 1.072, ...)",
// Toast and modal arrival (spring, bounce 0.2)
settle: "linear(0, 0.039, 0.1324, 0.2525, 0.3805, 0.504, ...)",
// Figma EASE_OUT
out: "cubic-bezier(0.33, 1, 0.68, 1)",
};The durations come straight from the keyframe lengths too. Press down 120ms, release 370ms, toast rise 550ms, modal rise 500ms.
For rows moving in a table, I use the Web Animations API with the same curves:
el.animate(
[{ translate: `0 ${prev - top}px` }, { translate: "0 0" }],
{ duration: DUR.rowSlide, easing: EASE.slide, delay: moved * DUR.rowStagger, fill: "backwards" }
);Zero animation libraries in any of the four demos. Just CSS transitions, linear() springs, and element.animate().
The other three designs had no Motion timelines in Figma. So they reuse the Appendix springs. Now every prototype on the site moves the same way.
Step 5: Build every flow
A prototype with only the happy path feels fake the second you click something off-script.
So I built what happens after every click. The Appendix demo has:
- 6 screens: dashboard, doctors, queue, missed queue, checkout, settings
- 7 modals: cancel patient, rejoin, edit bill, print medical certificate, new patient, doctor profile, help
- 30 patients in the queue, so pagination actually has pages
- A search palette, toasts with Undo, full screen, and a reset
The Jobflo board has 4 views (board, list, calendar, Gantt), drag and drop between 6 due-date columns, and 7 keyboard shortcuts on the hovered card.
Some screens don't exist in Figma. There's no Settings frame for Appendix, so I composed it from pieces that do exist. Same components, same tokens. That's the rule: fill gaps with the design system, never invent a new look.
5 things Figma exports get wrong
1. Inside strokes don't add size. In Figma, an inside stroke sits inside the box. In CSS, a border adds to it (or eats the padding). Fix: draw the stroke as an absolute overlay span with inset-0 and pointer-events-none, like Figma does.
2. Icon colors get lost. When an icon instance has a color override (like the brand color on the active nav item), the export often comes out in the original color. Fix: export a recolored copy, named *-active.
3. Gradients lose a minus sign. The CSS from the Figma MCP dropped the minus on the first gradient stop. It wrote 72.645% when the real value was -72.645%. Fix: recompute the stops from the paint's gradientTransform.
4. Some icons don't export at all. SF Symbols can't be exported from Figma, and on Velta the icon instances had lost their library, so they rendered empty. Fix: draw them inline as SVG, matched to the exported screenshots.
5. Asset links expire. Images pulled through the Figma MCP come back as temporary URLs. They work today and break next week. Fix: always download the file and ship it from your own /public folder.
A few web-only problems
The design file doesn't know it's going to live inside a web page. A few things broke that Figma never shows:
- Tooltips got clipped by table containers with
overflow: hidden. Fix: portal them into the artboard root, so they're still scaled but not clipped. - Escape closed the whole case study. The page overlay listens for Escape too. Fix: popovers listen in the capture phase and stop the event there.
- Focus fell to the page. When a focused button unmounts, focus drops to
<body>, and my site's keyboard shortcuts fire. Fix: move focus back into the demo. - Full screen isn't everywhere. Use the Fullscreen API where it exists, and fall back to a fixed overlay that covers the viewport.
How it drops into a case study
My case studies are MDX files, and they can't import components directly. So there's a small registry:
const PROTOTYPES = {
"appendix-queue": AppendixQueueDemo,
"hiro-app": HiroDemo,
"velta-invest": VeltaDemo,
"jobflo-tasks": JobfloTasksDemo,
} as const;And in the story it's one line:
<Prototype demo="appendix-queue" caption="Live prototype preview." />That's it. The Figma file stays the source of truth, and the case study finally shows how the thing actually works.
Contact
Have a project? Say hello at
