nteract elements

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/html outputs (pandas DataFrames, custom visualizations)
  • text/markdown outputs (rendered markdown with embedded scripts)
  • Widget outputs (ipywidgets, anywidget)

Without isolation, malicious code could:

  1. Access the parent DOM and steal data
  2. Call Tauri APIs (in desktop apps) to access the filesystem
  3. 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-frame

New 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():

Theme syncs to iframe via postMessage

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:

HTML Editor
Live PreviewLoading...

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().

Search within iframe content with highlighting and navigation:

0/0

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 mode
  • eval - Execute JavaScript (for bootstrapping)
  • clear - Clear all content
  • search - Search for text in content
  • search_navigate - Navigate to a specific search match
  • comm_* - Widget communication

Iframe → Parent:

  • ready - Iframe loaded
  • resize - Content height changed
  • link_click - User clicked a link
  • error - JavaScript error occurred
  • search_results - Number of matches found
  • widget_* - Widget state updates

Props

PropTypeDefaultDescription
idstringUnique ID for message routing
initialContentRenderPayloadContent to render on ready
darkModebooleantrueInitial dark mode state
minHeightnumber24Minimum iframe height
maxHeightnumber2000Maximum iframe height
classNamestringAdditional CSS classes
onReady() => voidCalled when iframe is ready
onResize(height: number) => voidCalled on content resize
onLinkClick(url: string, newTab: boolean) => voidCalled on link click
onDoubleClick() => voidCalled on double-click
onWidgetUpdate(commId: string, state: object) => voidWidget state update
onError(error: { message: string }) => voidJavaScript error handler
onMessage(message: IframeToParentMessage) => voidAll 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 content
  • allow-downloads - File downloads from widgets
  • allow-forms - Form submissions
  • allow-pointer-lock - Interactive visualizations
  • allow-popups - Links opening new windows
  • allow-popups-to-escape-sandbox - Popups are unrestricted
  • allow-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 connections
  • comm_msg - Sending state updates
  • comm_close - Closing connections
  • widget_update - Receiving state changes from widgets

See the Widgets documentation for more details on widget integration.

On this page