Filter Plotly scatter3d by pandas MultiIndex dropdowns
Create one dropdown per pandas MultiIndex level to filter Plotly scatter3d traces. Step-by-step code for updatemenus, JS visibility masks, transforms, and standalone HTML export without Dash.
How can I create one dropdown menu per level of a pandas.MultiIndex DataFrame to filter traces in a Plotly scatter3d figure and preserve interactivity in a standalone HTML file (no Dash or IPython.widgets)?
Details:
- I have a DataFrame with a MultiIndex on columns (dates × categories), and each (date, category) column pair is plotted as a separate scatter3d trace.
- Currently I loop over each (date, category) tuple and create a single dropdown with a button for every tuple; this is not viable when a level contains many values.
- I want one dropdown for each MultiIndex level (for example, one dropdown for dates and one for categories) so users can pick a value from each level and the plot updates accordingly.
- The interactivity must work in a standalone HTML export (so
DashandIPython.widgetsare not options).
Example code:
import numpy as np
import pandas as pd
import plotly.graph_objects as go
## data looks something like this
categories = pd.Index(["A", "B"])
dates = pd.date_range(start="2026-01-01", end="2026-01-10", freq="D")
columns = pd.MultiIndex.from_product([dates, categories])
index = pd.Index(range(6), name="Observations")
x, y, z = (pd.DataFrame(
np.random.standard_normal(size=(len(index), len(columns))),
columns=columns,
index=index
) for _ in range(3))
## scatter plot
fig = go.Figure()
for date, cat in columns:
fig.add_scatter3d(
x=x[date, cat],
y=y[date, cat],
z=z[date, cat],
mode="markers",
)
fig.update_layout(updatemenus=[
dict(
x=0.85, y=1.00, xanchor="center", showactive=True,
buttons=[
dict(label=f"({date:%Y-%m-%d}, {cat})", method="restyle", visible=True, args=[{
"visible": np.arange(len(columns)) == c,
}])
for c, (date, cat) in enumerate(columns)
],
),
])
fig.show()
Question: What is the best way to implement one dropdown per MultiIndex level (e.g., date and category) that efficiently updates trace visibility or otherwise filters the plotted traces in Plotly, supports large numbers of values per level, and works in a standalone HTML file without using Dash or IPython.widgets? Are there recommended approaches using Plotly’s updatemenus, custom JavaScript callbacks, grouping/structuring traces, or another technique to achieve this?
Use one of two practical patterns: either (A) keep one trace per (date,category), precompute a mapping from each MultiIndex level value to the trace indices in Python and embed a small Plotly.js script in the exported HTML that reads one
Contents
- Problem overview and UX constraints
- Data shape and trace → index mapping
- Approach A — Precompute visible masks + embedded JavaScript (keeps per-(date,category) traces)
- Approach B — Single-trace + Plotly transforms (most scalable for many values)
- Performance, trade-offs and practical tips (scatter3d)
- Exporting to a standalone HTML and debugging notes
- Sources
- Conclusion
Problem overview and UX constraints
You have a pandas.MultiIndex on your DataFrame columns (dates × categories) and you plot each column pair as a separate Plotly scatter3d trace. Building a single dropdown with one button per (date,category) tuple explodes when a level has many values. The UX you want is one dropdown per MultiIndex level (for example, one “date” select and one “category” select) that combine (typically by intersection) and update which traces/points are visible — and everything must work in a standalone HTML file (no Dash, no ipywidgets). The official Plotly docs describe two canonical patterns here: precompute visibility arrays and call restyle, or use client-side callbacks / Plotly.js to compute combined masks in the browser; there’s also a third option using Plotly transforms to avoid many traces entirely (Plotly dropdowns guidance, updatemenus reference, transforms doc).
Data shape and trace → index mapping
First, convert your MultiIndex columns to a flat list of traces and record each trace’s index. That mapping is the core of the JS approach; transforms use a long (stacked) DataFrame instead.
Example: build a map of level value → list of trace indices (Python):
# 'columns' is your MultiIndex: for example from your example code
mapping = {"date": {}, "category": {}}
for idx, (date, cat) in enumerate(columns):
date_str = pd.Timestamp(date).strftime("%Y-%m-%d") if isinstance(date, pd.Timestamp) else str(date)
mapping["date"].setdefault(date_str, []).append(idx)
mapping["category"].setdefault(str(cat), []).append(idx)
This mapping (serialized to JSON) is what a small client-side script can use to compute the boolean visible mask for Plotly.restyle.
Approach A — Precompute visible masks + embedded JavaScript (keeps per-(date,category) traces)
When you need per-(date,category) trace styling (different marker/color/legend entry) but still want one dropdown per level, embed a tiny JS routine in the exported HTML that combines the two select values and calls Plotly.restyle with a boolean visibility array. This keeps the Python code simple and the browser work cheap (array lookup + restyle).
Copy-paste-ready pattern (adapted to your example):
import json
import numpy as np
import pandas as pd
import plotly.graph_objects as go
import plotly.io as pio
# --- build data (your example) ---
categories = pd.Index(["A", "B"])
dates = pd.date_range("2026-01-01", "2026-01-10")
columns = pd.MultiIndex.from_product([dates, categories])
index = pd.Index(range(6), name="Observations")
rng = np.random.default_rng(0)
x = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)
y = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)
z = pd.DataFrame(rng.standard_normal((len(index), len(columns))), columns=columns, index=index)
# --- add one trace per (date,category) ---
fig = go.Figure()
for i, (date, cat) in enumerate(columns):
fig.add_trace(
go.Scatter3d(
x=x[date, cat], y=y[date, cat], z=z[date, cat],
mode="markers",
name=f"{pd.Timestamp(date).strftime('%Y-%m-%d')} | {cat}"
)
)
# --- build mapping from each level value -> trace indices ---
mapping = {"date": {}, "category": {}}
for idx, (date, cat) in enumerate(columns):
dstr = pd.Timestamp(date).strftime("%Y-%m-%d")
mapping["date"].setdefault(dstr, []).append(idx)
mapping["category"].setdefault(str(cat), []).append(idx)
# --- create HTML and inject JS that builds two <select> elements and updates visibility ---
fig_html = pio.to_html(fig, full_html=True, include_plotlyjs="cdn", div_id="plotly-div")
date_options = "".join([f'<option value="{d}">{d}</option>' for d in mapping["date"].keys()])
cat_options = "".join([f'<option value="{c}">{c}</option>' for c in mapping["category"].keys()])
post_script = f"""
<script>
const mapping = {json.dumps(mapping)};
const n_traces = {len(columns)};
const gd = document.getElementById('plotly-div');
// insert simple controls above the plot
const controls = document.createElement('div');
controls.style.margin = '6px 0';
controls.innerHTML = `
<label>Date: <select id="date-select">{date_options}</select></label>
<label style="margin-left:12px">Category: <select id="cat-select">{cat_options}</select></label>
`;
gd.parentNode.insertBefore(controls, gd);
// compute intersection-visible mask and restyle
function computeVisible(date, cat) {{
const vis = new Array(n_traces).fill(false);
const dateIdx = mapping.date[date] || [];
const catIdx = mapping.category[cat] || [];
const catSet = new Set(catIdx);
for (const i of dateIdx) {{
if (catSet.has(i)) vis[i] = true;
}}
Plotly.restyle(gd, {{'visible': vis}});
}}
// wire up events
const dateSelect = document.getElementById('date-select');
const catSelect = document.getElementById('cat-select');
dateSelect.addEventListener('change', ()=> computeVisible(dateSelect.value, catSelect.value));
catSelect.addEventListener('change', ()=> computeVisible(dateSelect.value, catSelect.value));
// initial apply
computeVisible(dateSelect.value, catSelect.value);
</script>
"""
# write final HTML
html_out = fig_html.replace("</body>", post_script + "</body>")
with open("plotly_multiindex_dropdowns.html", "w", encoding="utf-8") as f:
f.write(html_out)
Why this works
- The Python side only builds traces once and outputs a standalone HTML.
- The mapping JSON is small relative to the trace payload if your levels are moderate.
- At runtime the browser computes an intersection of index lists and calls
Plotly.restylewith a single boolean array — very quick.
When to pick this approach
- You need per-trace styling, separate legend entries or different hover templates per (date,category).
- You have moderate numbers of unique values per level, or you accept the HTML size for very many values.
Related guidance: Plotly docs show precomputing boolean arrays and using restyle for fast toggles (dropdowns doc).
Approach B — Single-trace + Plotly transforms (most scalable for many values)
If you have lots of unique dates and categories (hundreds or thousands), restructure your data into a single long trace and attach a filter transform per MultiIndex level. Each transform filters the same arrays by a target column; use updatemenus that restyle transforms[i].value for each level. This is server-free and pure client-side filtering — highly scalable and still works in a standalone HTML.
Pattern sketch:
# build long-form DataFrame (stack the MultiIndex columns)
dx = x.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'x'})
dy = y.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'y'})
dz = z.stack().reset_index().rename(columns={'level_0':'date', 'level_1':'category', 0:'z'})
df = dx.merge(dy, on=['Observations','date','category']).merge(dz, on=['Observations','date','category'])
df['date_str'] = df['date'].dt.strftime('%Y-%m-%d')
trace = go.Scatter3d(
x=df['x'], y=df['y'], z=df['z'], mode='markers',
transforms=[
dict(type='filter', target=df['date_str'].tolist(), operation='=', value=df['date_str'].iloc[0]),
dict(type='filter', target=df['category'].astype(str).tolist(), operation='=', value=str(categories[0]))
],
)
fig = go.Figure(trace)
# one updatemenu per level that restyles the transform.value
date_buttons = [
dict(label=d.strftime('%Y-%m-%d'), method='restyle', args=[{'transforms[0].value': d.strftime('%Y-%m-%d')}])
for d in dates
]
cat_buttons = [
dict(label=c, method='restyle', args=[{'transforms[1].value': str(c)}])
for c in categories
]
fig.update_layout(updatemenus=[
dict(x=0, y=1.1, buttons=date_buttons),
dict(x=0.35, y=1.1, buttons=cat_buttons),
])
fig.write_html("plot_transforms.html", include_plotlyjs="cdn", full_html=True)
Why this works and when to choose it
- Transforms are applied client-side by Plotly.js, so no server needed.
- You keep a single trace: memory and rendering scale better than thousands of tiny traces.
- Updatemenus that restyle
transforms[i].valuedo not override each other, so two independent dropdowns naturally combine (intersection). - Best when styling per-point/group is uniform (the same marker style) and you only need point-level filtering, not a separate legend entry per (date,category).
See Plotly transforms documentation for details and examples (multiple transforms).
Performance, trade-offs and practical tips (scatter3d)
- Many traces vs. one trace: Plotly’s WebGL renderer (
scatter3d) handles thousands of points in a single trace well, but many traces are heavier than many points in one trace (3d scatter notes). If you have styling per group, you may need multiple traces; otherwise transforms or a single trace are cheaper. - Use
restylefor visibility toggles (fast because the browser receives a precomputed array): see the official dropdown pattern (dropdowns doc, updatemenus reference). - If you choose the JS-visible-mask route and have extremely many level values, the mapping JSON can get large. In that case prefer transforms or server-side solutions (Dash) if server-side compute is available.
- Grouping:
legendgroup/legendgrouptitlecan help reduce legend clutter and allow grouped legend toggles, but they don’t replace the dropdown-filtering UX. See the plotly.js discussion on legend grouping (GitHub issue) if grouping is relevant: https://github.com/plotly/plotly.js/issues/3135. - Multi-select: For multi-select dropdowns you can build UI
<select multiple>+ adjust the JS to union/ intersection indices. For transforms, you can sometimes useoperation: 'in'withvalueas an array if Plotly supports it in your version.
Exporting to a standalone HTML and debugging notes
- Export:
fig.write_html(..., full_html=True, include_plotlyjs='cdn')or useplotly.io.to_html()then insert your JS before</body>as shown above. This produces a single file that works offline (with or without CDN) and contains your controls. - No Dash required: embedding Plotly.js and a bit of JS in the HTML is the recommended route for standalone interactive files (community discussions: https://stackoverflow.com/questions/60097577/how-to-export-a-plotly-dashboard-app-into-a-html-standalone-file-to-share-with-t and https://stackoverflow.com/questions/58985789/are-javascript-callbacks-possible-in-plotly-or-dash).
- Debugging tips:
- Confirm your plot DIV id (use
div_idinto_htmlor inspect the HTML). console.log(mapping)from the injected script to verify keys and index lists.- If
Plotly.restyleseems to do nothing, check that the boolean array length equals the trace count. - If transforms don’t filter as expected, ensure
targetis an array-like (use.tolist()or convert to str) and the transformvaluetype matches.
Sources
- Plotly Documentation — Dropdown menus in Python
- Plotly Reference — layout.updatemenus
- Plotly Documentation — Multiple transforms in Python
- Plotly Documentation — 3d scatter plots in Python
- https://stackoverflow.com/questions/61556618/plotly-how-to-display-and-filter-a-dataframe-with-multiple-dropdowns
- https://stackoverflow.com/questions/59406167/plotly-how-to-filter-a-pandas-dataframe-using-a-dropdown-menu
- https://community.plotly.com/t/plotly-python-adding-multiple-dropdowns-to-select-unique-trace-and-show-together-on-one-fig/55631
- https://community.plotly.com/t/updatemenus-button-select-multiple-traces/18797
- https://stackoverflow.com/questions/58985789/are-javascript-callbacks-possible-in-plotly-or-dash
- https://stackoverflow.com/questions/60097577/how-to-export-a-plotly-dashboard-app-into-a-html-standalone-file-to-share-with-t
- https://github.com/plotly/plotly.js/issues/3135
Conclusion
Two practical, standalone-HTML solutions are recommended: use Plotly filter transforms (one transform per MultiIndex level) if you want the most scalable client-side filtering with simple updatemenus, or keep per-(date,category) traces and embed a small Plotly.js script that combines one dropdown per level into a boolean visibility mask and calls Plotly.restyle. Pick transforms when you can represent all points in one trace and you want maximum scale; pick the JS-visible-mask approach when per-trace styling/legend entries matter.