A 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).

Callisto Widget in Jupyter Notebook

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), defining process.env. For some reason, vite generates code that expects this to be defined, even though process.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/laband 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)

Callisto Widget in Jupyter Notebook

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.