Tips for saving interactive plots as html

It is generally possible to save an interactive HoloViews or panel objects as html files using the save function/method. However, there are limitations.

What are the limitations and what are pitfalls to avoid?


First, lets review ways that a file can be created using save. Since HoloViews save uses the panel save function, we will review the parameters of the save method in panel:

save(panel, filename, title=None, resources=None, template=None,
         template_variables=None, embed=False, max_states=1000,
         max_opts=3, embed_json=False, json_prefix='', save_path='./',

panel is the panel object you are saving
filename is the file you are saving to -if you do not define a suffix, it is assumed to be html by default, as we will discuss here.
title is the tab name that will appear int he browser - the window title - of not changes, it will be called panel
resources is used when you want your html file to be static and work offline without need to reach out to the internet to grab functions needed to display the interactive part. This makes the html file larger, yet it can work locally and independently. The natural use of this by using resources = INLINE after from bokeh.resources import INLINE

template and template_variables are a template html file and variables to pass to that template as explained here

embed is a key parameter. If embed=False then the html document created will not be interactive, for example, if you have a HoloMap with a slider, then the slider will not change the plot.

So to make the html interactive, one will need to use embed=True - in which case, the html file will include JavaScript code to make it interactive. However, when using embed = True, there are a few things to consider.

  1. How big you want the generated files to be ?
  2. How much time it will take to construct your file ?
  3. Is your content static or dynamic?

Those will be discussed below.

max_states is a number that defines when a warning appears when creating a plot. In the past is was used to block the user from generating huge files that take hours to generate. Yet from version 0.8 it just gives a warning when a user passes a certain number of states - the default is 1000. What are those states?
Imagine you create a HoloMap with 3 dimensions using the code below

import holoviews as hv
import numpy as np
import panel as pn
import bokeh
from bokeh.resources import INLINE


def sine_curve(phase, freq, elevation):
    xvals = [0.1* i for i in range(100)]
    yvals = [np.sin(phase+freq*x) + elevation for x in xvals]
    data = {'xvals': xvals, 'yvals': yvals}
    return hv.Points(data, kdims=['xvals','yvals'])

frequencies = [0.5+i*0.25 for i in range(5)] 
phases      = [i*np.pi/2 for i in range(5)]
elevations  = [i*0.1 for i in range(5)]
curve_dict = {(p,f,v): sine_curve(p,f,v) for p in phases for f in frequencies for v in elevations}
hmap = hv.HoloMap(curve_dict, kdims=['phase', 'frequency', 'elevation']).opts(height=500,width=500)

panel_object = pn.pane.HoloViews(hmap)
pn.pane.HoloViews(hmap).save('test', embed=True, resources=INLINE)

In this situation there are 5x5x5=125 states. It will take a few seconds to generate and the file size would be ~1.5Mb - it will smaller without resources included. - about 0.6Mb. However, lets look at what we are doing here, there are 125 images that represent the plot - one for each tick combination of any of the sliders. So each possible image represent a state. If we increase the number of ticks in the frequency slider to 20, then we will have 20x5x5 = 500 images / states which will take less than a minute to create a file of size 2,6Mb. And if we increase the number of ticks in all sliders to 20 we will get 20x20x20 =8000 image/states - which will trigger a warning by default - unless we set max_states to be greater than 8000, in which case the warning will disappear. The reason the warning is there is to make sure that the time an file size are reasonable and prevent the user from running long inefficient processes - for example, to generate the file with 8000 states, it will take about half an hour and the file would be about 23Mb in size. Due to the exponential nature of combining states from different widgets, this can quickly become unreasonable, therefore the need of a warning. So the user needs to be aware of the size of the file to be generated. For example, the problem becomes worst when you create 2 panel layout tabs each hosting a plot generated by the original states sine example. In this case, there are 5x5x5 states for the first tab and 5x5x5 for the second tab altogether 5^6 = 15625 states, which can take an hour to generate and the file will be bigger. And if we try to generate two tabs each with a plot with 20 ticks per slider we will have 20^6 =64000000 states, which will not be reasonable to compute on one computer since it will take a long time - estimated as about half a year of computation assuming there is a strong enough computer with sufficient memory to finish the computation and the file generated is estimated to be around 180 Gb in size. So the user has to be aware of limitations of the save command. This relates to the first two questions the user needs to consider we listed above.

Before continuing to discussion about the third question , lets review the last few parameters:
max_opts is a parameter that is relevant to continuous widgets such as sliders where the user did not define the ticks as we explicitly defined in the example above. In such widgets, the system need to know where to put the ticks in which the slider will stop - max_opts defines exactly that.

The last few parameters embed_json, json_prefix, save_path, load_path are used if the user wished to export some of the data into json files to accompany with the html file that the html file will know to interact with. json is an exchange format for data and if used, it can help with future manipulation of this data and bokeh knows how to use these files.

However, it is also important to know what the save is useful for. In the above case, the data does not change once the plot is generated. The data is not time dependent, it is not random, and is not dynamic in any way. Each time you run the code you expect to see the exact same output. However, if your data is dynamic and will change each time you run the program, then the save command will freeze the situation and capture the system states specifically for that time and the next time you use save, you will get a different html file that captures different states. For example if the data you have captures vitals of an athlete running, then each time you generate a file using save, it is similar to freezing the athlete in time and recording all the parameters in that snapshot.

With dynamic data, it may not be reasonable to generate static html files using embedded html files - it may be inefficient - files may be large and the time to generate may not be reasonable. In such cases using a panel server may be the best solution. On the other hand, sometimes, it may be useful to capture dynamic data in a document that can be preserved in an html file like a snapshot. The user will have to make those decision.

Even with static data that does not change, the number of combinations of states may be so large that it is not reasonable to save them as an html file. However, creating a panel server to serve the plots dynamically may be a suitable solution. Fortunately panel offers both export and deploy solutions: server / static export and the user can decide depending on constraints.


Hi @Jacob-Barhak,

I have recently found your post on interactive HTML files. I have to achieve exactly the same thing and I have tryied to apply the method you exposed, unfortunatelly with no succes. Here I provide the code of my app based on Panel for reproducibility.

from logging import setLogRecordFactory
import param
import numpy as np
import pandas as pd
import panel as pn
import holoviews as hv
import bokeh
import hvplot.pandas
from holoviews.plotting.util import process_cmap

pn.config.sizing_mode = "stretch_width"

EMPTY_PLOT = hv.Curve({})
COLOR_MAPS = hv.plotting.util.list_cmaps()
STYLE = """
body {
    margin: 0px;
    min_height: 100vh;
} {
    background: #f2f2f2;
    color: #000000;
    font-family: roboto, sans-serif, Verdana;
} {
    background: #212121;
    border-color: white;
    box-shadow: 5px 5px 20px #9E9E9E;
    color: #ffffff;
    z-index: 50;
} {
    background: #ffffff;
    border-radius: 5px;
    box-shadow: 2px 2px 2px lightgrey;
    color: #000000;
} {
    background: #e0e0e0;
    color: #000000;



    data_A = pd.read_csv("data_A.csv", index_col=0)
except Exception as e:
    data_A = pd.read_csv(

    data_B = pd.read_csv("data_B.csv", index_col=0)
except Exception as e:
    data_B = pd.read_csv(

Variable = pn.widgets.RadioBoxGroup(
    options=["Cut Distance", "Removed Volume", "Av. uncut chip thickness"],

# Insert plot
class PempDashoardApp(param.Parameterized):
    tool = param.ObjectSelector(label="Tool", default="S1_1", objects=["S1_1", "S2_1"])
    variable = param.ObjectSelector(
        default="Cut Distance",
        objects=["Cut Distance", "Removed Volume", "Av. uncut chip thickness"],
    color_map = param.ObjectSelector(default="winter", objects=COLOR_MAPS)

    insert_plot_pane = param.ClassSelector(class_=pn.pane.HoloViews)
    edge_plot_pane = param.ClassSelector(class_=pn.pane.HoloViews)
    history_plot_pane = param.ClassSelector(class_=pn.pane.HoloViews)

    view = param.ClassSelector(class_=pn.Column)

    def __init__(self, **params):
        params["insert_plot_pane"] = pn.pane.HoloViews(EMPTY_PLOT, sizing_mode="stretch_both", margin=10)
        params["edge_plot_pane"] = pn.pane.HoloViews(EMPTY_PLOT, sizing_mode="stretch_both", margin=10)
        params["history_plot_pane"] = pn.pane.HoloViews(EMPTY_PLOT, sizing_mode="stretch_both", margin=10)
        params["view"] = pn.Column(css_classes=["app-body"], sizing_mode="stretch_both", margin=0)



    def _init_view(self):
        appbar = pn.Row(
            pn.pane.Markdown("# Classic Dashboard in Panel ", margin=(10, 5, 10, 25)),
                margin=(10, 50, 10, 5),
        settings_bar = pn.Row(
                parameters=["tool", "variable"],
                    "tool": {"align": "center", "width": 75, "sizing_mode": "fixed"},
                    "variable": {"type": pn.widgets.RadioBoxGroup, "inline": True, "align": "end",},
                margin=(10, 25, 10, 5),
            margin=(50, 25, 25, 25),

        self.view[:] = [
                pn.Column(self.insert_plot_pane, css_classes=["app-container"], margin=25),
                pn.Column(self.edge_plot_pane, css_classes=["app-container"], margin=25),
            pn.Row(self.history_plot_pane, css_classes=["app-container"], margin=25),

    @pn.depends("tool", "variable", "color_map", watch=True)
    def _update_insert_plot(self):
        plot_data = data_A.loc[self.tool]
        data = [(plot_data["Xo"], plot_data["Yo"], plot_data[self.variable])]
        self.insert_plot_pane.object = hv.Path(data, vdims=self.variable).opts(
            cmap=self.color_map, color=self.variable, line_width=4, colorbar=True

    @pn.depends("tool", "variable", "color_map", watch=True)
    def _update_edge_plot(self):
        plot_data = data_A.loc[self.tool]
        self.edge_plot_pane.object = plot_data.hvplot(
            x="Number", y=self.variable, kind="area", alpha=0.6, color=self.get_color

    @pn.depends("tool", "color_map", watch=True)
    def _update_history_plot(self):
        plot_data = data_B.loc[self.tool]
        self.history_plot_pane.object = plot_data.hvplot(
            x="Cut Distance", y="Feed", kind="line", line_width=4

    def get_color(self):
        return process_cmap(self.color_map, 1)[0]

def view():
    return PempDashoardApp().view


This is what my app looks like when deployed in the Jupyter environment. Then I do this to save it as an html:

view().save('test', embed=True, resources=INLINE)

I do get a new html file but the interactivity given by the widgets is apparently lost. Does anyone have any hints for my issue?

Thanks in advance!

1 Like

Sorry @pemp for the late response. I just saw your question.

You did not specify versions of libraries, so I could not run your code on Jupyter - I was able to run it as a regular file and see the output. I looked at your code. It seems that you are creating your own dashboard rather than using existing primitives and using callback functions to handle events of changing widget value.

I suspect that the @pn.depends decorators that you use are not recognized by the save command and therefore you loose the interactivity. What you are seeing is the plot specified by the default option. Although it seems to you that your plot is static since you do not load any new data your dashboard is actually dynamic. It is dynamic since it plots a new plot only when you trigger the widget and the python callback functions that update color only execute when you trigger them by changing the value in the widget.

In the example I have about with holoviews, the system renders all the plots behind the scenes and triggering the plot change does not call a python function. In fact, holoviews generates all the plots beforehand.

In your code the system does not know in advance how many colors are there. You can potentially change it dynamically somewhere else in your code. In my example, all plots for each option have been generated in a for loop before I assemble them in holoviews.

If you want to convert your example to a static html file, you will have to make some compromises on looks and prepare all plots before you render them.

Note that panel is designed so you can create amazing dynamic contents that you cannot save into a static html file. However, you can still create very sophisticated plots that will save to one html file.

1 Like

Thank you @Jacob-Barhak for the detailed answer. After playing around a little bit I understood the constraints of standalone html files and the requirements for a certain degree of interactivity. Based on this I adapted my app in order to comunicate the essential results and still present them in a htm file.