An Elementor template is a JSON file. To convert HTML into one, put your markup and CSS inside a single HTML widget, wrap that widget in a container, and save the result with "version": "0.4". Elementor imports it under Templates, then Saved Templates.
That is the whole trick. The rest of this post is the exact format, a script that does it, and the five things that go wrong after import.
The template format
This is the smallest file Elementor accepts:
{
"version": "0.4",
"title": "Pricing section",
"type": "section",
"content": [
{
"id": "a1b2c3d",
"elType": "container",
"settings": { "content_width": "full" },
"elements": [
{
"id": "e4f5a6b",
"elType": "widget",
"widgetType": "html",
"settings": { "html": "<div class=\"pricing\">…</div><style>.pricing{…}</style>" },
"elements": []
}
],
"isInner": false
}
],
"page_settings": []
}
Four things to know:
typeissectionfor one block orpagefor a full page.idvalues are short lowercase hex strings. They only need to be unique inside the file.elType: "container"is the flexbox container. It has been the default layout element since Elementor 3.16. The oldersectionandcolumnpair still imports, but it gets clamped to the theme's content width.- The HTML widget takes one string. Your CSS goes in a
<style>tag inside that same string.
A script that wraps HTML and CSS
No dependencies. Save it as to-elementor.mjs:
import { readFileSync, writeFileSync } from 'node:fs'
import { randomBytes } from 'node:crypto'
const [htmlFile, cssFile, out = 'template.json'] = process.argv.slice(2)
const html = readFileSync(htmlFile, 'utf8')
const css = cssFile ? readFileSync(cssFile, 'utf8') : ''
const id = () => randomBytes(4).toString('hex').slice(0, 7)
const scope = `imp-${id()}`
// Prefix every selector so the theme and the block cannot restyle each other.
// Walks the stylesheet block by block: @media and @supports are scoped inside,
// other at-rules (@keyframes, @font-face) pass through untouched.
function scopeCss(css) {
let out = ''
let i = 0
while (i < css.length) {
const open = css.indexOf('{', i)
if (open === -1) break
const prelude = css.slice(i, open).trim()
let depth = 1
let j = open + 1
while (j < css.length && depth) {
if (css[j] === '{') depth++
else if (css[j] === '}') depth--
j++
}
const body = css.slice(open + 1, j - 1)
if (/^@(media|supports)/.test(prelude)) out += `${prelude} {
${scopeCss(body)}}
`
else if (prelude.startsWith('@')) out += `${prelude} {${body}}
`
else out += `${prelude.split(',').map(sel => `.${scope} ${sel.trim()}`).join(', ')} {${body}}
`
i = j
}
return out
}
const scoped = scopeCss(css)
const widget = `<div class="${scope}">\n${html}\n</div>\n<style>\n${scoped}\n</style>`
const template = {
version: '0.4',
title: 'Imported section',
type: 'section',
content: [{
id: id(),
elType: 'container',
settings: { content_width: 'full', padding: { unit: 'px', top: '0', right: '0', bottom: '0', left: '0', isLinked: true } },
elements: [{ id: id(), elType: 'widget', widgetType: 'html', settings: { html: widget }, elements: [] }],
isInner: false
}],
page_settings: []
}
writeFileSync(out, JSON.stringify(template, null, 2))
console.log(`Wrote ${out}`)
Run it:
node to-elementor.mjs pricing.html pricing.css pricing.json
The scoping walks the stylesheet by matching braces. It handles plain rules, @media, @supports, @keyframes and @font-face. It will not survive native CSS nesting, or a comment or string that contains a brace. For a stylesheet like that, parse it properly with PostCSS.
Import it
- In WordPress, open Templates, then Saved Templates.
- Click Import Templates and choose the
.jsonfile. - Edit a page with Elementor, click the folder icon, open My Templates and insert it.
Five things that break after import
These are the real ones. All five came up while building an exporter that does this for arbitrary websites.
1. Relative URLs. src="/img/hero.png" now points at your WordPress site. A widget cannot carry a <base> tag, so every src, href, srcset and CSS url() has to be absolute before you wrap it.
2. The theme leaks in. Your theme styles h1, a, ul and button. If the original page relied on a CSS reset (Tailwind's Preflight, normalize.css), that reset is gone and headings get the theme's margins and colors. Add a small reset under your scope class: zero margins, list-style: none, color: inherit on links and headings.
3. The block is squeezed. Even a full-width container sits inside the theme's content column on many themes. Break out with:
.imp-abc123 { position: relative; width: 100vw; left: 50%; margin-left: -50vw; }
html, body { overflow-x: clip; }
Use clip, not hidden. hidden creates a scroll container and breaks sticky headers.
4. Everything is invisible. Sites that fade sections in on scroll ship the starting state in CSS (opacity: 0; transform: translateY(28px)) and remove it with JavaScript. The JavaScript did not come along. Delete those declarations or the section never appears.
5. A byte order mark. If your editor saves the file as UTF-8 with BOM, PHP's json_decode rejects it and Elementor reports an invalid file. Save without one.
When this is the wrong approach
If the client needs to change a headline from Elementor's sidebar, do not import an HTML widget. Rebuild the section with native widgets. It takes longer and it is the right answer for content that non-developers will edit every week.
The HTML widget route is for sections that should look exactly like the source and change rarely: a hero from the old site during a migration, a pricing table, a footer.
The four ways, compared
| Method | Time per section | Handles the five problems | Good for |
|---|---|---|---|
| By hand in the HTML widget | 20 to 40 minutes | You do | One section, once |
| The script above | 2 minutes, after you have clean HTML and CSS | Scoping only | HTML you already own |
| A browser extension | 1 minute | Yes | Picking sections visually |
| An API call | Seconds | Yes | Many sections, pipelines, AI agents |
The catch with the script is the input. It needs the section's HTML and only the CSS that applies to it. On a real site that CSS is spread over several stylesheets and thousands of rules, and extracting the right ones is most of the work.
Or use one API call
MiroMiro's code endpoint resolves which CSS rules apply to a section of a live page and returns the finished template. Pass framework=elementor:
curl -s "https://miromiro.app/api/v1/code?url=stripe.com§ion=pricing&framework=elementor" \
-H "Authorization: Bearer $MIROMIRO_API_KEY" \
| jq -r .code > pricing.json
pricing.json is ready to import. It has absolute URLs, scoped CSS with the reset, the full-width breakout, responsive and hover rules, and scroll-reveal states already switched to visible. framework=shopify returns a theme section instead.
One limit to know: it is still one HTML widget, and a section the engine reads badly imports just as badly. Check the result in a staging site before you put it in front of a client.
If you work in ChatGPT or Claude, the same thing is available as a tool: connect the MCP server and ask for "the pricing section of stripe.com as an Elementor template".
You can start for free - 300 credits a month, no card. A section costs 25.
Summary
Use the HTML widget inside a full-width container, scope your CSS, and fix URLs before you wrap anything. Write the script yourself if the HTML is yours. Use an extractor when the section lives on a site whose CSS you would otherwise have to untangle by hand.
Frequently asked questions
What format is an Elementor template file?
JSON, with version, type, title and a content array of elements. Each element has an id, an elType and its own elements children. Widgets add a widgetType.
Can I convert HTML to native Elementor widgets automatically?
Not reliably. The original CSS targets the original markup, so remapping it onto heading and button widgets breaks the layout. One HTML widget keeps it intact.
Why does my imported HTML look squeezed?
The theme's content column. Set the container's content_width to full, and if the theme still constrains it, use the 100vw breakout above.
Does the HTML widget need Elementor Pro?
No. The HTML widget, containers and template import are in the free plugin.
Can I do the same for Shopify?
Yes. The equivalent is a section file in the theme's sections folder: the markup and a <style> block inside {% raw %}, followed by a {% schema %} block with a name and a preset.
Skip the maintenance - one API call
Design tokens, brand data, fonts, SVGs, images, and section code from any URL. 300 free credits a month, no card.
Soraia · Founder, MiroMiro
Building MiroMiro: a browser extension and API that extract design tokens, assets, and production-ready code from any live website. The gotchas in these posts come from the extraction engine's own commit history.
Follow on X