skills/panel-custom-components/SKILL.md
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.
npx skillsauth add marcskovmadsen/holoviz-mcp panel-custom-componentsInstall this skill globally with one command. Works with Claude Code, Cursor, and Windsurf.
3 of 9 scanners reported clean
Some scanners were skipped, did not run, or reported a non-clean status. Review each row below.
This skill covers building custom Panel components that bridge Python and JavaScript. Use it when you need to:
panel-material-ui appsPrerequisites: Solid JavaScript and React knowledge assumed.
| 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 |
┌─────────────────────────────────────────────────────────────────┐
│ 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 │
└─────────────────────────────────────────────────────────────────┘
Important: Build custom components in two phases. By experience, getting JS imports and responsive sizing to work can take significant debugging effort.
Before building the full component, create a minimal example using your actual target library that validates:
model.on() / model.useState()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": {
# Replace with your actual library
"my-lib": "https://esm.sh/[email protected]",
}
}
_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);
}
"""
# Test with explicit dimensions first, then responsive
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:
poc.value = "New" updates the displayOnce the POC validates all three concerns, build out the full component:
_stylesheetsAll custom components inherit from a base class and use param for state:
import param
from panel.custom import JSComponent # or ReactComponent, AnyWidgetComponent
from pathlib import Path
class MyComponent(JSComponent):
"""A custom component with Python-JS state synchronization."""
# Define parameters that sync with JavaScript
value = param.Integer(default=0, bounds=(0, 100), doc="Current value")
label = param.String(default="Click me", doc="Button label")
# ESM code (inline string or external file)
_esm = Path(__file__).parent / "my_component.js"
# Optional: CDN imports
_importmap = {
"imports": {
"lodash": "https://esm.sh/[email protected]",
}
}
# Optional: CSS styles
_stylesheets = [
Path(__file__).parent / "my_component.css",
# Or inline CSS string
]
All component types export a render function:
// JSComponent / ReactComponent / MaterialUIComponent
export function render({ model, el }) {
// model: access to Python parameters
// el: DOM element to render into
}
// AnyWidgetComponent uses default export
export default {
render({ model, el }) {
// Same signature
}
}
_importmap = {
"imports": {
# Basic import
"canvas-confetti": "https://esm.sh/[email protected]",
# Namespace import (trailing slash)
"@mui/material/": "https://esm.sh/@mui/[email protected]/",
# Shared dependencies with ?external=
"my-react-lib": "https://esm.sh/my-react-lib?external=react,react-dom",
}
}
Nest Panel components inside custom components:
from panel.custom import JSComponent, Child, Children
class Container(JSComponent):
header = Child() # Single child
items = Children() # Multiple 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);
}
}
"""
JavaScript → Python:
// In JavaScript
button.onclick = () => {
model.send_event('button_click', { timestamp: Date.now() });
};
# In Python
class MyComponent(JSComponent):
def _handle_button_click(self, event):
print(f"Button clicked at {event.data['timestamp']}")
Python → JavaScript:
# In Python
class MyComponent(JSComponent):
def trigger_animation(self):
self.send_msg({'action': 'animate', 'duration': 500})
// In JavaScript
model.on('msg:custom', (event) => {
if (event.data.action === 'animate') {
runAnimation(event.data.duration);
}
});
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.
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);
}
"""
Reading and Writing Parameters:
// Direct property access
const currentValue = model.value;
const label = model.label;
// Direct assignment syncs to Python
model.value = 42;
model.label = "New Label";
Subscribing to Changes:
export function render({ model, el }) {
const display = document.createElement('div');
// Subscribe to changes
model.on('value', () => {
display.textContent = model.value;
});
// Initialize with current value
display.textContent = model.value;
el.appendChild(display);
}
// render - Initial render (called once)
export function render({ model, el }) {
// Setup DOM, event listeners
}
// after_render - Post-render (useful for measurements)
export function after_render({ model, el }) {
const { width, height } = el.getBoundingClientRect();
initChart(el, width, height);
}
// resize - Size changes
model.on('resize', ({ width, height }) => {
canvas.width = width;
canvas.height = height;
redraw();
});
// remove - Cleanup
model.on('remove', () => {
clearInterval(interval);
document.removeEventListener('keypress', handler);
});
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();
}
"""
When wrapping existing web components, follow these patterns:
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 ensures web component is registered before render executes
import "https://esm.sh/@google/[email protected]";
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%";
// Safe to set attributes immediately - component is guaranteed loaded
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);
// Handle parameter changes - add/remove boolean attributes
model.on('auto_rotate', () => {
if (model.auto_rotate) viewer.setAttribute("auto-rotate", "");
else viewer.removeAttribute("auto-rotate");
});
// Error handling
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 whererender()may execute before the custom element is registered, causingdocument.createElement('model-viewer')to create a genericHTMLElementinstead 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');
// Set attributes BEFORE adding to DOM (available during upgrade)
if (model.auto_rotate) {
viewer.setAttribute('auto-rotate', '');
}
el.appendChild(viewer);
// Wait for custom element to be defined, then set properties
customElements.whenDefined('model-viewer').then(() => {
viewer.autoRotate = model.auto_rotate; // Property access now works
});
// Handle parameter changes
model.on('auto_rotate', () => {
viewer.setAttribute('auto-rotate', model.auto_rotate ? '' : null);
if (typeof viewer.autoRotate !== 'undefined') {
viewer.autoRotate = model.auto_rotate;
}
});
}
ReactComponent enables building custom Panel components with React and JSX. Use it for complex state management and React library integration.
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>
);
}
"""
model.useState() - Synced State:
export function render({ model }) {
// Syncs bidirectionally with Python's self.value
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"); // Syncs to Python
const [local, setLocal] = React.useState(false); // UI-only
return (
<div>
<input
value={synced}
onChange={(e) => setSynced(e.target.value)}
onFocus={() => setLocal(true)}
onBlur={() => setLocal(false)}
/>
{local && <span>Editing...</span>}
</div>
);
}
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} />;
}
class ChartComponent(ReactComponent):
_importmap = {
"imports": {
# React libraries need ?external=react,react-dom
"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>
);
}
"""
AnyWidgetComponent enables building custom Panel components using the AnyWidget specification for cross-platform compatibility (Jupyter + Panel).
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);
}
}
"""
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(); // Required to sync to Python!
Listening for Changes:
model.on("change:value", () => {
console.log("Value changed to:", model.get("value"));
});
// Multiple parameters
["name", "count"].forEach(param => {
model.on(`change:${param}`, updateUI);
});
class ReactCounter(AnyWidgetComponent):
value = param.Integer(default=0)
_importmap = {
"imports": {
# Pin to React 18.2.0 (most stable) and bundle deps together
# Using ?deps= ensures consistent internal references
"@anywidget/react": "https://esm.sh/@anywidget/[email protected][email protected],[email protected]",
"react": "https://esm.sh/[email protected]",
}
}
_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[email protected],[email protected]to bundle dependencies together with specific versions. Without version pinning, esm.sh serves React 19 which has breaking changes.
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.
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)
# Replace ./utils import:
# - install_theme_hooks: from bundle
# - apply_global_css: no-op (only for global CSS styling)
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
# Apply at module load
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.
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!")
MaterialUIComponent has @mui/material/ pre-configured:
// Individual imports
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Card from "@mui/material/Card";
// Layout
import Box from "@mui/material/Box";
import Stack from "@mui/material/Stack";
import Grid from "@mui/material/Grid";
// Feedback
import Alert from "@mui/material/Alert";
import CircularProgress from "@mui/material/CircularProgress";
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>
);
}
"""
# Theme applied automatically via Page
page = Page(main=[ThemedCard(title="Hello World")], title="My App", dark_theme=True)
page.servable()
Use explicit icon imports (not trailing slash) with ?external=react to share the React instance with panel-material-ui:
class IconComponent(MaterialUIComponent):
_importmap = {
"imports": {
# Explicit import for each icon used - ?external=react shares React instance
"@mui/icons-material/Favorite": "https://esm.sh/@mui/[email protected]/Favorite?external=react",
# Add more icons as needed:
# "@mui/icons-material/Delete": "https://esm.sh/@mui/[email protected]/Delete?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
styleprops for icon dimensions (width,height) because MUI CSS classes may not load properly with custom MaterialUIComponent.
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; // Initialize
model.on('value', () => {
el.textContent = model.value;
});
}
Don't mix API patterns between component types
// WRONG: Using AnyWidget API in ReactComponent
const value = model.get("value"); // Don't do this!
// RIGHT: Use hooks in ReactComponent
const [value] = model.useState("value");
Don't forget model.save_changes() in AnyWidgetComponent
// WRONG: Changes won't sync to Python
model.set("value", newValue);
// RIGHT: Always call save_changes after set
model.set("value", newValue);
model.save_changes();
Don't import React manually in ReactComponent
// WRONG: React is already globally available
import React from 'react';
// RIGHT: Use React directly (it's in scope)
const [state, setState] = React.useState(0);
Don't use deprecated ReactiveHTML
# WRONG: Deprecated
from panel.reactive import ReactiveHTML
# RIGHT: Use ESM components
from panel.custom import JSComponent
Don't inline large ESM in production
# WRONG: Large inline strings are slow
_esm = """... 500 lines of code ..."""
# RIGHT: External file + compile
_esm = Path(__file__).parent / "component.js"
# Then: panel compile component.py
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
# WRONG: Private parameters don't sync
_computed = param.String() # Undefined in JS
# RIGHT: Public parameters sync
computed = param.String() # Available as model.computed
Prefer ESM imports over __javascript__ - ESM imports are synchronous, __javascript__ is not
# WRONG: __javascript__ loads asynchronously - render() may run before library is ready
__javascript__ = ["https://unpkg.com/@google/[email protected]/dist/model-viewer.min.js"]
# RIGHT: ESM import guarantees library loads before render() executes
_esm = """
import "https://esm.sh/@google/[email protected]";
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 whererender()executes before the library finishes loading - especially problematic for web components that must register custom elements before you can create them.
Custom components should be tested using Playwright for UI testing. Panel provides test utilities that make this straightforward.
pip install panel pytest pytest-playwright pytest-xdist
playwright install chromium
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.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
# Timeout constants
DEFAULT_TIMEOUT = 2_000 # Standard operations (clicks, text assertions)
LOAD_TIMEOUT = 5_000 # Initial page/component load
NETWORK_TIMEOUT = 5_000 # External resources (CDN libraries)
# =============================================================================
# Test Components
# =============================================================================
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);
}
"""
# =============================================================================
# Fixtures - CRITICAL: Always reset state to properly shuts down all threaded Panel
# servers, allowing pytest to exit cleanly after tests complete.
# =============================================================================
# ALWAYS INCLUDE THIS FIXTURE!
@pytest.fixture(autouse=True)
def server_cleanup():
"""Clean up Panel state after each test."""
try:
yield
finally:
pn.state.reset()
# =============================================================================
# Smoke Test - CRITICAL: ALWAYS Verify Panel/Bokeh infrastructure works before other tests!
# =============================================================================
# ALWAYS INCLUDE THIS TEST!
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)
# Check for Bokeh document idle message (confirms Panel loaded successfully)
# Example: "[bokeh 3.8.2] document idle at 16 ms"
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]}"
# Check for no errors (ignore favicon 404s)
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]}"
# =============================================================================
# Basic Test Patterns
# =============================================================================
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)
# =============================================================================
# State Sync Tests
# =============================================================================
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)
# Update from Python
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)
# Verify initial state
expect(page.locator("#counter-btn")).to_have_text("Count: 5", timeout=LOAD_TIMEOUT)
# JS → Python: Click button
page.locator("#counter-btn").click()
wait_until(lambda: counter.value == 6, page)
# Python → JS: Update from Python
counter.value = 100
expect(page.locator("#counter-btn")).to_have_text("Count: 100", timeout=DEFAULT_TIMEOUT)
# JS → Python: Click again
page.locator("#counter-btn").click()
wait_until(lambda: counter.value == 101, page)
| 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 |
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",
# IMPORTANT: Set explicit dimensions - 100% width/height collapses to 0px
style={"min-height": "400px", "min-width": "400px"},
)
serve_component(page, viewer)
expect(page.locator("#model-viewer")).to_be_visible(timeout=NETWORK_TIMEOUT)
# Run UI tests in parallel for faster feedback (recommended)
pytest path/to/test_file.py -n auto
# Run UI tests sequentially (exit on first failure)
pytest path/to/test_file.py -x
-n auto (pytest-xdist) to run tests in parallel for faster feedback-x (exit on first failure) for sequential runs--headed --slowmo 500 for debugging if the users asks for thisimport 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);
}
"""
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>
);
}
"""
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);
}
}
"""
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")
_esm path is correct (use Path(__file__).parent)render (or default for AnyWidget)model.on('param', callback) is registeredmodel.useState() not React.useState() for synced statemodel.save_changes() after model.set()_importmap are correct?external=react,react-dom_stylesheets paths are correct:host for root stylinglet timeout;
input.oninput = (e) => {
clearTimeout(timeout);
timeout = setTimeout(() => {
model.value = e.target.value;
}, 300);
};
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);
}
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:
el) - Set sizing_mode in Python to control how el fills available spacemodel.width/model.height - Access the component's dimensions in JavaScriptafter_render for initial setup, resize for size changesPattern 1: Fixed Dimensions
For components with known size requirements:
class Canvas(JSComponent):
_esm = "canvas.js"
canvas = Canvas(width=400, height=400) # Fixed size
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);
// Initialize with current dimensions
const chart = createChart(container, el.clientWidth, model.height);
// Handle resize
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);
// Use after_render when library needs DOM dimensions
model.on('after_render', () => {
map.invalidateSize(); // Recalculate size after layout
});
model.on('resize', () => {
map.invalidateSize();
});
}
Pattern 4: Libraries with Built-in Responsive Support
Some libraries handle resizing internally:
// ChartJS with responsive options
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 setmin_heightto prevent the component from collapsing to zero height when the parent has no explicit height.
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; });
// Initialize
input.value = model.value;
error.textContent = model.error;
el.append(input, error);
}
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")
For integrating specific JavaScript libraries:
https://esm.sh/[package]https://cdn.jsdelivr.net/npm/[package]development
Use when building Python classes with validated, typed parameters using the Param library. Triggers include creating configuration classes, building reusable components with state, implementing reactive dependencies between parameters, adding type-safe attributes with bounds/constraints, creating testable parameterized classes, or when users mention param.Parameterized, @param.depends, or param.watch.
tools
Best practices for developing tools, dashboards and interactive data apps with HoloViz Panel. Create reactive, component-based UIs with widgets, layouts, templates, and real-time updates. Use when developing interactive data exploration tools, dashboards, data apps, or any interactive Python web application. Supports file uploads, streaming data, multi-page apps, and integration with HoloViews, hvPlot, Pandas, Polars, DuckDB and the rest of the HoloViz and PyData ecosystems.
tools
Best practices for developing modern looking tools, dashboards and data apps using HoloViz Panel and Panel Material UI components.
data-ai
Best practices for integrating HoloViews and hvPlot visualizations into Panel applications. Use when embedding HoloViews/hvPlot plots in Panel panes, preserving zoom/pan state across data refreshes with DynamicMap, composing DynamicMap overlays without type errors, using HoloViews streams (Selection1D, RangeXY, Tap, BoundsXY, Pipe, Buffer) with Panel, cross-filtering with link_selections, making HoloViews plots responsive in Panel layouts, or wiring Panel widgets to Bokeh plot properties with jslink.