Pane for manage and persistent iframes.
A vanilla, headless, lightweight library to manage multiple iframes outside of your app's render tree, while still visually "mounting" them onto any element — without losing their state.
Extracted from the iframe management patterns of Vite DevTools, Nuxt DevTools and devframe.
Iframes lose all their state (navigation, scroll, JS, inputs) the moment they are detached or reparented in the DOM. That makes them painful in component frameworks: switching a tab, moving a panel, or re-rendering a subtree kills the embedded page.
iframe-pane keeps every iframe parked in a dedicated overlay container that never re-renders. Your components only render a plain placeholder element — the pane is positioned over it and follows its box. Unmounting just hides the iframe; remounting brings it back exactly where it left off.
- Headless & vanilla — zero dependencies, no UI, framework-agnostic; integrates in a few lines with Vue, React, Solid, or plain DOM
- State-preserving — iframes are never reparented; "mount" only syncs position/size onto a target element
- Warm hiding — hidden panes get
opacity: 0.001+pointer-events: noneinstead ofdisplay: none, so the embedded page keeps rendering and is never throttled or re-laid-out - Auto box syncing — a shared
ResizeObserveron targets plus windowscroll/resizelisteners (rAF-batched) keep panes glued to their targets - Pointer-events locking — temporarily disable pointer events on all iframes during drags or panel resizes, so iframes don't swallow your
pointermoves - LRU auto-dispose — optional cap on live panes (
maxPanes, default unlimited); least-recently-active unmounted panes are freed automatically - SSR-safe — managers can be created in module scope; the DOM is only touched on first use
pnpm add iframe-paneimport { createIframePanes } from 'iframe-pane'
const panes = createIframePanes()
// get-or-create a pane (options only apply on creation)
const pane = panes.ensure('docs', {
src: 'https://example.com/',
attrs: { allow: 'clipboard-read; clipboard-write' },
})
// visually attach it to any element — position and size follow automatically
pane.mount(document.querySelector('#panel')!)
// switching away? just unmount — the iframe stays alive, state preserved
pane.unmount()
// ...and mount it somewhere else later, state intact
pane.mount(anotherElement)The library is headless — iframes only carry minimal built-in styles. Customize them per state, at the manager level and/or per pane (camelCase CSS properties):
const panes = createIframePanes({
// applied to every iframe on creation
styleDefault: { borderRadius: '8px', transition: 'opacity 150ms ease' },
// applied when a pane is shown (over the built-in `opacity: 1; pointer-events: auto`)
styleActive: { boxShadow: '0 2px 12px rgba(0, 0, 0, 0.2)' },
// applied when a pane is hidden (over the built-in `opacity: 0.001; pointer-events: none`)
styleHidden: { visibility: 'hidden' },
})
// per-pane overrides, merged over the manager's
panes.ensure('docs', {
src: 'https://example.com/',
style: { colorScheme: 'dark' },
})Precedence: built-ins → manager options → pane options. On a state transition, style keys owned by the opposite state are reset to their styleDefault value (or removed), so the two states can use disjoint properties safely.
Iframes capture pointer events, which breaks drag interactions happening above them (panel resizing, window dragging, …). Lock them while the interaction runs:
splitter.addEventListener('pointerdown', () => {
const release = panes.lockPointerEvents()
window.addEventListener('pointerup', release, { once: true })
})Locks are counted, so overlapping interactions compose — pointer events are restored when the last lock is released.
// list all managed panes
for (const pane of panes.list())
console.log(pane.id, pane.isMounted, pane.isVisible)
// LRU-like auto-dispose: keep at most 5 live iframes.
// Least-recently-active unmounted panes are disposed first;
// mounted panes are never evicted.
const panes = createIframePanes({ maxPanes: 5 })iframe-pane is iframe-first, but the pane machinery (box syncing, warm hiding, pointer-events locking, LRU eviction) is element-agnostic. Pass a tagName to manage any element the same way — for example a <div> for a custom-render dock that also needs to persist out of the render tree:
// create a <div> pane instead of an <iframe>
const dock = panes.ensure('custom', { tagName: 'div' })
dock.element.append(myRenderedNode) // pane.element is the managed <div>
dock.mount(document.querySelector('#stage')!)Or adopt an existing element with element (it takes precedence over tagName); it's appended into the container and gets the same base styles and state:
const node = document.createElement('div')
panes.ensure('custom', { element: node })pane.element is the canonical accessor for any pane. pane.iframe remains as a back-compat alias — it returns the managed element when it's an <iframe>, and undefined for non-iframe panes. src is only applied to iframe panes and is ignored otherwise.
ensure infers the pane's element type from the options, so the accessors are precisely typed at the call site:
panes.ensure('a').element // HTMLIFrameElement
panes.ensure('a').iframe // HTMLIFrameElement (non-nullable)
panes.ensure('b', { tagName: 'div' }).element // HTMLDivElement
panes.ensure('b', { tagName: 'div' }).iframe // undefined
panes.ensure('c', { element: myCanvas }).element // HTMLCanvasElement
// looked-up panes have an unknown element type, so `iframe` is nullable
panes.get('a')?.iframe // HTMLIFrameElement | undefined| Option | Default | Description |
|---|---|---|
container |
fixed full-viewport <div> in <body> |
Positioned element hosting the parked iframes |
maxPanes |
Infinity |
LRU auto-dispose limit |
styleDefault |
— | Base inline styles applied to every iframe on creation |
styleActive |
{ opacity: '1', pointerEvents: 'auto' } |
Inline styles applied when a pane is shown |
styleHidden |
{ opacity: '0.001', pointerEvents: 'none' } |
Inline styles applied when a pane is hidden |
zIndex |
unset | z-index of the default container |
document |
globalThis.document |
Document to operate on |
onPaneCreated / onPaneDisposed |
— | Lifecycle callbacks |
Returns an IframePanes manager:
ensure(id, options?)— get-or-create a pane (src,attrs,tagName,element,style,styleActive,styleHidden,onCreated; creation-only)get(id)/has(id)/list()lockPointerEvents()— returns a release functionmaxPanes— read/write; lowering evicts immediatelyupdateAll()— manually re-sync all mounted panesdispose()— tear everything down
mount(target)— show and keep the iframe synced over the target elementunmount()— detach and hide (iframe kept alive)show()/hide()— toggle visibility without detachingupdate()— manually re-sync the boxdispose()— remove the element and unregister itelement— the managed element (an<iframe>by default)iframe— back-compat alias: the managed element when it's an<iframe>, elseundefinedtarget,isMounted,isVisible,isDisposed,lastActiveAt
The pattern is identical everywhere: render an empty placeholder element, mount the pane to it on mount, unmount on cleanup. Create the manager once, in module scope.
import { createIframePanes } from 'iframe-pane'
const panes = createIframePanes()
function switchTab(id: string, src: string) {
for (const pane of panes.list())
pane.unmount()
panes.ensure(id, { src }).mount(document.querySelector('#stage')!)
}// panes.ts
import { createIframePanes } from 'iframe-pane'
export const panes = createIframePanes()<!-- IframePane.vue -->
<script setup lang="ts">
import { onScopeDispose, useTemplateRef, watchPostEffect } from 'vue'
import { panes } from './panes'
const props = defineProps<{ id: string, src: string }>()
const el = useTemplateRef('el')
const pane = panes.ensure(props.id, { src: props.src })
watchPostEffect(() => {
if (el.value)
pane.mount(el.value)
})
onScopeDispose(() => pane.unmount())
</script>
<template>
<div ref="el" class="h-full w-full" />
</template>// IframePane.tsx
import { useEffect, useRef } from 'react'
import { panes } from './panes'
export function IframePane({ id, src }: { id: string, src: string }) {
const el = useRef<HTMLDivElement>(null)
useEffect(() => {
const pane = panes.ensure(id, { src })
pane.mount(el.current!)
return () => pane.unmount()
}, [id, src])
return <div ref={el} style={{ width: '100%', height: '100%' }} />
}// IframePane.tsx
import { onCleanup, onMount } from 'solid-js'
import { panes } from './panes'
export function IframePane(props: { id: string, src: string }) {
let el!: HTMLDivElement
onMount(() => {
const pane = panes.ensure(props.id, { src: props.src })
pane.mount(el)
onCleanup(() => pane.unmount())
})
return <div ref={el} style={{ width: '100%', height: '100%' }} />
}- The default container is
position: fixed. If an ancestor of<body>-level content creates a containing block (transform,filter,contain, …), provide your owncontainerplaced appropriately instead. - Panes follow target boxes, not target lifecycles: when the target element is removed from the DOM, call
unmount()(the framework integrations above do this in their cleanup hooks). - Multiple panes may be mounted to the same target — they stack in creation order.
pnpm install
pnpm playMIT License © Anthony Fu