← Blog
April 19, 2026 · 9 min read · Candas Özgenç

Designing iframe widgets that work in Notion, Obsidian, OBS, and WordPress

Every iframe host has opinions. Here's what broke us the most, and the contract we eventually settled on.

WidgetCraft's promise is "one URL, every surface". Paste a widgetcraft.ai embed URL into Notion, Obsidian, OBS, WordPress, your own website — and it renders. After thirteen widgets and four months of platform-specific paper cuts, here's the shape of the problem and the contract that survived.

Each host is a different runtime

The word "iframe" suggests a standard. It isn't. Each host wraps your iframe in a different container with different constraints:

  • Notion — responsive, resizable by the user, respects postMessage-driven height changes if you include the magic wc_height protocol. Does NOT pass Origin on embeds.
  • Obsidian — native iframe inside a webview, no Origin at all. Plays well with standard iframe content; blocks some clipboard APIs.
  • OBS — browser source, full width/height control by the streamer, chroma-key friendly backgrounds. Needs transparent or specific solid-color modes.
  • WordPress (Gutenberg) — depends on whether the user pastes a URL (oEmbed lookup) or a shortcode. We ship both via the WidgetCraft Embeds plugin.
  • Ghost / personal sites — arbitrary CSS context; often get wrapped in containers that set overflow: hidden or height: 0.

Early on, we tried to detect the host and branch. That was a losing game — any host we added ran the risk of breaking another. So we inverted the approach.

The widget contract

Every WidgetCraft widget now follows a uniform contract:

1. Transparent background by default. Never paint a full viewport. When the user asks for a solid background, paint a card; the host's background shows around it. This makes the same widget look correct in Notion (white bg), Obsidian (dark bg), and an OBS transparent scene all at once.

2. Explicit pixel height, emitted via `postMessage`. Our useAutoSize hook measures the card and sends { type: "wc-height", height: 152 } to the parent. Notion's iframe wrapper listens and resizes; other hosts ignore it. Never pass 100% height to a vendor iframe — Spotify's IFrame API in particular silently renders white.

3. No-cache + X-Frame-Options: ALLOWALL. Widget routes under /w/* get explicit headers:

Cache-Control: no-store
X-Frame-Options: ALLOWALL
Content-Security-Policy: frame-ancestors *

The no-cache is because widgets are cheap to re-render and users expect edits to appear immediately. The frame-ancestors is because we WANT to be embeddable anywhere.

4. Iframe detection without breaking direct visits. Our WidgetFrame component checks window.self !== window.top. Inside an iframe, render the widget bare. Outside (a direct browser visit), wrap in a preview card with a "Customize in Builder" CTA. This is what saves us when someone emails a widget URL instead of embedding it — they still land somewhere useful.

5. All config in URL params, mirrored via postMessage. A widget responds to ?label=X&date=Y AND to window.postMessage({ type: "widgetcraft-config", label: "X", date: "Y" }). The builder uses the postMessage path for live preview; the published embed uses the URL path. Same resolver, same output.

What broke us

A sampler of things we got wrong before writing the contract down:

  • Spotify's IFrame API silently went white when we passed height: "100%". Fixed by forcing 152px explicit pixels. Their SDK doesn't document this.
  • Notion cached OG preview images aggressively. A widget URL with a new wc_config_id would render correctly but still show the old image in the Notion preview card for days. Fixed by cachebusting our OG image URL with the config id.
  • Obsidian blocked `navigator.clipboard.writeText` silently. Our builder's copy-URL button failed in Obsidian's "Embed preview" mode with no error. Fixed with a textarea + selection fallback.
  • OBS passed no Origin header and our postMessage origin check was blocking it. Fixed by accepting null origin explicitly (documented in the widget-surfaces contract).
  • Wix's wrapper added 20px of vertical padding we couldn't reach. Accepted it — the widget looks fine with some extra whitespace; the alternative was host-specific CSS hacks.

Sizing is the hardest sub-problem

If you only solve one thing, solve sizing. Iframes default to 150×300px with scrollbars if you don't intervene. In Notion, you get a tall thin slice that shows the widget badge but nothing else. Users assume the widget is broken.

Three approaches we tried:

  • Fixed pixel height in `height="N"` attribute — works in WordPress/Ghost, ignored by Notion.
  • CSS aspect-ratio — doesn't propagate through the iframe boundary.
  • postMessage height beacon — works in Notion + any host that implements the same protocol. Ignored by hosts that don't, but falls back gracefully because we ALSO set a sensible default height attribute for naive consumers.

The beacon message shape:

parent.postMessage({ type: "wc-height", height: measuredHeight }, "*");

A useAutoSize hook in each widget measures via ResizeObserver and emits. Notion picks it up within 100ms of first paint. Any host that doesn't implement it gets the initial height attribute and the widget clips nicely inside a card shape.

The takeaway

You can't test every iframe host. Instead, make the widget's own behavior so *constrained* that it renders correctly in any host that follows the HTML spec. No full-viewport paint. No client-origin assumptions. No APIs that hosts sometimes block. A clean postMessage channel for the hosts that speak it; gracious fallbacks for the ones that don't.

Writing the contract down (we keep ours at contracts/widget-surfaces.md) let us ship widgets faster after the tenth one than the first — every new widget now has a template of constraints to match, not a platform matrix to test against.