| name | panel-custom-components |
| description | Build custom Panel components using JSComponent (vanilla JS, web components), ReactComponent (React/JSX), AnyWidgetComponent (AnyWidget spec for cross-platform), or MaterialUIComponent (Material UI themed). Use when wrapping JS libraries, creating interactive widgets, or building themed components. Includes decision guide, best practices, DOs/DON'Ts, and Playwright UI testing patterns. |
| metadata | {"version":"1.0.0","author":"holoviz","category":"web-development","difficulty":"advanced"} |
Panel Custom Components
This skill covers building custom Panel components that bridge Python and JavaScript. Use it when you need to:
- Wrap JavaScript libraries (D3, Leaflet, Chart.js, etc.)
- Create interactive widgets with custom UI behavior
- Create Material UI themed components for
panel-material-ui apps
Prerequisites: Solid JavaScript and React knowledge assumed.
1. Decision Guide - Which Component Type to Use
Comparison Table
| Criteria | JSComponent | ReactComponent | AnyWidgetComponent | MaterialUIComponent |
|---|
| Best For | Vanilla JS libs, Web Components, D3, Leaflet, simple widgets | React ecosystem, complex state, MUI/Chakra libs | Cross-platform (Jupyter+Panel), community sharing | panel-material-ui apps, MUI theming |
| JS Pattern | DOM manipulation | React/JSX | AnyWidget AFM spec | React/JSX + MUI |
| State Sync | model.on('param', cb) | model.useState("param") | model.get/set/save_changes | model.useState("param") |
| Export | export function render({model, el}) | export function render({model, el}) | export default { render } | export function render({model, el}) |
| Base Import | panel.custom.JSComponent | panel.custom.ReactComponent | panel.custom.AnyWidgetComponent | panel_material_ui.MaterialUIComponent |
Decision Flow
┌─────────────────────────────────────────────────────────────────┐
│ Need Material UI theming / using panel-material-ui? │
│ YES → MaterialUIComponent │
│ NO ↓ │
├─────────────────────────────────────────────────────────────────┤
│ Need Jupyter compatibility / sharing community widgets? │
│ YES → AnyWidgetComponent │
│ NO ↓ │
├─────────────────────────────────────────────────────────────────┤
│ Using React libraries or need complex state management? │
│ YES → ReactComponent │
│ NO ↓ │
├─────────────────────────────────────────────────────────────────┤
│ Vanilla JS, Web Components, or simple DOM manipulation? │
│ YES → JSComponent │
└─────────────────────────────────────────────────────────────────┘
2. Development Workflow
Important: Build custom components in two phases. By experience, getting JS imports and responsive sizing to work can take significant debugging effort.
Phase 1: POC (Proof of Concept)
Before building the full component, create a minimal example using your actual target library that validates:
- JS imports work - The library loads without "Unexpected token 'export'" or CORS errors
- Library renders something - You can see actual output from the library (a chart, a map, a widget, etc.)
- Python-JS connection works - State syncs bidirectionally via
model.on() / model.useState()
- Element displays responsively - Component fills container and handles resize events
The goal is to see your library working in a Panel component before investing time in the full implementation. Use the template below as a starting point, but replace the placeholder library with your actual library and render something real from it.
Minimal POC template:
import param
from panel.custom import JSComponent
class MyComponentPOC(JSComponent):
"""Minimal POC to validate imports and responsiveness.
Replace 'my-lib' with your actual library and render something from it!
"""
value = param.String(default="Hello")
_importmap = {
"imports": {
"my-lib": "https://esm.sh/my-lib@1.0.0",
}
}
_esm = """
import myLib from 'my-lib';
export function render({ model, el }) {
// 1. Verify import works
console.log('Library loaded:', myLib);
// 2. Render something from the library!
// Replace this with actual library usage, e.g.:
// - myLib.createChart(el, data)
// - new myLib.Map(el)
// - myLib.render(<Component />, el)
const div = document.createElement('div');
div.id = 'poc-element';
div.textContent = model.value;
// 3. Verify Python-JS sync
model.on('value', () => { div.textContent = model.value; });
// 4. Verify responsive sizing
div.style.cssText = 'width:100%;height:100%;background:#f0f0f0;';
model.on('resize', () => {
console.log('Resize:', el.clientWidth, el.clientHeight);
});
el.appendChild(div);
}
"""
poc = MyComponentPOC(value="Test", height=200, sizing_mode="stretch_width")
poc.servable()
ALWAYS test the UI via Playwright smoke tests! (see details below)
POC validation checklist:
Phase 2: Finalize
Once the POC validates all three concerns, build out the full component:
- Add all parameters and their JS sync handlers
- Implement the full library integration
- Add error handling and edge cases
- Add CSS styling via
_stylesheets
3. Core Patterns (All Component Types)
Python Class Structure
All custom components inherit from a base class and use param for state:
import param
from panel.custom import JSComponent
from pathlib import Path
class MyComponent(JSComponent):
"""A custom component with Python-JS state synchronization."""
value = param.Integer(default=0, bounds=(0, 100), doc="Current value")
label = param.String(default="Click me", doc="Button label")
_esm = Path(__file__).parent / "my_component.js"
_importmap = {
"imports": {
"lodash": "https://esm.sh/lodash@4.17.21",
}
}
_stylesheets = [
Path(__file__).parent / "my_component.css",
]
ESM Export Pattern
All component types export a render function:
export function render({ model, el }) {
}
export default {
render({ model, el }) {
}
}
Import Maps for CDN Dependencies
_importmap = {
"imports": {
"canvas-confetti": "https://esm.sh/canvas-confetti@1.6.0",
"@mui/material/": "https://esm.sh/@mui/material@5.16.7/",
"my-react-lib": "https://esm.sh/my-react-lib?external=react,react-dom",
}
}
Child and Children Parameters
Nest Panel components inside custom components:
from panel.custom import JSComponent, Child, Children
class Container(JSComponent):
header = Child()
items = Children()
_esm = """
export function render({ model, el }) {
const header = model.get_child("header");
el.appendChild(header);
for (const item of model.get_child("items")) {
el.appendChild(item);
}
}
"""
Event Handling
JavaScript → Python:
button.onclick = () => {
model.send_event('button_click', { timestamp: Date.now() });
};
class MyComponent(JSComponent):
def _handle_button_click(self, event):
print(f"Button clicked at {event.data['timestamp']}")
Python → JavaScript:
class MyComponent(JSComponent):
def trigger_animation(self):
self.send_msg({'action': 'animate', 'duration': 500})
model.on('msg:custom', (event) => {
if (event.data.action === 'animate') {
runAnimation(event.data.duration);
}
});
4. JSComponent Patterns
JSComponent is the foundation for building custom Panel components with vanilla JavaScript. Use it for DOM manipulation, Web Components, and wrapping libraries like D3, Leaflet, or Chart.js.
Basic Structure
import param
from panel.custom import JSComponent
class CounterButton(JSComponent):
"""A simple counter button component."""
value = param.Integer(default=0, doc="Current count")
_esm = """
export function render({ model, el }) {
const button = document.createElement('button');
button.id = 'counter-btn';
function update() {
button.textContent = `Count: ${model.value}`;
}
button.onclick = () => {
model.value += 1;
};
model.on('value', update);
update(); // Initialize
el.appendChild(button);
}
"""
State Synchronization
Reading and Writing Parameters:
const currentValue = model.value;
const label = model.label;
model.value = 42;
model.label = "New Label";
Subscribing to Changes:
export function render({ model, el }) {
const display = document.createElement('div');
model.on('value', () => {
display.textContent = model.value;
});
display.textContent = model.value;
el.appendChild(display);
}
Lifecycle Hooks
export function render({ model, el }) {
}
export function after_render({ model, el }) {
const { width, height } = el.getBoundingClientRect();
initChart(el, width, height);
}
model.on('resize', ({ width, height }) => {
canvas.width = width;
canvas.height = height;
redraw();
});
model.on('remove', () => {
clearInterval(interval);
document.removeEventListener('keypress', handler);
});
External Libraries Example (D3)
class D3BarChart(JSComponent):
data = param.List(default=[10, 20, 30, 40, 50])
_importmap = {
"imports": {
"d3": "https://esm.sh/d3@7"
}
}
_esm = """
import * as d3 from 'd3';
export function render({ model, el }) {
const svg = d3.select(el)
.append('svg')
.attr('width', 400)
.attr('height', 200);
function update() {
const bars = svg.selectAll('rect').data(model.data);
bars.enter()
.append('rect')
.merge(bars)
.attr('x', (d, i) => i * 50)
.attr('y', d => 200 - d * 3)
.attr('width', 40)
.attr('height', d => d * 3)
.attr('fill', 'steelblue');
bars.exit().remove();
}
model.on('data', update);
update();
}
"""
4.1. Wrapping Third-Party Web Components
When wrapping existing web components, follow these patterns:
Custom Element Lifecycle
Recommended: Use ESM imports instead of __javascript__ to avoid race conditions and timing issues. ESM imports guarantee the web component is fully loaded before your render() function executes.
JavaScript with ESM import:
import "https://esm.sh/@google/model-viewer@3.4.0";
export function render({ model, el }) {
const viewer = document.createElement('model-viewer');
viewer.id = 'model-viewer';
viewer.style.display = "block";
viewer.style.width = "100%";
viewer.style.height = "100%";
viewer.alt = model.alt;
if (model.src) viewer.setAttribute("src", model.src);
if (model.auto_rotate) viewer.setAttribute("auto-rotate", "");
if (model.camera_controls) viewer.setAttribute("camera-controls", "");
el.appendChild(viewer);
model.on('auto_rotate', () => {
if (model.auto_rotate) viewer.setAttribute("auto-rotate", "");
else viewer.removeAttribute("auto-rotate");
});
viewer.addEventListener('error', (event) => {
console.error("Component error:", event.detail);
});
}
Why avoid __javascript__? The __javascript__ class attribute loads scripts asynchronously in parallel with your ESM code. This creates a race condition where render() may execute before the custom element is registered, causing document.createElement('model-viewer') to create a generic HTMLElement instead of the proper web component. ESM imports are resolved before your module executes, eliminating this timing issue.
Fallback: Using customElements.whenDefined()
If you must use __javascript__ (e.g., for libraries without ESM builds), protect against race conditions with customElements.whenDefined():
export function render({ model, el }) {
const viewer = document.createElement('model-viewer');
if (model.auto_rotate) {
viewer.setAttribute('auto-rotate', '');
}
el.appendChild(viewer);
customElements.whenDefined('model-viewer').then(() => {
viewer.autoRotate = model.auto_rotate;
});
model.on('auto_rotate', () => {
viewer.setAttribute('auto-rotate', model.auto_rotate ? '' : null);
if (typeof viewer.autoRotate !== 'undefined') {
viewer.autoRotate = model.auto_rotate;
}
});
}
5. ReactComponent Patterns
ReactComponent enables building custom Panel components with React and JSX. Use it for complex state management and React library integration.
Basic Structure
import param
from panel.custom import ReactComponent
class CounterButton(ReactComponent):
"""A simple counter button using React."""
value = param.Integer(default=0, doc="Current count")
_esm = """
export function render({ model }) {
const [value, setValue] = model.useState("value");
return (
<button id="counter-btn" onClick={() => setValue(value + 1)}>
Count: {value}
</button>
);
}
"""
State Hooks
model.useState() - Synced State:
export function render({ model }) {
const [value, setValue] = model.useState("value");
const [name, setName] = model.useState("name");
return (
<div>
<input value={name} onChange={(e) => setName(e.target.value)} />
<p>Value: {value}</p>
</div>
);
}
React.useState() - Local State:
export function render({ model }) {
const [synced, setSynced] = model.useState("value");
const [local, setLocal] = React.useState(false);
return (
<div>
<input
value={synced}
onChange={(e) => setSynced(e.target.value)}
onFocus={() => setLocal(true)}
onBlur={() => setLocal(false)}
/>
{local && <span>Editing...</span>}
</div>
);
}
React Hooks
All standard React hooks are available via the global React object:
export function render({ model }) {
const inputRef = React.useRef(null);
const [data, setData] = model.useState("data");
const [loading, setLoading] = React.useState(true);
React.useEffect(() => {
fetchData().then(result => {
setData(result);
setLoading(false);
});
return () => console.log("Cleanup");
}, []);
const filteredData = React.useMemo(() => {
return data.filter(item => item.active);
}, [data]);
if (loading) return <div>Loading...</div>;
return <DataDisplay data={filteredData} />;
}
External React Libraries
class ChartComponent(ReactComponent):
_importmap = {
"imports": {
"recharts": "https://esm.sh/recharts@2?external=react,react-dom",
}
}
_esm = """
import { LineChart, Line, XAxis, YAxis } from 'recharts';
export function render({ model }) {
const [data] = model.useState("data");
return (
<LineChart width={400} height={300} data={data}>
<XAxis dataKey="name" />
<YAxis />
<Line type="monotone" dataKey="value" stroke="#8884d8" />
</LineChart>
);
}
"""
6. AnyWidgetComponent Patterns
AnyWidgetComponent enables building custom Panel components using the AnyWidget specification for cross-platform compatibility (Jupyter + Panel).
Basic Structure
import param
from panel.custom import AnyWidgetComponent
class CounterButton(AnyWidgetComponent):
"""A simple counter using AnyWidget API."""
value = param.Integer(default=0, doc="Current count")
_esm = """
export default {
render({ model, el }) {
const button = document.createElement('button');
button.id = 'counter-btn';
function update() {
button.textContent = `Count: ${model.get("value")}`;
}
button.onclick = () => {
model.set("value", model.get("value") + 1);
model.save_changes(); // Required!
};
model.on("change:value", update);
update();
el.appendChild(button);
}
}
"""
AnyWidget Model API
Reading Values:
const value = model.get("value");
const name = model.get("name");
Writing Values (must call save_changes()):
model.set("value", 42);
model.set("name", "Alice");
model.save_changes();
Listening for Changes:
model.on("change:value", () => {
console.log("Value changed to:", model.get("value"));
});
["name", "count"].forEach(param => {
model.on(`change:${param}`, updateUI);
});
React with AnyWidget
class ReactCounter(AnyWidgetComponent):
value = param.Integer(default=0)
_importmap = {
"imports": {
"@anywidget/react": "https://esm.sh/@anywidget/react@0.2?deps=react@18.2.0,react-dom@18.2.0",
"react": "https://esm.sh/react@18.2.0",
}
}
_esm = """
import * as React from "react"; /* mandatory import */
import { createRender, useModelState } from "@anywidget/react";
const render = createRender(() => {
const [value, setValue] = useModelState("value");
return (
<button onClick={() => setValue(value + 1)}>
Count: {value}
</button>
);
});
export default { render };
"""
Important: Always pin React versions when using @anywidget/react. Use ?deps=react@18.2.0,react-dom@18.2.0 to bundle dependencies together with specific versions. Without version pinning, esm.sh serves React 19 which has breaking changes.
7. MaterialUIComponent Patterns
MaterialUIComponent enables building custom components that integrate with panel-material-ui theming.
Note: MaterialUIComponent uses _esm_base (not _esm) because it builds on the existing panel-material-ui JavaScript bundle which includes React and MUI dependencies. For complete examples, see the panel-material-ui custom components guide.
Server Mode Workaround for Inline ESM
Inline _esm_base strings have a known issue in server mode (panel serve) where the ThemedTransform adds ./utils imports that aren't properly resolved. External .jsx files work without this issue.
Apply this one-time patch at module load to fix inline _esm_base in server mode:
import re
from panel_material_ui import MaterialUIComponent
def patch_material_ui_inline_esm():
"""
Temporary fix for inline _esm_base in server mode.
Apply once at module load. See: issue #563
"""
original = MaterialUIComponent._render_esm_base
@classmethod
def patched(cls):
esm = original.__func__(cls)
return re.sub(
r'import\s+\{[^}]*\}\s+from\s+"\.\/utils";?\s*',
'import pnmui from "panel-material-ui"; const install_theme_hooks = pnmui.install_theme_hooks; const apply_global_css = () => {};\n',
esm
)
MaterialUIComponent._render_esm_base = patched
patch_material_ui_inline_esm()
After applying the patch, all MaterialUIComponent subclasses with inline _esm_base work in server mode without any code changes. This workaround is tracked in panel-material-ui#563.
Basic Structure
import param
from panel_material_ui import MaterialUIComponent
class StyledButton(MaterialUIComponent):
"""A custom button with Material UI styling."""
label = param.String(default="Click me", doc="Button label")
variant = param.Selector(default="contained", objects=["text", "outlined", "contained"])
_esm_base = """
import Button from "@mui/material/Button";
export function render({ model }) {
const [label] = model.useState("label");
const [variant] = model.useState("variant");
return (
<Button
id="styled-btn"
variant={variant}
onClick={() => model.send_event("click", {})}
>
{label}
</Button>
);
}
"""
def _handle_click(self, event):
print("Button clicked!")
MUI Component Imports
MaterialUIComponent has @mui/material/ pre-configured:
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Card from "@mui/material/Card";
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
Theming
MaterialUIComponent automatically inherits the theme from Page:
import param
import panel as pn
from panel_material_ui import Page, MaterialUIComponent
class ThemedCard(MaterialUIComponent):
title = param.String(default="Card Title")
_esm_base = """
import Card from "@mui/material/Card";
import CardContent from "@mui/material/CardContent";
import Typography from "@mui/material/Typography";
import { useTheme } from "@mui/material/styles";
export function render({ model }) {
const [title] = model.useState("title");
const theme = useTheme();
return (
<Card>
<CardContent>
<Typography variant="h5" color="primary">
{title}
</Typography>
</CardContent>
</Card>
);
}
"""
page = Page(main=[ThemedCard(title="Hello World")], title="My App", dark_theme=True)
page.servable()
Using MUI Icons
Use explicit icon imports (not trailing slash) with ?external=react to share the React instance with panel-material-ui:
class IconComponent(MaterialUIComponent):
_importmap = {
"imports": {
"@mui/icons-material/Favorite": "https://esm.sh/@mui/icons-material@5.16.7/Favorite?external=react",
}
}
_esm_base = """
import IconButton from "@mui/material/IconButton";
import FavoriteIcon from "@mui/icons-material/Favorite";
export function render({ model }) {
// Use inline style for icon dimensions - MUI CSS classes may not load properly
return (
<IconButton id="icon-btn" color="primary" style={{ padding: '8px' }}>
<FavoriteIcon style={{ width: '24px', height: '24px', fill: 'currentColor' }} />
</IconButton>
);
}
"""
Important:
- Do NOT use the trailing slash pattern (
@mui/icons-material/) with query parameters - importmaps require values ending in / when keys end in /, which breaks ?external=react. Use explicit imports for each icon instead.
- Use inline
style props for icon dimensions (width, height) because MUI CSS classes may not load properly with custom MaterialUIComponent.
8. Best Practices
DOs
-
Use external .js/.jsx files for development
_esm = Path(__file__).parent / "component.js"
Then run panel serve app.py --dev for hot reload.
-
Use _importmap with ?external= for shared dependencies
_importmap = {
"imports": {
"my-lib": "https://esm.sh/my-lib?external=react,react-dom",
}
}
-
Clean up resources in the remove lifecycle
export function render({ model, el }) {
const interval = setInterval(updateData, 1000);
model.on('remove', () => clearInterval(interval));
}
-
Use panel compile for production bundling
panel compile my_component.py
-
Define proper param types with metadata
value = param.Integer(default=0, bounds=(0, 100), doc="Slider value")
-
Use descriptive element IDs for testing
button.id = "submit-button";
input.id = "username-input";
-
Handle initial state in render
export function render({ model, el }) {
el.textContent = model.value;
model.on('value', () => {
el.textContent = model.value;
});
}
DON'Ts
-
Don't mix API patterns between component types
const value = model.get("value");
const [value] = model.useState("value");
-
Don't forget model.save_changes() in AnyWidgetComponent
model.set("value", newValue);
model.set("value", newValue);
model.save_changes();
-
Don't import React manually in ReactComponent
import React from 'react';
const [state, setState] = React.useState(0);
-
Don't use deprecated ReactiveHTML
from panel.reactive import ReactiveHTML
from panel.custom import JSComponent
-
Don't inline large ESM in production
_esm = """... 500 lines of code ..."""
_esm = Path(__file__).parent / "component.js"
-
Don't forget to handle resize events for responsive components
model.on('resize', ({ width, height }) => {
chart.resize(width, height);
});
-
Don't use _ prefix for parameters needed in JavaScript
_computed = param.String()
computed = param.String()
-
Prefer ESM imports over __javascript__ - ESM imports are synchronous, __javascript__ is not
__javascript__ = ["https://unpkg.com/@google/model-viewer@3.4.0/dist/model-viewer.min.js"]
_esm = """
import "https://esm.sh/@google/model-viewer@3.4.0";
export function render({ model, el }) {
// model-viewer custom element is guaranteed to be registered
const viewer = document.createElement('model-viewer');
...
}
"""
Why? __javascript__ loads scripts asynchronously. This causes race conditions where render() executes before the library finishes loading - especially problematic for web components that must register custom elements before you can create them.
9. Testing with Playwright
Custom components should be tested using Playwright for UI testing. Panel provides test utilities that make this straightforward.
Setup
pip install panel pytest pytest-playwright pytest-xdist
playwright install chromium
Test Utilities from Panel
Panel provides test utilities in panel.tests.util for serving components during Playwright tests:
serve_component(page, app) - Serves a component and navigates the browser to it. Returns (msgs, port) tuple with console messages and server port.
wait_until(fn, page, timeout=5000) - Polls a function until it returns True or times out. Essential for JS → Python sync tests.
Complete Example Test File
This complete, working example demonstrates all key testing patterns:
"""Tests for Panel custom components with Playwright."""
import pytest
pytest.importorskip("playwright")
import panel as pn
import param
from panel.custom import JSComponent
from panel.tests.util import serve_component, wait_until
from playwright.sync_api import expect
pytestmark = pytest.mark.ui
DEFAULT_TIMEOUT = 2_000
LOAD_TIMEOUT = 5_000
NETWORK_TIMEOUT = 5_000
class CounterButton(JSComponent):
"""A simple counter button component for testing."""
value = param.Integer(default=0, doc="Current count")
_esm = """
export function render({ model, el }) {
const button = document.createElement('button');
button.id = 'counter-btn';
function update() {
button.textContent = `Count: ${model.value}`;
}
button.onclick = () => {
model.value += 1;
};
model.on('value', update);
update();
el.appendChild(button);
}
"""
class DisplayComponent(JSComponent):
"""A simple display component for testing Python → JS sync."""
text = param.String(default="", doc="Text to display")
_esm = """
export function render({ model, el }) {
const display = document.createElement('div');
display.id = 'display';
function update() {
display.textContent = model.text;
}
model.on('text', update);
update();
el.appendChild(display);
}
"""
class TextInput(JSComponent):
"""A simple text input for testing JS → Python sync."""
value = param.String(default="", doc="Input value")
_esm = """
export function render({ model, el }) {
const input = document.createElement('input');
input.id = 'text-input';
input.type = 'text';
input.value = model.value;
input.oninput = (e) => {
model.value = e.target.value;
};
model.on('value', () => {
if (input.value !== model.value) {
input.value = model.value;
}
});
el.appendChild(input);
}
"""
@pytest.fixture(autouse=True)
def server_cleanup():
"""Clean up Panel state after each test."""
try:
yield
finally:
pn.state.reset()
def test_no_console_errors(page):
"""Smoke test: Verify no JavaScript errors during component load."""
component = CounterButton(value=0)
msgs, _port = serve_component(page, component)
info_messages = [m for m in msgs if m.type == "info"]
assert any("document idle" in m.text.lower() for m in info_messages), \
f"Expected Bokeh 'document idle' message not found. Got: {[m.text for m in info_messages]}"
error_messages = [m for m in msgs if m.type == "error"]
real_errors = [m for m in error_messages if "favicon" not in m.text.lower()]
assert len(real_errors) == 0, f"JavaScript errors found: {[m.text for m in real_errors]}"
def test_component_renders(page):
"""Test that component renders correctly."""
counter = CounterButton(value=42)
serve_component(page, counter)
expect(page.locator("#counter-btn")).to_have_text("Count: 42", timeout=LOAD_TIMEOUT)
def test_component_interaction(page):
"""Test user interaction updates state."""
counter = CounterButton(value=0)
serve_component(page, counter)
page.locator("#counter-btn").click()
wait_until(lambda: counter.value == 1, page)
expect(page.locator("#counter-btn")).to_have_text("Count: 1", timeout=DEFAULT_TIMEOUT)
def test_python_to_js_sync(page):
"""Test Python → JS state synchronization."""
display = DisplayComponent(text="Initial")
serve_component(page, display)
expect(page.locator("#display")).to_have_text("Initial", timeout=LOAD_TIMEOUT)
display.text = "Updated"
expect(page.locator("#display")).to_have_text("Updated", timeout=DEFAULT_TIMEOUT)
def test_js_to_python_sync(page):
"""Test JS → Python state synchronization."""
text_input = TextInput(value="")
serve_component(page, text_input)
page.locator("#text-input").fill("Hello World")
wait_until(lambda: text_input.value == "Hello World", page)
assert text_input.value == "Hello World"
def test_bidirectional_sync(page):
"""Test bidirectional state synchronization."""
counter = CounterButton(value=5)
serve_component(page, counter)
expect(page.locator("#counter-btn")).to_have_text("Count: 5", timeout=LOAD_TIMEOUT)
page.locator("#counter-btn").click()
wait_until(lambda: counter.value == 6, page)
counter.value = 100
expect(page.locator("#counter-btn")).to_have_text("Count: 100", timeout=DEFAULT_TIMEOUT)
page.locator("#counter-btn").click()
wait_until(lambda: counter.value == 101, page)
Key Testing Patterns
| Pattern | Use Case |
|---|
msgs, port = serve_component(page, component) | Serve component, get console messages |
expect(locator).to_have_text("text", timeout=X) | Assert element text |
wait_until(lambda: condition, page) | Wait for Python state change (JS → Python) |
page.locator("#id").click() / .fill("text") | Simulate user interaction |
Testing Components with External Resources
Components loading external resources (CDN libraries, 3D models) need longer timeouts and explicit dimensions:
def test_component_with_external_resources(page):
viewer = ModelViewer(
src="https://example.com/model.glb",
style={"min-height": "400px", "min-width": "400px"},
)
serve_component(page, viewer)
expect(page.locator("#model-viewer")).to_be_visible(timeout=NETWORK_TIMEOUT)
Running Tests
pytest path/to/test_file.py -n auto
pytest path/to/test_file.py -x
- Use
-n auto (pytest-xdist) to run tests in parallel for faster feedback
- Run headless unless users ask for headed mode
- Use
-x (exit on first failure) for sequential runs
- Use
--headed --slowmo 500 for debugging if the users asks for this
10. Complete Examples
JSComponent Counter
import param
from panel.custom import JSComponent
class CounterButton(JSComponent):
value = param.Integer(default=0, doc="Current count")
_esm = """
export function render({ model, el }) {
const button = document.createElement('button');
button.id = 'counter-btn';
function update() {
button.textContent = `Count: ${model.value}`;
}
button.onclick = () => { model.value += 1; };
model.on('value', update);
update();
el.appendChild(button);
}
"""
ReactComponent Counter
import param
from panel.custom import ReactComponent
class CounterButton(ReactComponent):
value = param.Integer(default=0, doc="Current count")
_esm = """
export function render({ model }) {
const [value, setValue] = model.useState("value");
return (
<button id="counter-btn" onClick={() => setValue(value + 1)}>
Count: {value}
</button>
);
}
"""
AnyWidgetComponent Counter
import param
from panel.custom import AnyWidgetComponent
class CounterButton(AnyWidgetComponent):
value = param.Integer(default=0, doc="Current count")
_esm = """
export default {
render({ model, el }) {
const button = document.createElement('button');
button.id = 'counter-btn';
function update() {
button.textContent = `Count: ${model.get("value")}`;
}
button.onclick = () => {
model.set("value", model.get("value") + 1);
model.save_changes();
};
model.on("change:value", update);
update();
el.appendChild(button);
}
}
"""
Test for All Versions
import pytest
pytest.importorskip("playwright")
from playwright.sync_api import expect
from panel.tests.util import serve_component, wait_until
pytestmark = pytest.mark.ui
@pytest.mark.parametrize("CounterClass", [
pytest.param("counter_js.CounterButton", id="js"),
pytest.param("counter_react.CounterButton", id="react"),
pytest.param("counter_anywidget.CounterButton", id="anywidget"),
])
def test_counter(page, CounterClass):
import importlib
module_name, class_name = CounterClass.rsplit(".", 1)
module = importlib.import_module(module_name)
Counter = getattr(module, class_name)
counter = Counter(value=0)
serve_component(page, counter)
button = page.locator("#counter-btn")
expect(button).to_have_text("Count: 0")
button.click()
wait_until(lambda: counter.value == 1, page)
expect(button).to_have_text("Count: 1")
11. Troubleshooting
Component Not Rendering
- Check browser console for JavaScript errors
- Verify
_esm path is correct (use Path(__file__).parent)
- Ensure export function is named
render (or default for AnyWidget)
- Check import map URLs are accessible
State Not Syncing
- JSComponent: Ensure
model.on('param', callback) is registered
- ReactComponent: Use
model.useState() not React.useState() for synced state
- AnyWidgetComponent: Call
model.save_changes() after model.set()
Import Errors
- Verify CDN URLs in
_importmap are correct
- For React libraries, add
?external=react,react-dom
- Check for CORS issues with custom CDN URLs
Styles Not Applied
- Check
_stylesheets paths are correct
- For inline CSS, ensure it's a list of strings
- CSS is scoped to shadow DOM - use
:host for root styling
12. Common Patterns
Debouncing User Input
let timeout;
input.oninput = (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
model.value = e.target.value;
}, 300);
};
Loading External Data
export async function render({ model, el }) {
el.textContent = "Loading...";
const data = await fetch(model.data_url).then(r => r.json());
el.textContent = "";
renderChart(el, data);
}
Responsive Component Sizing
Custom components should fill their parent container (el) and respond to size changes. Panel controls el's dimensions via sizing_mode and related parameters.
Key Concepts:
- Panel sizes the container (
el) - Set sizing_mode in Python to control how el fills available space
model.width/model.height - Access the component's dimensions in JavaScript
- Use lifecycle hooks -
after_render for initial setup, resize for size changes
Pattern 1: Fixed Dimensions
For components with known size requirements:
class Canvas(JSComponent):
_esm = "canvas.js"
canvas = Canvas(width=400, height=400)
export function render({ model, el }) {
const canvas = document.createElement('canvas');
canvas.width = model.width;
canvas.height = model.height;
el.appendChild(canvas);
}
Pattern 2: Responsive Width, Fixed Height
Most common pattern - component stretches horizontally:
class Chart(JSComponent):
_esm = "chart.js"
chart = Chart(height=400, sizing_mode="stretch_width")
export function render({ model, el }) {
const container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
el.appendChild(container);
const chart = createChart(container, el.clientWidth, model.height);
model.on('resize', () => {
chart.resize(el.clientWidth, model.height);
});
}
Pattern 3: Fully Responsive (Stretch Both)
For components that fill all available space:
class Map(JSComponent):
_esm = "map.js"
map_component = Map(min_height=400, sizing_mode="stretch_both")
export function render({ model, el }) {
const container = document.createElement('div');
container.style.width = '100%';
container.style.height = '100%';
el.appendChild(container);
const map = createMap(container);
model.on('after_render', () => {
map.invalidateSize();
});
model.on('resize', () => {
map.invalidateSize();
});
}
Pattern 4: Libraries with Built-in Responsive Support
Some libraries handle resizing internally:
const chart = new Chart(canvas, {
...model.object,
options: {
responsive: true,
maintainAspectRatio: false,
}
});
Common sizing_mode Configurations:
| Use Case | Python Configuration |
|---|
| Fixed size | width=400, height=300 |
| Fill width, fixed height | height=400, sizing_mode="stretch_width" |
| Fill height, fixed width | width=400, sizing_mode="stretch_height" |
| Fill container | sizing_mode="stretch_both" (set min_height for safety) |
| Fill with constraints | sizing_mode="stretch_both", min_width=200, max_width=800 |
Tip: When using sizing_mode="stretch_both", always set min_height to prevent the component from collapsing to zero height when the parent has no explicit height.
Two-Way Binding with Validation
class ValidatedInput(JSComponent):
value = param.String(default="")
error = param.String(default="")
@param.depends('value', watch=True)
def _validate(self):
if len(self.value) < 3:
self.error = "Must be at least 3 characters"
else:
self.error = ""
export function render({ model, el }) {
const input = document.createElement('input');
const error = document.createElement('span');
error.className = 'error';
input.oninput = (e) => { model.value = e.target.value; };
model.on('value', () => { input.value = model.value; });
model.on('error', () => { error.textContent = model.error; });
input.value = model.value;
error.textContent = model.error;
el.append(input, error);
}
13. Learning More
Finding Documentation
Use the search tool to find relevant documentation:
search("JSComponent lifecycle hooks", project="panel")
search("ReactComponent useState", project="panel")
search("AnyWidget model API", project="panel")
Official Documentation
External Resources
For integrating specific JavaScript libraries:
- Search the web for "[library name] ESM import" to find CDN URLs
- Check esm.sh for React-compatible bundles:
https://esm.sh/[package]
- Check jsDelivr for UMD bundles:
https://cdn.jsdelivr.net/npm/[package]