August 1, 2026 · Piyush Ranjan Mishra
Building an Elementor-Style Visual Website Builder From Scratch

I built a config-driven, drag-and-drop website builder — click widgets onto a canvas, edit content and style in a live side panel, export to standalone HTML, and turn any generated page into a lead-gen product with a Stripe-powered claim flow. It’s the kind of project where the naive version is a weekend build and the version that doesn’t fall apart under its own weight requires a handful of upfront architecture decisions that are easy to skip and expensive to retrofit.
The one rule everything else follows from
A tree of plain JSON objects is the only source of truth. HTML is never hand-authored — it’s always compiled from the tree:
interface ComponentNode {
id: string;
type: string;
props: Record<string, any>;
children: ComponentNode[];
}
Every widget type is defined once in a central registry, and each definition implements two independent functions of the same props: render() (React, for the live canvas) and toHtml() (a pure string generator, for the compiled export). Neither reads from the other. This is the decision that makes the whole system trustworthy — the live canvas, Preview mode, the JSON validator’s preview, and the exported static HTML file are four separate renderers of the exact same tree, and because render() and toHtml() are both pure functions of props, they can never quietly drift into showing two different pages. A lot of “visual builder” implementations I’ve seen skip this and let the exported HTML be a separate, hand-touched-up copy of what the editor shows — which works until someone edits one and forgets the other, and now the demo doesn’t match what ships.
The props panel builds itself
Every widget declares a propsSchema — an array of typed field descriptors (text, color, select, number, boolean, and so on) with a label and a panel section (“Content” / “Style” / “Layout”). The props panel component reads this schema and renders the right input for each field generically. Adding a new widget never means writing a new settings-form component — it means adding one registry entry with a schema, and the panel exists for free. This paid for itself immediately: 27 widget types, zero hand-built settings forms.
Responsive design without @media
The editor needs a Desktop / Tablet / Mobile switcher that changes a widget’s layout regardless of the actual browser window size — @media queries can’t do this, since they only respond to the real viewport. Every responsive widget uses CSS Container Queries instead, scoped to a class unique per node instance:
.b-header-a1b2c3d4 {
/* desktop layout */
}
@container (max-width: 640px) {
.b-header-a1b2c3d4 {
/* mobile layout */
}
}
The device-preview frame gets a real CSS class with container-type: inline-size, and the device switcher just sets a fixed pixel width on that frame. Switching devices re-triggers every widget’s container queries deterministically, in the editor, in Preview mode, and in the JSON validator’s preview — three completely different code paths, one shared responsive mechanism.
Entrance animations, implemented once, not 27 times
Every widget can carry an _animation prop ({ type, duration, delay }), but no individual widget’s render()/toHtml() knows anything about animation. The wrapping happens once, in the tree-walkers that compile the page — if a node has an animation configured, it gets wrapped in a generic <div data-anim="fade-in" style="--anim-dur:600ms">; if it doesn’t, there’s zero extra markup. One shared stylesheet and one shared IntersectionObserver-based reveal script cover every animated instance on the page, injected once regardless of how many widgets use it. And animations are inert in the edit canvas — always shown fully visible — so scrolling around while building never hides your own content mid-transition; they only actually play in Preview mode and the compiled export.
The zero-JS static-HTML constraint forced better engineering
The hardest widgets to build were the ones with real interactivity that also needed to work in the exported static HTML file, which has no React runtime at all: a header’s mobile hamburger menu, a before/after image slider, a floating live-chat panel. For the header, the trick is a hidden checkbox input plus a label styled as the hamburger, toggled open/closed with a pure CSS sibling selector (.nav-toggle:checked ~ .menu) — no JavaScript required, identical behavior in the live canvas and the static export. For the chat panel and the image slider, where real state genuinely was needed, I used plain DOM manipulation scoped to event.currentTarget.closest(...) instead of React state (since a render()/toHtml() pair has no component identity to hold state in), with the static export getting the equivalent behavior via one small inlined <script> per instance, scoped with document.currentScript.parentElement. This constraint — “the interactivity has to work with zero framework runtime” — is uncomfortable to design around but produces genuinely more portable widgets than reaching for React state by default would have.
Final Result

What I’d tell someone building something similar
Decide the source-of-truth boundary before writing the first widget, not after the third one drifts. If you’re building multiple rendering surfaces of the same content (editor, preview, export), make every surface a pure function of the same data structure — never let any one of them become independently editable state, even under time pressure to ship the first widget faster. And when a constraint (no framework runtime in the export) feels like it’s fighting you, it’s usually pushing you toward an implementation that’s more portable anyway, not just harder.