A Jupyter
widget
from a React
component
written in typescript
and styled with tailwindcss
Jupyter
widget from a
React
component written in
typescript
and styled with
tailwindcss
January 26th, 2025

Just want the code? Check out the GitHub repo.
Development Workflow
In the previous post I outlined why we might want to use custom UI components in a jupyter notebook. In this post I'll show how to take a React component from a component library and make it available to a Jupyter notebook. Specifically, I'll be integrating a sequence viewer into a jupyter notebook. This component is a good showcase, since it has some custom styling, takes multiple data types as props, and has some interactivity (clicking and dragging for selection).
When I started this work, I was surprised to find that there wasn't a go-to library or post to guide me through this process. My first port of call was a cookiecutter
template. User error is a possible and perhaps likely culprit, but I failed to get this working. The python and especially javascript toolchains turn over very quickly and the template no longer uses the "best of breed" tools I wanted to use (i.e. uv
over conda
, vite
over webpack
).
What ended up working for me was anywidget. This toolkit has a pretty nifty API, supports React
, and (with a bit of setup) plays nicely with vite
. Anywidget's docs lightly dissuade you from using a bundler. This is probably the saner way of going about it, but I am smitten with tailwindcss
and typescript
. If don't need them the docs are well written and helpful.
The Setup
Since we're building a widget that will accompany Jupyter, I decided to call my widget callisto
. Anyways, let's take a look at the directory structure.
.
├── README.md
├── bundle
│ ├── callisto_frontend.css
│ └── callisto_frontend.js
├── example
│ └── example.ipynb
├── package.json
├── pnpm-lock.yaml
├── requirements.txt
├── src
│ ├── App.tsx
│ ├── callisto_frontend.tsx
│ └── index.css
├── tsconfig.json
├── tsconfig.node.json
Roughly, the project root dir contains the frontend config files, src/
contains the frontend source files, bundle/
will house the bundled version of the frontend source, and example
has an example python notebook showing how to wire up that bundled source as a jupyter widget.
Python/Notebook Config
That widget looks like:
import pathlib
import anywidget
import traitlets
class SequenceViewer(anywidget.AnyWidget):
_esm = pathlib.Path("../bundle/callisto_frontend.js")
_css = pathlib.Path("../bundle/callisto_frontend.css")
sequences = traitlets.List(["ATGC" * 100,]).tag(sync=True)
selection = traitlets.TraitType({ "start": 34, "end": 300, direction": "forward"}).tag(sync=True)
The _esm
and _css
fields are needed by anywidget to find the bundled css and js files produced by vite. These could be source files if you're not using a bundler. The sequences
and selection
fields are data arguments/props needed by my SequenceViewer
React component, your widget would take different fields.
Typescript/Frontend Config
Next let's take a look at the vite config that generates the bundle/callisto_frontend.js
and bundle/callisto_frontend.css
files.
import { defineConfig } from "vite";
import anywidget from "@anywidget/vite"; // (1a) anywidget import
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
build: {
outDir: "bundle",
lib: {
entry: ["src/callisto_frontend.tsx"], // (2) frontend entrypoint
formats: ["es"],
},
},
plugins: [anywidget(), tailwindcss()], // (1b)
define: {
// (3)
"process.env": {},
},
});
The important things to note are:
(1a) and (1b)
, importing the anywidget vite plugin and wiring it up(2)
, pointing vite to the entrypoint for our frontend code(3)
, definingprocess.env
. For some reason, vite generates code that expects this to be defined, even thoughprocess.env
isn't defined in the browser. We add this define to avoid a runtime error.
Frontend Widget Entrypoint
Finally, let's take a look at the frontend widget entrypoint defined in src/callisto_frontend.tsx
.
import { createRender } from "@anywidget/react";
import App from "./App";
import "./index.css";
const render = createRender(App);
export default { render };
Instead of using React's render
function as we would in a vanilla React app, we import the createRender
function from @anywidget/react
. Our actual widget will live in the an App
component, so we import that. And then the index.css
file which has our tailwindcss config.
Frontend Widget
The widget itself is pretty simple - the only difference from a normal React component is that we use useModelState
from @anywidget/react
to get and set the sequences
and selection
props shown in the python class above.
import { useModelState } from "@anywidget/react";
import {
Annotation,
AriadneSelection,
SequenceViewer,
} from "@nitro-bio/sequence-viewers";
function App() {
const [sequences] = useModelState<string[]>("sequences");
const [selection, setSelection] = useModelState<AriadneSelection | null>(
"selection",
);
const annotations: Annotation[] = [
{
text: "example",
type: "CDS",
direction: "forward",
start: 10,
end: 200,
className: "bg-amber-600 text-white",
},
{
text: "example",
type: "foo",
direction: "reverse",
start: 300,
end: 20,
className: "bg-rose-600 text-white",
},
];
const charClassName = ({ sequenceIdx }: { sequenceIdx: number }) => {
if (sequenceIdx === 0) {
return "text-brand-600";
} else if (sequenceIdx === 1) {
return "text-indigo-600";
} else {
return "text-amber-600";
}
};
return (
<div className="flex max-h-[600px] overflow-y-auto">
<SequenceViewer
sequences={sequences}
annotations={annotations}
selection={selection}
setSelection={setSelection}
charClassName={charClassName}
noValidate
/>
</div>
);
}
export default App;
Now let's take a look at how it all comes together in a development loop. First, let's install the the frontend dependencies and start the vite dev server:
pnpm install
pnpm dev # alias for `vite build --watch`
Now, when you make changes to the src/
directory, the vite dev server will rebuild the frontend code.
in a seperate shell, let's install the notebook's python dependencies and start the notebook server:
uv venv
uv pip install jupyter anywidget
source .venv/bin/activate
juptyer lab
Navigate to http://localhost:8888/lab
and open a new notebook. You should be able to import your widget and use it like so:
Import the widget:
import pathlib
import anywidget
import traitlets
class SequenceViewer(anywidget.AnyWidget):
_esm = pathlib.Path("../bundle/callisto_frontend.js")
_css = pathlib.Path("../bundle/callisto_frontend.css")
sequences = traitlets.List(["ATGC" * 100,]).tag(sync=True)
selection = traitlets.TraitType({ "start": 34, "end": 300, "direction": "forward"}).tag(sync=True)
Display the widget:
nitro_seq_viewer = SequenceViewer()
nitro_seq_viewer
Use the output:
print(nitro_seq_viewer.selection)
If you make changes to the frontend code, you'll need to re-run the import cell and the display cell to see the changes in the notebook.