Tired of static, lifeless charts that just sit on the page? In a world swimming with data, a simple bar graph or a flat scatter plot just doesn't cut it anymore. Your audience wants to explore, to filter, to interact with the data and uncover their own insights. They want a story, not just a picture.
That's where dashboards come in. But let's be honest, building a truly interactive web-based dashboard can feel daunting. You might think you need to be a full-stack wizard, juggling a Python backend with a complex JavaScript frontend framework. What if I told you there's a better way?
Enter Bokeh. It's a fantastic Python library that bridges the gap, letting you build sophisticated, browser-based visualizations with the data-crunching power of Python and the slick responsiveness of JavaScript. In this guide, we're going to roll up our sleeves and build a dashboard from the ground up. We'll start with a simple plot and layer on interactivity, from linked selections to dynamic filters and even real-time updates—all powered by Bokeh and a sprinkle of its secret weapon: CustomJS.
Laying the Groundwork: From Raw Data to Your First Interactive Plot
Every great dashboard starts with two things: data and a basic plot. Before we can build our masterpiece, we need to set up our canvas and get our materials ready. For this project, we'll be using a few key Python libraries: Bokeh for the plotting, and Pandas and NumPy for creating and managing our sample data.
First, let's create a synthetic dataset that mimics sensor readings. Imagine we're tracking temperature, pressure, and humidity from a few different sensors over time.
import numpy as np
import pandas as pd
from bokeh.io import show
from bokeh.plotting import figure
from bokeh.models import ColumnDataSource, HoverTool
# Create some sample data
np.random.seed(42)
N = 300
data = pd.DataFrame({
"temp_c": 20 + 5 * np.random.randn(N),
"pressure_kpa": 101 + 3 * np.random.randn(N),
"humidity_pct": 40 + 15 * np.random.randn(N),
"sensor_id": np.random.choice(["A1", "A2", "B7", "C3"], size=N),
"timestep": np.arange(N)
})
# Bokeh works best with its own data structure, the ColumnDataSource
source_main = ColumnDataSource(data)
The ColumnDataSource is Bokeh's secret sauce. It's a data object that Bokeh components (like plots and widgets) can efficiently share. Any updates to this source will automatically propagate to all the plots listening to it.
Now, let's make our first plot: a simple scatter plot of temperature vs. pressure.
# Create a figure
p_scatter = figure(
title="Temperature vs Pressure",
width=400, height=300,
x_axis_label="Temperature (°C)",
y_axis_label="Pressure (kPa)",
tools="pan,wheel_zoom,reset" # The tools you want available
)
# Add a circle glyph
scat = p_scatter.circle(
x="temp_c", y="pressure_kpa",
size=8, fill_alpha=0.6,
fill_color="orange", line_color="black",
source=source_main, legend_label="Sensor Readings"
)
# Add a hover tool for more detail
hover = HoverTool(tooltips=[
("Temp (°C)", "@temp_c{0.0}"),
("Pressure", "@pressure_kpa{0.0} kPa"),
("Humidity", "@humidity_pct{0.0}%"),
("Sensor", "@sensor_id")
], renderers=[scat])
p_scatter.add_tools(hover)
p_scatter.legend.location = "top_left"
show(p_scatter)
Just like that, you have more than a static image. You have a plot you can pan, zoom, and inspect. Hovering over any data point reveals its specific details, thanks to the HoverTool. This is the baseline interactivity that Bokeh gives you for free, and it's already a huge step up from a static matplotlib export.
Connecting the Dots: Visualizing Relationships with Linked Brushing
One plot is good, but a great dashboard reveals relationships across different variables. What if we want to see how the points we select in our temperature-pressure plot look in a humidity-temperature plot? This is where "linked brushing" comes in, and it's incredibly easy with Bokeh.
Because both plots will use the same ColumnDataSource, any selection on one plot will be automatically reflected on the other. Let's create a second plot for humidity vs. temperature and place it next to our first one.
# (imports and data setup from before)
from bokeh.layouts import row
from bokeh.plotting import figure
# ... (p_scatter code from above) ...
# Create the second plot
p_humidity = figure(
title="Humidity vs Temperature (Linked Selection)",
width=400, height=300,
x_axis_label="Temperature (°C)",
y_axis_label="Humidity (%)",
# Add selection tools!
tools="pan,wheel_zoom,reset,box_select,lasso_select,tap"
)
p_humidity.square(
x="temp_c", y="humidity_pct",
size=8, fill_alpha=0.6,
fill_color="navy", line_color="white",
# CRITICAL: Use the same data source
source=source_main
)
# Arrange the plots side-by-side
layout_linked = row(p_scatter, p_humidity)
show(layout_linked)
Now, when you use the Box Select or Lasso Select tool on one of the plots, you'll see the corresponding points highlight on the other. This is a powerful analytical tool. Are the high-pressure readings also the high-humidity ones? Just draw a box and find out instantly. This is the magic of a shared data source.
Painting a Richer Picture: Adding Depth with Color Gradients
Color is one of the most effective ways to add another dimension of data to a 2D plot. Instead of just plotting pressure vs. humidity, what if the color of each point could represent its humidity level? This gives us three dimensions of information in one glance.
Bokeh makes this easy with a LinearColorMapper. We'll map the continuous values in our humidity_pct column to a color palette.
# (imports and data setup from before)
from bokeh.models import LinearColorMapper, ColorBar, BasicTicker, PrintfTickFormatter
from bokeh.palettes import Viridis256
# Create a color mapper
color_mapper = LinearColorMapper(
palette=Viridis256,
low=data["humidity_pct"].min(),
high=data["humidity_pct"].max()
)
# Create the figure
p_color = figure(
title="Pressure vs Humidity (Colored by Humidity)",
width=500, height=350,
x_axis_label="Pressure (kPa)",
y_axis_label="Humidity (%)"
)
# The 'color' argument is now a dictionary pointing to the data field and mapper
p_color.circle(
x="pressure_kpa", y="humidity_pct",
size=8, fill_alpha=0.8, line_color=None,
color={"field": "humidity_pct", "transform": color_mapper},
source=source_main
)
# Add a color bar to serve as a legend
color_bar = ColorBar(
color_mapper=color_mapper,
title="Humidity %",
location=(0, 0)
)
p_color.add_layout(color_bar, "right")
show(p_color)
Now, you have a beautiful plot where the relationship between pressure and humidity is immediately visible, and the color gradient provides an intuitive understanding of the humidity levels. The color bar on the right is crucial—it's the legend that makes the colors meaningful.
Putting You in the Driver's Seat: Building Interactive Controls
This is where our dashboard truly starts to feel like a dynamic application. We're going to give the user direct control over the data they see using widgets like dropdowns and sliders. This is perfect for filtering down to a specific sensor or exploring a certain temperature range.
Filters that Talk: Dropdowns and Sliders
We'll add a dropdown to filter by sensor_id and a slider to filter by a maximum temperature. These widgets, when changed, will trigger a Python callback function that updates which data is visible.
To do this, we'll use a CDSView with a BooleanFilter. Think of the view as a lens that sits on top of our ColumnDataSource. The filter tells the lens which rows of data to let through. We change the filter, and the plot updates instantly without reloading the page.
# (imports and data setup from before)
from bokeh.layouts import column, row
from bokeh.models import Select, Slider, CDSView, BooleanFilter, Div
# Create the widgets
sensor_options = sorted(data["sensor_id"].unique().tolist())
sensor_select = Select(title="Filter by Sensor ID:", value=sensor_options[0], options=sensor_options)
temp_slider = Slider(
title="Filter by Max Temperature (°C):",
start=data["temp_c"].min(),
end=data["temp_c"].max(),
step=0.5,
value=data["temp_c"].max()
)
# Create the filter and view
# This list of booleans determines which rows are visible
initial_booleans = [
(s == sensor_select.value) and (t <= temp_slider.value)
for s, t in zip(data["sensor_id"], data["temp_c"])
]
bool_filter = BooleanFilter(booleans=initial_booleans)
view = CDSView(filter=bool_filter)
# Create the plot that will use the view
p_filtered = figure(
title="Filtered Sensor Data",
width=400, height=300,
x_axis_label="Temp (°C)",
y_axis_label="Pressure (kPa)"
)
# IMPORTANT: Pass the 'view' to the renderer
p_filtered.circle(
x="temp_c", y="pressure_kpa",
source=source_main, view=view, # Use the view here!
size=8, fill_alpha=0.7, fill_color="firebrick"
)
# Define the Python callback function
def update_filters(attr, old, new):
# This function runs in Python when a widget changes
new_booleans = [
(s == sensor_select.value) and (t <= temp_slider.value)
for s, t in zip(data["sensor_id"], data["temp_c"])
]
bool_filter.booleans = new_booleans # Update the filter
# Link the callback to the widgets
sensor_select.on_change("value", update_filters)
temp_slider.on_change("value", update_filters)
# Arrange the dashboard
controls = column(Div(text="<b>Interactive Filters</b>"), sensor_select, temp_slider)
dashboard_layout = row(p_filtered, controls)
show(dashboard_layout)
Now you have a plot and a set of controls. Change the dropdown or move the slider, and you'll see the plot update in real-time. This interaction requires a running Python kernel (like in a Jupyter notebook or a Bokeh server) to execute the update_filters function.
Instant Gratification: Supercharging Your Dashboard with CustomJS
The Python callback approach is powerful, but it has one limitation: it requires a live Python process to respond to user interactions. What if you want to export your dashboard to a standalone HTML file that works anywhere, without a server? Or what if you need an interaction to be absolutely instantaneous, with zero network latency?
This is where CustomJS comes in. It allows you to write small snippets of JavaScript code that run directly in the user's browser. This code can manipulate your plot's data sources and properties without ever talking back to Python.
Let's build a simple example to see it in action. We'll create a plot with a button that, when clicked, makes the points on the plot larger.
# (imports from before)
from bokeh.models import Button, CustomJS
# A new, simple data source for this example
mini_source = ColumnDataSource({
"x": np.linspace(0, 2 * np.pi, 80),
"y": np.sin(np.linspace(0, 2 * np.pi, 80))
})
p_wave = figure(title="Sine Wave (CustomJS Example)", width=400, height=250)
# Get a reference to the renderer so we can modify it in JS
wave_renderer = p_wave.circle(
x="x", y="y", source=mini_source,
size=6, fill_color="green"
)
# The JavaScript code snippet
js_code = """
// 'r' is the renderer we passed in the 'args'
// 'glyph' is the visual representation (the circle)
const current_size = r.glyph.size;
r.glyph.size = current_size + 2;
"""
# Create the CustomJS callback
js_callback = CustomJS(args=dict(r=wave_renderer), code=js_code)
# Create a button and attach the JS callback to its 'on_click' event
grow_button = Button(label="Enlarge Points (Client-Side)", button_type="success")
grow_button.js_on_click(js_callback)
show(column(p_wave, grow_button))
Click the button, and the points grow instantly. No lag, no waiting for a server. The args dictionary in CustomJS is the magic that passes your Python objects (like the wave_renderer) into the JavaScript namespace, so you can manipulate them. This unlocks incredibly fast, client-side interactivity for standalone dashboards.
Bringing Your Dashboard to Life: Visualizing Real-Time Data Streams
Many modern applications need to visualize data as it arrives—think IoT sensor feeds, stock tickers, or live monitoring systems. Bokeh handles this gracefully with the stream method of its ColumnDataSource.
We can simulate a live data stream by periodically adding new data points to our source. The plot will automatically update to show the new data.
# (imports from before)
import time
from bokeh.io import push_notebook
# Create a source for streaming data
stream_source = ColumnDataSource({"t": [], "val": []})
# Create a plot to display the stream
p_stream = figure(title="Streaming Sensor Value", width=500, height=250)
p_stream.line(x="t", y="val", source=stream_source, line_width=2)
p_stream.circle(x="t", y="val", source=stream_source, size=6, fill_color="red")
# Get a handle to update the plot in a Jupyter environment
handle = show(p_stream, notebook_handle=True)
# The streaming loop
for t in range(50):
new_point = {"t": [t], "val": [np.sin(t / 5) + 0.2 * np.random.randn()]}
# The stream method adds new data
stream_source.stream(new_point, rollover=100) # 'rollover' limits the data size
push_notebook(handle=handle) # Push the update
time.sleep(0.1)
Run this code, and you'll see the plot draw itself in real-time. The stream method is highly efficient for appending data, and the rollover argument is a lifesaver, preventing the browser from getting bogged down by keeping only the last 100 points.
Beyond the Basics: What You've Built and Where to Go Next
Let's take a step back and look at what we've accomplished. We went from a blank slate to a feature-rich, interactive dashboard. We learned how to create linked plots for comparative analysis, use color to add depth, and empower users with custom filters and controls. Most importantly, we saw the distinction between server-side Python callbacks and lightning-fast, client-side CustomJS interactions.
This journey showcases the core philosophy of Bokeh: it seamlessly blends the analytical heavy-lifting of Python with the fluid, interactive capabilities of modern web browsers. You don't have to choose between powerful data processing and a great user experience—Bokeh gives you both.
The dashboard we built is just the beginning. From here, you can explore more complex layouts, different plot types, and more advanced CustomJS callbacks to create truly bespoke data exploration tools. So take these building blocks, apply them to your own datasets, and start telling interactive stories with your data.




