Isolated Frame
Secure iframe sandbox for rendering untrusted HTML, Markdown, and widget outputs
Loading...
IsolatedFrame renders untrusted content in a secure blob: URL iframe with strict sandbox restrictions. This prevents:
- XSS attacks from malicious notebook outputs
- DOM access to the parent application
- Tauri API access in desktop environments
Why Isolation?
Jupyter outputs can contain arbitrary HTML and JavaScript from:
text/htmloutputs (pandas DataFrames, custom visualizations)text/markdownoutputs (rendered markdown with embedded scripts)- Widget outputs (ipywidgets, anywidget)
Without isolation, malicious code could:
- Access the parent DOM and steal data
- Call Tauri APIs (in desktop apps) to access the filesystem
- Perform XSS attacks against the user
IsolatedFrame uses a blob: URL with sandbox restrictions (no allow-same-origin) to create a completely isolated origin.
Installation
npx shadcn@latest add @nteract/isolated-frameNew to @nteract? pnpm dlx shadcn@latest registry add @nteract
Copy from nteract/elements.
Setup
IsolatedFrame requires IsolatedRendererProvider to be present in your component tree. The provider loads the renderer bundle and makes it available to all IsolatedFrame components.
Option A: Fetch from URL (Web Apps)
Host the renderer bundle files and configure the provider with basePath:
import { IsolatedRendererProvider } from "@/components/isolated"
function App() {
return (
<IsolatedRendererProvider basePath="/isolated">
{/* All IsolatedFrame components will use this bundle */}
<MyNotebook />
</IsolatedRendererProvider>
)
}Option B: Vite Plugin (Tauri/Desktop)
For desktop apps or when you want the bundle embedded in your app, use a custom loader:
import { IsolatedRendererProvider } from "@/components/isolated"
function App() {
return (
<IsolatedRendererProvider
loader={() => import("virtual:isolated-renderer")}
>
<MyNotebook />
</IsolatedRendererProvider>
)
}Usage
Once the provider is set up, use IsolatedFrame anywhere in your app:
import { useRef } from "react"
import { IsolatedFrame, type IsolatedFrameHandle } from "@/components/isolated"
function HtmlOutput({ html }) {
const frameRef = useRef<IsolatedFrameHandle>(null)
return (
<IsolatedFrame
ref={frameRef}
darkMode={true}
initialContent={{
mimeType: "text/html",
data: html,
}}
onReady={() => console.log("Frame ready")}
onResize={(height) => console.log("New height:", height)}
/>
)
}Examples
HTML Table
Loading...
<IsolatedFrame
initialContent={{
mimeType: "text/html",
data: `<table>...</table>`,
}}
/>Styled Content
Loading...
<IsolatedFrame
initialContent={{
mimeType: "text/html",
data: `<style>.card { ... }</style><div class="card">...</div>`,
}}
/>Interactive Content
Scripts execute safely inside the iframe without access to the parent:
Loading...
<IsolatedFrame
initialContent={{
mimeType: "text/html",
data: `
<button onclick="count++">Increment</button>
<script>let count = 0;</script>
`,
}}
/>Theme Synchronization
Dark mode can be toggled at runtime via setTheme():
Loading...
const frameRef = useRef<IsolatedFrameHandle>(null)
// Toggle theme
frameRef.current?.setTheme(isDark)
<IsolatedFrame
ref={frameRef}
darkMode={initialDarkMode}
initialContent={content}
/>Interactive HTML Editor
Try editing the HTML below to see live updates via the render() method:
This demo combines CodeMirrorEditor with IsolatedFrame to create a live HTML playground. Changes are debounced (300ms) and sent to the iframe via frameRef.current.render().
Global Find (Search)
Search within iframe content with highlighting and navigation:
Loading...
const frameRef = useRef<IsolatedFrameHandle>(null);
const [matchCount, setMatchCount] = useState(0);
// Search for text (case-insensitive)
frameRef.current?.search("accuracy", false);
// Navigate between matches
frameRef.current?.searchNavigate(0); // first match
frameRef.current?.searchNavigate(1); // second match
// Clear search highlights
frameRef.current?.search("");
// Listen for search results
<IsolatedFrame
ref={frameRef}
onMessage={(msg) => {
if (msg.type === "search_results") {
setMatchCount(msg.payload.count);
}
}}
/>Search matches are highlighted in yellow, and the active match (after searchNavigate) is highlighted in orange and scrolled into view.
Architecture
Communication between parent and iframe uses postMessage:
┌─────────────────────────────────────────────────────────────┐
│ Parent Window │
│ │
│ ┌─────────────────┐ postMessage ┌────────────┐ │
│ │ IsolatedFrame │ ──────────────────────▶│ │ │
│ │ Component │ render, theme, │ Iframe │ │
│ │ │ eval, clear │ (blob:) │ │
│ │ │ ◀──────────────────────│ │ │
│ └─────────────────┘ ready, resize, └────────────┘ │
│ link_click, error │
└─────────────────────────────────────────────────────────────┘Message Types
Parent → Iframe:
render- Render content (HTML, Markdown, etc.)theme- Update dark/light modeeval- Execute JavaScript (for bootstrapping)clear- Clear all contentsearch- Search for text in contentsearch_navigate- Navigate to a specific search matchcomm_*- Widget communication
Iframe → Parent:
ready- Iframe loadedresize- Content height changedlink_click- User clicked a linkerror- JavaScript error occurredsearch_results- Number of matches foundwidget_*- Widget state updates
Props
| Prop | Type | Default | Description |
|---|---|---|---|
id | string | — | Unique ID for message routing |
initialContent | RenderPayload | — | Content to render on ready |
darkMode | boolean | true | Initial dark mode state |
minHeight | number | 24 | Minimum iframe height |
maxHeight | number | 2000 | Maximum iframe height |
className | string | — | Additional CSS classes |
onReady | () => void | — | Called when iframe is ready |
onResize | (height: number) => void | — | Called on content resize |
onLinkClick | (url: string, newTab: boolean) => void | — | Called on link click |
onDoubleClick | () => void | — | Called on double-click |
onWidgetUpdate | (commId: string, state: object) => void | — | Widget state update |
onError | (error: { message: string }) => void | — | JavaScript error handler |
onMessage | (message: IframeToParentMessage) => void | — | All messages |
Handle Methods
The ref exposes imperative methods:
interface IsolatedFrameHandle {
send: (message: ParentToIframeMessage) => void;
render: (payload: RenderPayload) => void;
eval: (code: string) => void;
setTheme: (isDark: boolean) => void;
clear: () => void;
search: (query: string, caseSensitive?: boolean) => void;
searchNavigate: (matchIndex: number) => void;
isReady: boolean;
isIframeReady: boolean;
}RenderPayload Type
interface RenderPayload {
mimeType: string; // e.g., "text/html", "text/markdown"
data: unknown; // Content (string for most types)
metadata?: Record<string, unknown>;
cellId?: string; // For output routing
outputIndex?: number;
append?: boolean; // Append instead of replace
replace?: boolean; // Replace all outputs
}Security Model
Critical: The iframe sandbox does NOT include allow-same-origin. This is intentional - adding it would give the iframe access to the parent's origin and any Tauri APIs.
Sandbox attributes:
allow-scripts- Required for interactive contentallow-downloads- File downloads from widgetsallow-forms- Form submissionsallow-pointer-lock- Interactive visualizationsallow-popups- Links opening new windowsallow-popups-to-escape-sandbox- Popups are unrestrictedallow-modals- alert/confirm/prompt
Renderer Bundle
IsolatedFrame requires a React renderer bundle to render content. The bundle includes React, all output components, and the widget system.
The renderer is a separate Vite project that builds an IIFE bundle. This bundle is eval'd into the iframe via postMessage.
Provider Configuration
The IsolatedRendererProvider handles loading the bundle. Configure it once at your app root:
// For web apps: fetch from a URL
<IsolatedRendererProvider basePath="/isolated">
<App />
</IsolatedRendererProvider>
// For Tauri/desktop: use a Vite virtual module
<IsolatedRendererProvider loader={() => import("virtual:isolated-renderer")}>
<App />
</IsolatedRendererProvider>If the provider is missing, IsolatedFrame will throw a helpful error directing you to this documentation.
Building the Isolated Renderer
To support widgets, create an isolated-renderer directory with a Vite config:
// isolated-renderer/vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "path";
export default defineConfig({
plugins: [react()],
build: {
lib: {
entry: path.resolve(__dirname, "index.tsx"),
name: "IsolatedRenderer",
formats: ["iife"],
fileName: () => "isolated-renderer.js",
},
outDir: "public/isolated",
rollupOptions: {
output: {
inlineDynamicImports: true,
},
// Exclude any desktop/native APIs
external: [/@tauri-apps\/.*/],
},
},
});The entry point (index.tsx) bootstraps React and registers a message handler:
// isolated-renderer/index.tsx
import { createRoot } from "react-dom/client";
import { useState, useEffect, useCallback } from "react";
// Signal that React is handling messages
window.__REACT_RENDERER_ACTIVE__ = true;
function IsolatedRendererApp() {
const [outputs, setOutputs] = useState([]);
const handleMessage = useCallback((event) => {
if (event.source !== window.parent) return;
const { type, payload } = event.data;
if (type === "render") {
setOutputs((prev) => [...prev, payload]);
} else if (type === "clear") {
setOutputs([]);
}
}, []);
useEffect(() => {
window.addEventListener("message", handleMessage);
window.parent.postMessage({ type: "renderer_ready" }, "*");
return () => window.removeEventListener("message", handleMessage);
}, [handleMessage]);
return outputs.map((output, i) => (
<OutputRenderer key={i} payload={output} />
));
}
// Initialize
const root = createRoot(document.getElementById("root"));
root.render(<IsolatedRendererApp />);Build with: vite build --config isolated-renderer/vite.config.ts
Then host the built files at a path accessible to your app (e.g., /isolated/) and configure the provider:
<IsolatedRendererProvider basePath="/isolated">
<App />
</IsolatedRendererProvider>Widget Integration
For Jupyter widgets, use CommBridgeManager to proxy widget communication between the parent and the isolated renderer:
import { CommBridgeManager } from "@/components/outputs/isolated"
// In your widget container
const bridge = new CommBridgeManager({
frame: frameRef.current,
store: widgetStore,
sendUpdate: (commId, state) => kernel.sendCommMsg(commId, state),
sendCustom: (commId, content) => kernel.sendCustomMsg(commId, content),
closeComm: (commId) => kernel.closeComm(commId),
})
// Forward iframe messages to bridge
<IsolatedFrame
onMessage={(msg) => bridge.handleIframeMessage(msg)}
/>The comm bridge handles:
comm_open- Opening new widget connectionscomm_msg- Sending state updatescomm_close- Closing connectionswidget_update- Receiving state changes from widgets
See the Widgets documentation for more details on widget integration.