A first-time introduction to Virtual Frame — what it is, why it exists, and how it composes independently deployed web apps into a single page without a shared build.
There's a problem every large frontend eventually runs into, and it goes something like this: you have five teams, five stacks, five deployment pipelines — and one page. The design system lives in team A's repo. The checkout widget belongs to team B. The dashboard you're trying to embed was last touched by a team that no longer exists. None of these things ship together. All of them need to render on the same page, at the same time, and feel like one product.
The industry has been trying to solve this for a decade. The solutions all have a tax.
Module federation is great when host and remote share a build pipeline, and useless the moment they don't. The first time you need a coordinated upgrade across three repos, the "independent teams" story evaporates. Iframes give you perfect isolation and zero composability — you get a rigid rectangle that doesn't flow with your layout, can't inherit your theme, and picks fights about scroll, focus, and accessibility. Edge-side includes and server fragments work beautifully for static markup and fall apart the moment the remote needs its own runtime. Ad-hoc SPA shells work until they don't; then you're debugging a shared React instance that sees two different versions of the same context.
Each of these is the right answer to a different question. None of them is the right answer to the question most teams are actually asking: how do I compose fully independent web applications — different repos, different frameworks, different deploys — into one page, with real layout flow and real interactivity, without coupling any of them at build time?
Virtual Frame is a bet that you can do that if you stop thinking about embedding remote applications and start thinking about projecting them.
The one-paragraph version
Picture a hidden iframe loading another team's page. The page runs normally in there — same as if you'd opened it in a new tab. Virtual Frame takes what that hidden page is drawing and paints it into a slot on your own page, live. When the remote updates, your slot updates. When your users click, scroll, or type inside the slot, Virtual Frame forwards those interactions back to the hidden page so its app keeps working like it's running in a real browser tab.
The result: the remote app's output is part of your page. It flows with your layout, picks up your theme, and behaves like any other piece of your UI — because, as far as the browser is concerned, it is any other piece of your UI. The remote keeps running in its own world; its output lives in yours.
That's the entire pitch.
Why this mental shift matters
The most important word in the previous section is projecting, and it's worth slowing down on.
When you embed something — an iframe, a web component, a third-party widget — you're accepting its frame. The embedded thing has a boundary, and things stop at that boundary. Layout stops there. Events stop there. Themes stop there. You're not composing, you're docking.
When you project, the boundary collapses. The remote application still runs in its own browsing context — that part is non-negotiable, it's how you keep the remote's runtime from tripping over yours — but its rendered output flows into your page the way a child component's output would. The execution is isolated; the presentation is composed.
This is a surprisingly slippery idea the first time you encounter it, because we've been trained by twenty years of iframe behavior to think of "other-origin content" and "rigid rectangle" as the same thing. They're not. The rigidity is an implementation detail of how browsers expose iframe contents, not a law of nature.
How it actually works
Three primitives, no magic.
A source iframe, hidden off-screen. The remote runs as a complete, standalone application inside it — its framework boots, its router runs, its effects fire, its fonts load. Virtual Frame doesn't re-execute your app; it observes it. That's the key constraint: nothing about your remote has to change to be projectable.
A host element, which is any element you put on the page — a <div>, a <section>, a component root. Virtual Frame optionally attaches a Shadow DOM to it (open or closed) and mirrors the remote's <body> subtree into that shadow root. Shadow DOM gives you CSS isolation without giving up custom-property inheritance, which is the sweet spot: the remote's styles can't bleed into your page, but your theme still crosses the boundary.
A sync layer. Same-origin, this is a MutationObserver on the source plus CSS rewriting and event re-dispatch. Cross-origin, a bridge script loaded once in the remote serializes the DOM and events over postMessage, and the host reconstructs them. The cross-origin story is worth dwelling on, because this is where iframes usually give up: there is no host-side configuration. Drop the bridge into the remote, and every host on every origin can project it.
Everything else — selector-based projection, canvas streaming, SSR with resumption, the shared reactive store — is built on top of these three primitives. But the primitives are simple, and that's on purpose. Simple primitives compose.
A taste of code
The shortest path is the custom element. This is a complete working projection:
<script type="module">
import "virtual-frame/element";
</script>
<virtual-frame
src="https://dashboard.example.com"
isolate="open"
style="width: 100%; height: 600px"
></virtual-frame>
That's it. No build plugin. No config file. No framework buy-in. When the element connects to the DOM, it creates a hidden iframe at src, attaches a shadow root to the custom element, and starts projecting. When the element is removed, everything tears down.
If you want to project only a piece of the remote — a chart, a panel, a sidebar — add a selector:
<virtual-frame
src="https://dashboard.example.com"
selector="#metrics-chart"
isolate="open"
></virtual-frame>
The full remote still runs in the background, so the selected subtree behaves exactly as it would in its native page. Its event handlers work. Its data fetches work. Its animations work. You just pulled a widget out of another team's app without negotiating an API contract.
If you're in React, it's a component:
import { VirtualFrame, useVirtualFrame, useStore } from "@virtual-frame/react";
import { createStore } from "@virtual-frame/store";
const store = createStore();
function Dashboard() {
const count = useStore(store, ["count"]);
const frame = useVirtualFrame("https://remote.example.com", { store });
return (
<>
<p>Count: {count ?? 0}</p>
<VirtualFrame frame={frame} selector="#counter" />
</>
);
}
The store there is worth a second look. It's an event-sourced, proxy-based reactive object that synchronizes between host and remote automatically. Read and write it like a plain object on either side; mutations propagate over the same message channel the projection uses. It's the piece you reach for when "host and remote need to share state" comes up, and it means you don't have to invent a coordination protocol.
There are first-class bindings for Vue, Svelte, Solid, Angular, Next.js, Nuxt, SvelteKit, TanStack Start, SolidStart, Analog, React Router, and a few more. They all sit on the same core engine. Pick whichever is closest to how your page is built.
What you actually get
A few consequences of the projection model that are worth naming explicitly, because they're the things that tend to surprise people:
Layout flow just works. Projected content fills its host element's box and participates in flex and grid like any other child. No width="100%"-then-cry dance. No resize observer hacks. No scroll-inside-scroll confusion.
Theming crosses the boundary. CSS custom properties inherit into the shadow root, so your design tokens — colors, spacing, fonts, dark mode — reach the projection without any coordination with the remote team. They don't even need to know your tokens exist; they just need to use var() for things they want themeable.
Accessibility is inherited. The projected DOM is real DOM in your tree, so focus traversal, screen readers, and keyboard navigation see it as part of the page. You're not fighting the iframe a11y model.
SSR with resumption. The meta-framework integrations can server-fetch the remote, inline the projection inside declarative Shadow DOM, and resume on the client without a second round-trip. First paint is styled and interactive content arrives without the iframe flash.
Cross-origin without proxy gymnastics. No CORS negotiations. No server-side proxy. Ship the bridge once in the remote, and every host everywhere can project it.
What it isn't
Worth being honest about the non-goals, because they come up in design reviews and getting them wrong wastes everyone's time.
Virtual Frame is not an iframe replacement — it still uses one under the hood, because that's how you give the remote its own browsing context. What's different is that you don't see the iframe; its DOM is projected into your host.
It is not a trust boundary from the host's side. The iframe sandboxes the remote's script execution (same-origin policy still applies; the remote's JS can't touch host DOM or globals), but once you project the remote's DOM into your page, your code can read and manipulate that projected tree. If the remote is untrusted, keep the iframe visible and don't project.
It is not module federation. Nothing is shared at build time. No shared React instance, no shared bundle graph. If you need runtime coordination, use the shared store — a typed message channel, not a hidden dependency on a shared import.
It is not a hydration framework. The remote hydrates normally inside its own iframe. You don't rewrite your remote app to make it projectable; any web page will do.
When to reach for it
Virtual Frame fits well when you need to compose multiple independently deployed apps into one page without coordinating a build. When you want to embed UI from another team, tenant, or origin while keeping layout flow and interactivity native. When you want to retire a user-visible iframe that looks and feels like one. When you want to project only a slice of a remote app and let the rest keep running in the background.
Skip it when host and remote already share a build — module federation or a plain component export is lighter. Skip it when you need a hard security boundary against an untrusted remote — keep the iframe visible. Skip it when the remote doesn't need interactivity and doesn't change — a server-rendered fragment is simpler.
Where to go from here
The shortest possible next step: npm install virtual-frame, drop in the custom-element snippet above with a URL you own, and watch it work. Most of the mental model survives contact with five minutes of playing with it.
When you're ready for more:
- What is Virtual Frame? — the conceptual overview in depth, including the parts of the mental model this article skipped.
- Getting Started — installation, the three integration paths, and the first-projection checklist.
- Framework guides — React, Vue, Svelte, Solid, Angular, Next.js, Nuxt, and the rest. Pick yours.
-
Cross-Origin — the bridge protocol, CSP requirements, and the
proxyoption for keeping first-party cookies. - Store — the reactive message channel for bidirectional state between host and remote.
- API reference — every option, property, and method.
The thesis, one more time: stop embedding remote applications. Project them. The boundary you've been working around doesn't have to be there.
Top comments (0)