Enabling your React widgets inside trame
Bringing an existing React component library into trame is a two-sided job:
- On the JavaScript side — generate a UMD/ESM bundle and expose a function that registers each of your components under a tag name (e.g.
mui-button) that trame's React client can look up at render time. That bundling/registration step is outside the scope of this guide. - On the Python side — describe the same set of components as plain Python classes, so an application author can write
mui.Button(...)instead of hand-rolling a JSON tree. This is what the rest of this guide covers, using trame-mui (a from-scratch binding of MUI to trame) as a concrete, real-world example throughout.
The trame namespace
Every trame widget library — whether it ships with trame itself or is installed separately (trame-vuetify, trame-vtk, trame-mui, ...) — is actually two Python distributions layered on top of each other:
- Your real implementation package, named
trame_<name>(an underscore, since it's a regular, importable package). This is where all of your actual code lives — widget classes, module/asset declarations, and any layout helpers. For trame-mui this istrame_mui. - A thin facade that plugs into the shared
tramenamespace, namedtrame-<name>on PyPI but contributing files undertrame/widgets/,trame/modules/, and optionallytrame/ui/.
The reason this works without every widget library stepping on every other one is that trame, trame.widgets, trame.modules, and trame.ui are all namespace packages: their __init__.py is nothing but
__path__ = __import__("pkgutil").extend_path(__path__, __name__)Because every trame-related distribution ships this exact same one-liner at every level, Python merges all of their trame/, trame/widgets/, trame/modules/, and trame/ui/ directories into one logical namespace at import time. That's why from trame.widgets import html, vuetify3, mui all resolve correctly even though html/vuetify3 and mui come from entirely unrelated pip packages that know nothing about each other.
Concretely, trame-mui's src/ layout looks like this:
src/
├── trame/ # namespace facade — thin, re-exporting only
│ ├── __init__.py # pkgutil.extend_path
│ ├── modules/mui.py # from trame_mui.module import *
│ ├── widgets/mui.py # from trame_mui.widgets.mui import *
│ └── ui/mui.py # from trame_mui.ui.mui import * (optional)
└── trame_mui/ # your actual implementation package
├── __init__.py
├── module/__init__.py # JS/CSS assets this library needs to serve
├── widgets/mui.py # one Python class per component
└── ui/mui.py # optional convenience layoutsThe facade files are intentionally tiny — each one just re-exports from the real package next to it:
# trame/widgets/mui.py
from trame_mui.widgets.mui import * # noqa: F403
def initialize(server):
from trame_mui import module
server.enable_module(module)# trame/modules/mui.py
from trame_mui.module import * # noqa: F403Picking <name> (mui here) is the one naming decision you make: it becomes your PyPI package suffix (trame-mui), your import package (trame_mui), and the module name application authors will type after from trame.widgets import ... / from trame.modules import ....
Declaring your module: serving JS/CSS assets
Before any widget can render, its JS bundle and CSS need to be served to the page. This is what a trame module (a plain object/namespace with a known set of attributes, unrelated to a Python import module) declares — trame_mui/module/__init__.py:
from pathlib import Path
from trame_mui import __version__
# Lookup at runtime the directory to serve
serve_path = str(Path(__file__).with_name("serve").resolve())
# Serving static content
serve = {
f"__trame_mui_{__version__}": serve_path,
}
# Module scripts (mjs)
module_scripts = [
# ...
]
# UMD like scripts
scripts = [
f"__trame_mui_{__version__}/trame-mui.umd.js",
]
# Css files to load
styles = [
f"__trame_mui_{__version__}/roboto/300.css",
f"__trame_mui_{__version__}/roboto/400.css",
]
# Method to use to install components
react_use = ["TrameMui"]
# Optional when custom code execution is needed
def setup(server, **_):
if server.client_type != "react":
msg = f"Server using client_type='{server.client_type}' while we expect 'react'"
raise TypeError(msg)servemaps a URL prefix to a local directory to serve statically. Namespacing it with your package's__version__avoids asset collisions when two apps in the same process pin different versions of your library.module_scripts/scripts/styleslist the actual files (resolved againstserve) to inject into the page.react_useis the React counterpart of Vue'svue_use: it names the global registration function your JS bundle exposes (the piece mentioned in the introduction above) so the client-side runtime calls it to register your components' tags into its own renderer.setup(server, **_)runs once, the first time the module is enabled — a good place to assert preconditions like the expectedclient_type.
A module is activated with server.enable_module(module); trame tracks already-loaded modules, so calling it repeatedly (e.g. once per widget instance, as below) is harmless.
Defining a widget: props and events
Every widget class ultimately inherits from trame_client.widgets.core.AbstractElement. Libraries typically define one small intermediate base class per library that takes care of enabling the module automatically, so individual widgets don't have to repeat that:
from trame_client.widgets.core import AbstractElement
from trame_mui import module
class MuiHtmlElement(AbstractElement):
def __init__(self, _elem_name, children=None, **kwargs):
super().__init__(_elem_name, children, **kwargs)
if self.server:
self.server.enable_module(module)Every concrete component then subclasses MuiHtmlElement instead of AbstractElement directly:
class Accordion(MuiHtmlElement):
"""MUI Accordion - https://mui.com/material-ui/api/accordion/
:param default_expanded: If true, expands the accordion by default. (default: false) (``bool``)
:param disabled: If true, the component is disabled. (default: false) (``bool``)
:param expanded: If true, expands the accordion, otherwise collapses it. (``bool``)
:param on_change: Callback fired when the expand/collapse state is changed. (``func``)
"""
def __init__(self, children=None, **kwargs):
super().__init__("mui-accordion", children, **kwargs)
self.props += [
("default_expanded", "defaultExpanded"),
"disabled",
"expanded",
("on_change", "onChange"),
]A few things to note in that constructor:
- The first argument to
super().__init__(...)is the tag name —"mui-accordion"— the exact string the JS-side registration function (mentioned above) used to register this component. The name just needs to be unique without risking overriding another library widget. Vue.js use thekebab-caseconvention, butcamel-casecan be fine too as long it match whatever the JS side registered. self.propsis where you declare every prop your widget accepts.self.propsstart out populated with the shared DOM/React props/events every element understands (id,style,on_click,on_change, ...);self.props += [...]extends that list with whatever is specific to this component. Technically, you can also add toself.events += [...]for theon_*callback. But at the end with react, both are merged without any distinction.- Each entry is either a bare string or a
(python_name, react_name)tuple. A bare string ("disabled") means the Python kwarg and the React prop share the exact same name. A tuple is for everything else — most commonly translating Python'ssnake_caseconvention into React'scamelCase(("default_expanded", "defaultExpanded")).
Events are just props
Unlike trame's Vue bindings, where properties (props) and events (events) are two separate lists rendered with different HTML syntax (prop="..." vs @event="..."), React has no such distinction at the wire level — an event handler is simply a prop whose value happens to be a function. trame's React implementation mirrors that: there is no separate self.events list to populate — on_change, on_click, and any other callback prop are declared in self.props exactly like any other prop, using the same ("python_name", "reactPropName") tuple form:
self.props += [
("on_change", "onChange"), # a value, when set, is expected to be a
# react.Callback(...) — see the Getting
# Started guide for react.Bind / react.Callback
]The application author is the one who eventually passes a react.Callback(...) (or react.Bind(...) for a two-way-bound value) as the value for that prop — the widget definition only needs to declare that the prop exists and what its React name is.
Docstrings
The :param name: description (default: ...) (\`type``)` convention shown above isn't required by trame, but keeping it consistent across your library's classes means any docstring-based doc generator (Sphinx autodoc, IDE tooltips, ...) renders a useful per-prop reference automatically, the same way it does for the rest of the codebase.
Widgets that need eager children (literal_children)
A small number of components (MUI's Select, Tabs, Stepper, ...) don't render their children in the usual React way — they inspect child.props.* directly (via React.Children) before rendering, to figure out things like which item is selected, then clone that child with extra props injected. trame's default children handling wraps each child lazily, which defeats that inspection since the props such a component reads would live on the wrapper instead of on the real element. Opt out of the wrapper for those specific widgets by setting self.literal_children = True after calling super().__init__(...) or directly in the constructor:
class Select(MuiHtmlElement):
def __init__(self, children=None, **kwargs):
super().__init__("mui-select", children, literal_children= True, **kwargs)
self.props += [...]This is the exception, not the rule — most widgets never need it.
Putting it together
With MuiHtmlElement and one class per component defined in trame_mui/widgets/mui.py, the facade at trame/widgets/mui.py re-exports all of them with from trame_mui.widgets.mui import *. From an application's point of view, none of the namespace plumbing is visible — it's just:
from trame.widgets import mui, react
mui.Accordion(
expanded=react.Bind("panel_open", panel_open=False),
on_change=react.Callback("panel_open = $event.target.checked"),
)