Using Material UI¶
Skill version 1.0.2
Build and theme panel-material-ui (pmui) apps: layout, Page structure, and component gotchas, plus theming — palette, typography, icons, brand assets, and chart theming. For converting an existing plain-Panel app, see Migrating to Material UI.
Contents¶
Building:
- Lookup — where to fetch pmui docs as markdown
- Key Differences from Panel
- Page — incl. header / AppBar color
- Layouts
- Component Gotchas
Theming:
- Styling Layers
- Palette
- Typography
- Shape
- Component Overrides
- Icons
- Brand Assets
- Chart Theming
- Complete Themed Example
- Deep Dives — full theming guides as markdown
Lookup¶
Fetch pmui docs as markdown, not HTML: prefix any pmui doc path with /markdown/ and change .html/.ipynb → .md (also for links found inside pages). If the result is empty, the page moved — use the index.
Base: https://panel-material-ui.holoviz.org/markdown/ — append the endpoints below.
- Doc map / index:
https://panel-material-ui.holoviz.org/llms.txt(site root, not undermarkdown/) - Component:
reference/{section}/{Component}.mdSections:widgets,menus,layouts,panes,wrappers,page,chat,indicators,global - Section index (lists every component):
reference/{section}/index.md - How-to guides:
how_to/{guide}.md(index:how_to/index.md) - Search: web-search the topic, then convert the
.htmlhit to its/markdown/….mdURL.
Key Differences from Panel¶
- Import
panel_material_ui as pmui. Don't add"panel_material_ui"or"bokeh"topn.extension(). Don't setdesign='material'. - Prefer
pmui.Column/Row/Grid/Containeroverpn.*equivalents — they supportspacing, breakpoints, and theme inheritance. Fall back topn.*only when no pmui equivalent exists (e.g.pn.pane.HoloViews). If an existing app already usespn.*layouts, keep them rather than migrating. - Use
pmui.Pageinstead ofpn.template.FastListTemplate. - Use new param names (
label,color,variant) not legacy aliases (name,button_type,button_style). - Quick preview with
python app.py+.show()works for pmui (unlike standard Panel). - Widget from a Param: pmui has no auto
Parampane, so pick the widget class yourself withpmui.<Widget>.from_param(obj.param.x)(orpn.Param(obj, widgets={"x": {"type": pmui.Select}})to override auto-generated types). The Param-type → widget baseline matches Panel's own defaults (Param pane reference); the pmui-specific things to know:param.Boolean→pmui.Switch(Panel defaults toCheckbox), andparam.Array/param.DataFramehave no pmui widget — keeppn.widgets.ArrayInput/pn.widgets.Tabulator.
import panel as pn
import panel_material_ui as pmui
import param
pn.extension(throttled=True)
class MyApp(pn.viewable.Viewer):
value = param.Integer(default=5, bounds=(0, 10))
def __init__(self, **params):
super().__init__(**params)
with pn.config.set(sizing_mode="stretch_width"):
self._slider = pmui.IntSlider.from_param(self.param.value, margin=(10, 20))
self._output = pmui.Column(self._display)
@param.depends("value")
def _display(self):
return f"Value: {self.value}"
def __panel__(self):
if pn.state.served:
return pmui.Page(
title="My App",
sidebar=[self._slider],
main=[self._output],
)
return pmui.Row(pmui.Column(self._slider, max_width=300), self._output)
Page¶
- Title goes in
Page.title— don't repeat inmain. Page.sidebar,Page.main,Page.headerrequire lists — not bare components orlist(layout).- Don't add
ThemeToggle— built in. headeris only 100px — buttons, indicators, nav links only.- Add
margin=10to outermainlayouts so they stand out from sidebar. - Only use a sidebar when there are multiple control widgets. For a single selector, use inline
RadioButtonGrouporSelectin the main area withpmui.Container— avoids wasting viewport on a near-empty sidebar. - Sidebar order: logo → description → widgets → docs.
- Page not rendering (no header/sidebar): if
__panel__returns thePageonly underif pn.state.served:, that guard can evaluateFalsewhen__panel__runs, silently giving you the bare fallback layout with no top bar. For an app that is always served, build thePageonce in__init__and return it unconditionally from__panel__.
# ✅ Lists
pmui.Page(sidebar=[widget1, widget2], main=[content])
# ❌ Bare component — fails silently
pmui.Page(sidebar=widget1, main=content)
Page header / AppBar color¶
The Page header is an MUI AppBar color="primary". If theme_config sets no
palette.primary.main, it falls back to a hardcoded blue (#0072b5) with a white title — which
clashes with a dark app. Either set palette.primary.main, or override the header directly via the
.header class (use this when you want the header a different color from the brand primary, e.g. a
dark panel tone):
pmui.Page(
sx={"& .header": {
"backgroundColor": "#14141b", # match the app's panel color
"backgroundImage": "none",
"boxShadow": "none",
}},
...
)
Layouts¶
Notes:
- pmui.Container(width_option="lg") clamps content max width — prevents wide-screen stretching.
- pmui.Grid with size= breakpoints for responsive multi-column layouts. Nest items inside Grid(container=True). KPI cards: size={"xs": 6, "md": 3}. Side-by-side charts: size={"xs": 12, "md": 6}.
- size="grow" for auto-sized items.
- Set sizing_mode="stretch_width" on children inside Grid items so they fill the cell.
# 2-column responsive layout
pmui.Grid(
pmui.Grid(left_card, size={"xs": 12, "md": 6}),
pmui.Grid(right_card, size={"xs": 12, "md": 6}),
container=True, spacing=2,
)
# Width-clamped page content
pmui.Container(pmui.Column(...), width_option="lg")
Centering in Page¶
The pmui.Page main area does not support CSS flexbox centering. Use margins instead:
- Horizontal: wrap content in
pmui.Container(width_option="sm")for narrow centered cards - Vertical: use integer tuple margins like
margin=(100, 0, 0, 0)for top spacing.margin="auto"andpn.Spacer()don't work for vertical centering in Page
pmui.Page(
main=[
pmui.Container(
pmui.Column(
pmui.Paper(content, sx={"p": 5}),
sizing_mode="stretch_width",
margin=(100, 0, 0, 0), # Push down from top
),
width_option="sm", # Center horizontally with narrow width
)
],
)
Component Gotchas¶
Spacing and Alignment¶
pn.layout.HSpacer()pushes items left/right in a Rowpn.layout.VSpacer()pushes items top/bottom in a Column- Always set
sizing_modeon components unless intentionally fixed-size; fixed default widths are why widgets "aren't responsive". - Use
marginto prevent widgets touching container edges (default margins often suffice). Default margins are inconsistent (most10;Typography(5,10);Chip/Avatar/containers0), so loose text/buttons won't align with margin-0Grid/Paperblocks — pick one baseline. - Align a whole body: make each section an item of ONE
Grid(container=True, spacing=2)with childrenmargin=0— shared padding aligns them andspacingmakes the gaps (also stops cards touching):
pmui.Grid(pmui.Grid(title, size={"xs": 12}),
pmui.Grid(card, size={"xs": 12, "sm": 6, "md": 3}), ...,
container=True, spacing=2, sizing_mode="stretch_width")
- Slider thumb hits the edge → add horizontal margin, e.g.
margin=(10, 20). - Mixed-height rows:
align="center"; set gaps withsx={"gap": "12px"}, not per-item margins.
Layouts¶
Grid: usespacing=2+.ncolsdoesn't exist.Column/Row: usesize, notxs/sm/md. Set spacing viasx, notspacingparam.- List layouts take positional args:
pmui.Row(a, b), notpmui.Row([a, b]).
Components¶
Card: preferPaper. Setcollapsible=Falseunless needed.Tabulator: use"materialize"theme, not"material".Box→Column,TextField→TextInput(neither exists).Chip: uselabel=, notobject=(deprecated). Chips default tomargin=10, which blows out tight stacked layouts — setmargin=0when packing several together. Translucent-pill look:sx={"color": c, "backgroundColor": f"{c}22"}.Accordionheader text: the title renders as a Typography inside the summary, so a rule on.MuiAccordionSummary-rootwon't reach it. Target the content to restyle the label:sx={"& .MuiAccordionSummary-content *": {"fontSize": "13px", "color": "#6d5cff"}}.CheckButtonGroup/RadioButtonGroupstyling: in sidebars useorientation="vertical",color="primary",variant="outlined".- Button groups (
RadioButtonGroup,CheckButtonGroup):.from_param()may not write the widget value back to the bound param — clicking changes the buttons but the param never updates, so@param.depends/watchers never fire. Create the widget directly and wire it explicitly:
# ❌ clicks don't propagate — dependent views never update
self._toggle = pmui.RadioButtonGroup.from_param(self.param.chart_type)
# ✅ direct widget + explicit watcher
self._toggle = pmui.RadioButtonGroup(options=["bars", "lines"], value="bars")
self._toggle.param.watch(lambda e: setattr(self, "chart_type", e.new), "value")
Rating (and other icon widgets): stretch to fill their container under the default sizing_mode="stretch_width", rendering enormous. Pin them: pmui.Rating(end=5, size="small", width=170, sizing_mode="fixed").
- Dialog: for secondary detail that would crowd the page (or overflow the narrow Page contextbar), use a dialog and toggle .open. close_on_click=True dismisses on backdrop click:
self._details = pmui.Dialog(content, title="Details",
width_option="md", open=False, close_on_click=True)
# open from a button: self._details.open = True
Styling Layers¶
| Layer | Scope | Use |
|---|---|---|
theme_config |
Global (flows to children) | App-wide palette, typography, shape, component defaults |
sx |
Local instance | One-off styling, nested selectors, dark/light mode overrides |
styles |
Local instance | Outer container box (spacing, borders, backgrounds) |
stylesheets |
Local instance | Classic Panel internals via CSS selectors |
sx Examples¶
# Basic styling
pmui.Button(label="Custom", sx={"color": "white", "backgroundColor": "black"})
# Hover states
pmui.Button(sx={"&:hover": {"backgroundColor": "gray"}})
# Dark/light mode overrides
pmui.Button(sx={"&.mui-dark:hover": {"backgroundColor": "orange"}})
# Target nested MUI parts
pmui.FloatSlider(sx={"& .MuiSlider-thumb": {"borderRadius": 0}})
Palette¶
Each color category has four tokens: main, light, dark, contrastText. Only main is required; others auto-compute.
theme_config = {
"palette": {
"primary": {"main": "#6200ea"},
"secondary": {"main": "#03dac6"},
"error": {"main": "#b00020"},
}
}
Token Reference¶
| Token | Use |
|---|---|
primary.main |
Primary brand color |
primary.contrastText |
Text on primary background |
text.primary |
Main text color |
text.secondary |
Muted/secondary text |
background.default |
Page background |
background.paper |
Card/paper background |
Accessibility¶
theme_config = {
"palette": {
"contrastThreshold": 4.5, # WCAG 2.1 compliance (default: 3)
"tonalOffset": 0.2, # Light/dark variant shift
"primary": {"main": "#3f50b5"},
}
}
Typography¶
theme_config = {
"typography": {
"fontFamily": "Montserrat, Helvetica Neue, Arial, sans-serif",
"fontSize": 14, # Base size in px
"h1": {"fontSize": "2.5rem", "fontWeight": 700},
"body1": {"fontWeight": 500},
"button": {"fontStyle": "italic"},
}
}
Variants: h1-h6, subtitle1, subtitle2, body1, body2, button, caption, overline.
Responsive Typography¶
pmui.Typography("Responsive", sx={
"fontSize": "1.2rem",
"@media (min-width: 600px)": {"fontSize": "1.5rem"},
"@media (min-width: 900px)": {"fontSize": "2.4rem"},
})
Shape¶
Component Overrides¶
Override defaults for all instances of a component type:
theme_config = {
"components": {
"MuiButton": {
"defaultProps": {"disableRipple": True},
"styleOverrides": {"root": {"fontSize": "1rem"}},
},
"MuiPaper": {
"styleOverrides": {"root": {"padding": "16px"}},
},
}
}
Variant-Based Overrides¶
theme_config = {
"components": {
"MuiCard": {
"styleOverrides": {
"root": {
"variants": [{
"props": {"variant": "outlined"},
"style": {"borderWidth": "3px"},
}]
}
}
}
}
}
Icons¶
Use Material Icons from fonts.google.com/icons.
Icon Parameter¶
Buttons and some widgets accept icon directly:
pmui.Button(label="Save", icon="save")
pmui.Button(label="Delete", icon="delete_outlined") # Outlined variant
pmui.ButtonIcon(icon="settings")
Token Syntax in Labels¶
Embed icons in text with :material/icon_name::
pmui.Select(options=["Zoom :material/zoom:", "Pan :material/pan_tool:"])
pmui.Button(label="Warning :material/warning@color=warning:")
Token options: @size=large, @color=warning, @variant=outlined.
HTML/Markdown¶
Brand Assets¶
Logo and Favicon¶
pmui.Page.param.logo.default = "/path/to/logo.png"
pmui.Page.favicon = "/path/to/favicon.ico"
pmui.Page.meta.name = "My App"
Custom CSS¶
pmui.Page.config.raw_css.append("body { font-family: Montserrat; }")
pmui.Page.config.css_files.append(
"https://fonts.googleapis.com/css2?family=Montserrat:wght@400;700&display=swap"
)
Component Defaults¶
pn.pane.Image.stylesheets = ["img {border-radius: 8px}"]
pn.widgets.Tabulator.param.theme.default = "materialize"
pmui.Button.param.disable_elevation.default = True
Chart Theming¶
Plots auto-theme when using pmui.Page or pmui.ThemeToggle.
Categorical Palette¶
primary = "#6200ea"
colors = pmui.theme.generate_palette(primary)
df.hvplot.scatter(x="x", y="y", color="category", cmap=colors)
Continuous Colormap¶
Complete Themed Example¶
import panel as pn
import panel_material_ui as pmui
pn.extension()
THEME = {
"light": {
"palette": {
"primary": {"main": "#4099da"},
"secondary": {"main": "#644c76"},
},
"typography": {
"fontFamily": "Montserrat, sans-serif",
"fontSize": 14,
},
"shape": {"borderRadius": 8},
},
"dark": {
"palette": {
"primary": {"main": "#64b5f6"},
"secondary": {"main": "#9575cd"},
},
"typography": {
"fontFamily": "Montserrat, sans-serif",
"fontSize": 14,
},
"shape": {"borderRadius": 8},
},
}
pmui.Page(
title="Branded App",
theme_config=THEME,
sidebar=[pmui.Button(label="Action", icon="bolt", color="primary")],
main=[pmui.Typography("Welcome", variant="h4")],
).servable()
Deep Dives¶
Full theming guides as markdown. Base: https://panel-material-ui.holoviz.org/markdown/how_to/ — append:
customize_palette.md— palette tokens,contrastThreshold,tonalOffsetcustomize_typography.md— typographytheme_components.md— per-component theming (componentskey)control_dark_mode.md— dark modetheme_plotting_libraries.md— theme-aware plots (Bokeh/hvPlot/HoloViews/Plotly)using_mui_icons.md— Material iconsindex.md— full guide index