Skip to main content

Building Extension UI Panels

Extensions can contribute custom panels that appear in the app's flexible pane layout. Panels are rendered in sandboxed iframes, giving you full control over HTML, CSS, and JavaScript while maintaining security boundaries.

How panel iframes work

When a user opens an extension panel, the host:

  1. Creates an <iframe> in the renderer process.
  2. Loads it from the ext-ui://<extensionId>/<uiEntry> custom protocol.
  3. Applies a strict Content Security Policy (CSP) that blocks inline scripts, external resources, and cross-origin access.
  4. Establishes a MessagePort bridge between the iframe and the extension's worker process.

The iframe runs in its own origin (ext-ui://<extensionId>), which means:

  • It cannot access window.parent, window.top, or any other frame's DOM.
  • It cannot navigate away from the ext-ui:// protocol.
  • All resources (CSS, images, JS) must be bundled in your extension directory.

Declaring a panel type

Add a panel type to your manifest:

{
"permissions": ["ui:contribute-pane", "bible:read"],
"contributes": {
"panelTypes": [
{
"id": "verseExplorer",
"title": "Verse Explorer",
"icon": "assets/icon.svg",
"uiEntry": "ui/index.html",
"defaultBucket": "right"
}
]
}
}
  • uiEntry -- Path to the HTML file, relative to the extension root.
  • defaultBucket -- Where the panel appears by default: "left", "right", or "bottom". The user can move it.
  • icon -- Optional SVG icon shown in the panel tab.

Panel HTML file

Your HTML file is a standard web page. Keep it simple:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Verse Explorer</title>
<link rel="stylesheet" href="styles.css" />
</head>
<body>
<div id="content">
<p>Loading...</p>
</div>
<script src="panel.js"></script>
</body>
</html>
warning

Inline <script> tags are blocked by the CSP. Always use external script files.

The @bible/extension-ui SDK

The @bible/extension-ui package provides helper functions for communicating with the extension host from inside the iframe. Include it as a bundled script or import it if you are using a build tool.

Initialization

import { init } from '@bible/extension-ui';

const bridge = await init();

The init() function establishes the message bridge with the host and returns an object with methods for common operations.

Receiving data from the worker

The extension worker sends messages to the panel using the workspace API. In the panel, listen for messages:

bridge.onMessage((data) => {
// data is whatever the worker sent -- always JSON-serializable
document.getElementById('content').textContent = data.text;
});

Sending data to the worker

bridge.postMessage({ action: 'refresh', verseId: 43003016 });

The worker receives this through the panel's message handler registered during activate().

Linking verses

The SDK provides a helper to make verse references in your panel clickable. When the user clicks a linked verse, the app navigates to it:

import { linkVerses } from '@bible/extension-ui';

// Automatically finds elements with data-verse-id attributes
// and makes them clickable navigation links
linkVerses(document.getElementById('content'));

In your HTML:

<span data-verse-id="43003016">John 3:16</span>

You can also navigate programmatically:

import { navigateToVerse } from '@bible/extension-ui';

await navigateToVerse(43003016); // Navigate to John 3:16

Theming

The app supports three themes: Light, Dark, and Sepia. Your panel should respect the active theme so it blends with the rest of the UI.

CSS custom properties

The host injects a set of CSS custom properties into the iframe's root element. Use these instead of hardcoded colors:

body {
background-color: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-ui);
font-size: var(--font-size-base);
line-height: var(--line-height-base);
}

a {
color: var(--color-accent);
}

.card {
background-color: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: var(--radius-md);
padding: var(--space-md);
}

.verse-text {
font-family: var(--font-bible);
font-size: var(--font-size-bible);
line-height: var(--line-height-bible);
}

Common custom properties:

PropertyPurpose
--bg-primaryMain background color
--bg-secondaryCard/surface background
--text-primaryPrimary text color
--text-secondaryMuted text color
--color-accentLinks, active elements
--border-colorBorders and dividers
--font-uiSans-serif UI font
--font-bibleSerif font for Scripture text
--font-size-baseBase UI font size
--font-size-bibleBible text font size
--line-height-bibleBible text line height (1.7-1.8)
--space-sm, --space-md, --space-lgSpacing scale
--radius-sm, --radius-mdBorder radius scale

Responding to theme changes

When the user switches themes, the host updates the CSS custom properties on the iframe root. If you need to run JavaScript in response to theme changes:

import { init } from '@bible/extension-ui';

const bridge = await init();

bridge.onThemeChanged((theme) => {
// theme is 'light', 'dark', or 'sepia'
console.log('Theme changed to:', theme);
// Update any canvas elements, charts, or dynamic styling
});

Example: a verse cross-reference panel

Here is a complete example of a panel that shows related verses for the currently active verse.

extension.json

{
"id": "ext.my-publisher.related-verses",
"name": "Related Verses",
"version": "1.0.0",
"publisher": "my-publisher",
"engines": { "bibleApp": "^1.0.0" },
"main": "src/main.js",
"permissions": ["bible:read", "ui:contribute-pane"],
"activationEvents": ["onView:relatedVerses"],
"contributes": {
"panelTypes": [
{
"id": "relatedVerses",
"title": "Related Verses",
"uiEntry": "ui/index.html",
"defaultBucket": "right"
}
]
}
}

src/main.js

'use strict';

let eventHandle = null;

exports.activate = async function activate(api) {
// When the active verse changes, send data to the panel
eventHandle = await api.bible.onDidChangeActiveVerse.subscribe(
async (payload) => {
if (!payload) return;

const verse = await api.bible.getVerse(payload.verseId);

// Send the verse data to any open panels of our type
const panels = await api.workspace.getOpenPanels();
for (const panel of panels) {
if (panel.contentType === 'relatedVerses') {
// The panel will receive this through bridge.onMessage()
await api.workspace.openPanel('relatedVerses', {
message: {
verseId: payload.verseId,
reference: verse.reference,
text: verse.textPlain || verse.text,
},
});
}
}
}
);
};

exports.deactivate = async function deactivate() {
if (eventHandle) {
await eventHandle.dispose();
eventHandle = null;
}
};

ui/index.html

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Related Verses</title>
<style>
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: var(--font-ui);
padding: 12px;
margin: 0;
}
h2 {
font-size: 14px;
margin: 0 0 8px;
color: var(--text-secondary);
}
.verse-text {
font-family: var(--font-bible);
font-size: var(--font-size-bible);
line-height: var(--line-height-bible);
}
.empty {
color: var(--text-secondary);
font-style: italic;
}
</style>
</head>
<body>
<div id="content">
<p class="empty">Navigate to a verse to see related content.</p>
</div>
<script src="panel.js"></script>
</body>
</html>

ui/panel.js

import { init, linkVerses } from '@bible/extension-ui';

async function start() {
const bridge = await init();

bridge.onMessage((data) => {
const el = document.getElementById('content');
el.innerHTML = `
<h2>${data.reference}</h2>
<div class="verse-text">${data.text}</div>
`;
linkVerses(el);
});
}

start();

Tips

  • Keep panels lightweight. Every iframe has its own rendering context. Avoid heavy frameworks if a few lines of vanilla JS will do.
  • Use defaultBucket to suggest where your panel belongs. Users can always rearrange.
  • Test in all three themes. Use the CSS custom properties and test that your panel looks correct in Light, Dark, and Sepia.
  • Bundle all assets. The CSP blocks external resource loading. Fonts, images, and libraries must be included in your extension directory.