Jul 2026
5 min
ninejs is a Python package that adds tooltips, hover effects, click events, and standalone HTML export to plotnine charts.
I use plotnine a lot. It has a grammar that feels natural when you want to build a chart layer by layer, and it fits nicely into Python data workflows. But once a chart is rendered, it is static. That is often fine, until you want a reader to inspect a point, follow a line, or connect the same observation across multiple panels.
ninejs is my attempt to keep the plotnine workflow and add a thin layer of browser interactivity on top of it. You build the chart as a normal plotnine object, add a few extra aesthetics such as tooltip, hover_group, hover_key, or on_click, then export a standalone HTML file.
ninejs isn't a plotting library but rather a plotnine extension. There is no plotting engine and just adds the interactivity touch, which keeps it fairly lightweight!
ninejsThe chart below is a regular plotnine chart; the only difference is that the data mapped in aes() also contains interaction metadata.
from plotnine import aes, geom_point, ggplot, theme_minimal
from plotnine.data import anscombe_quartet
from ninejs import css, interactive, save
gg = (
ggplot(
anscombe_quartet,
aes(
x="x",
y="y",
color="dataset",
tooltip="dataset",
hover_group="dataset",
),
)
+ geom_point(size=7, alpha=0.5)
+ theme_minimal()
)
interactive(gg, hover_nearest=True)The code is almost identical to the original plotnine chart. The interactive part is not a separate JavaScript chart specification; it is attached to the same variables that already define the plot.
One feature I wanted early was linked hover. If the same row appears in two views, hovering it in one view should highlight it in the other.
import plotnine as gg
from plotnine.data import mtcars
from ninejs import css, interactive, save
mtcars = mtcars.rename(columns={"name": "carname"}).copy()
mtcars["car_id"] = mtcars["carname"].str.replace(r"\W+", "-", regex=True)
mtcars["tooltip"] = [
f"{carname}<br>MPG: {mpg}<br>Displacement: {disp}<br>Quarter mile: {qsec}s"
for carname, mpg, disp, qsec in zip(
mtcars["carname"],
mtcars["mpg"],
mtcars["disp"],
mtcars["qsec"],
strict=True,
)
]
mapping = gg.aes(tooltip="tooltip", hover_group="car_id", hover_key="car_id")
scatter_plot = (
gg.ggplot(mtcars, gg.aes("disp", "qsec"))
+ gg.geom_point(mapping=mapping, size=3, fill="#333333", color="#333333")
+ gg.theme_minimal(base_size=10)
)
bar_plot = (
gg.ggplot(mtcars, gg.aes("reorder(carname, mpg)", "mpg"))
+ gg.geom_col(mapping=mapping, fill="#8ecae6", color="white", size=0.35)
+ gg.coord_flip()
+ gg.theme_minimal(base_size=10)
)
plot = scatter_plot | bar_plot
(
interactive(plot, hover_nearest=True)
+ css(from_dict={".plot-element.hovered": {"fill": "#d62828"}})
)The important part is hover_key. The scatter point and the bar are two different SVG elements in two different panels, but they carry the same key. ninejs uses that key in the browser to apply the hover state to both elements at the same time.
The package currently supports all common plotnine geoms such as points, lines, paths, bars, histograms, areas, ribbons, and maps. Facets also work, and the generated output is a self-contained HTML document with the required JavaScript and CSS included.
You can also bring your own styling and behavior. css() lets you override the tooltip or hover states, while javascript() and on_click let you add custom browser-side behavior when a chart needs something more specific.
This is just a short overview of ninejs. You can read the documentation here, browse the gallery of examples here, and find the source code here.