Skip to content

Erratic axis range updates in response to scroll-zooming are visually distracting (best case) and can make callbacks unusable (worst case) #7494

@robertcollar-kobold

Description

@robertcollar-kobold

Expected behavior:

Axis ranges update smoothly in response to scroll-zooming

Screen.Recording.2025-07-24.at.1.22.11.PM.mov

Observed behavior:

Axis ranges update erratically in response to scroll-zooming, producing particularly egregious flashing of images displayed on figures.

Screen.Recording.2025-07-24.at.1.23.14.PM.mov

Impact

Seemingly any figure that uses scroll zooming can exhibit this erratic behavior. This is of course visually distracting, but it is particulary impactful when callbacks respond to axis range changes, which are erratic. In the worst case scenario, where two figures have their axis ranges synced through callbacks (e.g., an axis range change on one figure changes the axis range on another), this erratic behavior can lead to an infinite loop that renders them useless (not reproduced here given the complexity of those visualizations).

Steps to reproduce (using plotly.py in a jupyter notebook):

import plotly.graph_objects as go

1.) Scroll zooming produces erratic behavior when axis tick labels are long

fig = go.Figure()

fig.layout.yaxis.range = (1e5, 1e5 + 5)

fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.16.17.PM.mov

2.) This is most visible when an image is present

Examples going forward will have an image added. While each exhibits the problematic behavior without the image, adding the image highlights it.

fig = go.Figure()

# Add images
fig.add_layout_image(
        dict(
            source="https://images.plot.ly/language-icons/api-home/python-logo.png",
            xref="x",
            yref="y",
            x=0,
            y=1e5+5,
            sizex=2,
            sizey=2,
            sizing="stretch",
            opacity=0.5,
            layer="below")
)

fig.layout.yaxis.range = (1e5, 1e5 + 5)
fig.layout.yaxis.title = "title"

fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.16.52.PM.mov

3.) It happens any time that axis tick labels get long enough to move the axis title away from the plot

Here, the y axis tick labels are small initially, but upon zooming in enough to produce long y axis tick labels, the behavior reoccurs.

fig = go.FigureWidget()

# Add images
fig.add_layout_image(
        dict(
            source="https://images.plot.ly/language-icons/api-home/python-logo.png",
            xref="x",
            yref="y",
            x=0,
            y=3,
            sizex=2,
            sizey=2,
            sizing="stretch",
            opacity=0.5,
            layer="below")
)

fig._config = {"scrollZoom": True}
fig.layout.yaxis.title = "title"

fig
Screen.Recording.2025-07-24.at.11.51.10.AM.mov

4.) Setting the margin to not auto expand solves the issue in this case...

fig = go.FigureWidget()

# Add images
fig.add_layout_image(
        dict(
            source="https://images.plot.ly/language-icons/api-home/python-logo.png",
            xref="x",
            yref="y",
            x=0,
            y=3,
            sizex=2,
            sizey=2,
            sizing="stretch",
            opacity=0.5,
            layer="below")
)

fig._config = {"scrollZoom": True}
fig.layout.yaxis.title = "title"

fig.layout.margin.autoexpand=False

fig
Screen.Recording.2025-07-24.at.11.52.23.AM.mov

5.) ...but setting the margin to not auto expand is not a sufficient solution in all cases

Here, we add a callback that updates the figure title on a y axis range change (i.e., on a scroll zoom). The behavior persists despite the axis tick labels being very short and seems to be coincident with when the title is updated.

fig = go.FigureWidget()

# Add images
fig.add_layout_image(
        dict(
            source="https://images.plot.ly/language-icons/api-home/python-logo.png",
            xref="x",
            yref="y",
            x=0,
            y=3,
            sizex=2,
            sizey=2,
            sizing="stretch",
            opacity=0.5,
            layer="below")
)

fig._config = {"scrollZoom": True}

def callback(event, *args):
    fig.layout.title = str(fig.layout.yaxis.range)

fig.layout.on_change(callback, 'yaxis.range')
fig.layout.margin.autoexpand=False

fig
Screen.Recording.2025-07-24.at.11.54.19.AM.mov

6) Side note: in the example below, shrinking the margins exacerbates the issue...

fig = go.Figure()

# Add images
fig.add_layout_image(
        dict(
            source="https://images.plot.ly/language-icons/api-home/python-logo.png",
            xref="x",
            yref="y",
            x=0,
            y=1e5+5,
            sizex=2,
            sizey=2,
            sizing="stretch",
            opacity=0.5,
            layer="below")
)

fig.layout.yaxis.range = (1e5, 1e5 + 5)
fig.layout.margin.update(r=0, l=0, t=0, b=0)

fig.show(config={"scrollZoom": True})
Screen.Recording.2025-07-24.at.1.18.34.PM.mov

7.) This isn't just in jupyter notebooks or plotly.py; here's some html with plotly.js you can run to reproduce the issue:

<!DOCTYPE html>
<html>
<head>
    <script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
    <style>
        #plot { width: 800px; height: 600px; }
        #debug { margin-top: 20px; padding: 10px; background: #f0f0f0; }
    </style>
</head>
<body>
    <div id="plot"></div>
    <div id="debug">
        <h3>Debug Info:</h3>
        <div id="events"></div>
    </div>

    <script>
        // Your JSON data
        const jsonData = {
            "data": [],
            "layout": {
                "template": {
                    "data": {
                        "histogram2dcontour": [{"type": "histogram2dcontour", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
                        "choropleth": [{"type": "choropleth", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
                        "histogram2d": [{"type": "histogram2d", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
                        "heatmap": [{"type": "heatmap", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
                        "contourcarpet": [{"type": "contourcarpet", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
                        "contour": [{"type": "contour", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
                        "surface": [{"type": "surface", "colorbar": {"outlinewidth": 0, "ticks": ""}, "colorscale": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]]}],
                        "mesh3d": [{"type": "mesh3d", "colorbar": {"outlinewidth": 0, "ticks": ""}}],
                        "scatter": [{"fillpattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}, "type": "scatter"}],
                        "parcoords": [{"type": "parcoords", "line": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scatterpolargl": [{"type": "scatterpolargl", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "bar": [{"error_x": {"color": "#2a3f5f"}, "error_y": {"color": "#2a3f5f"}, "marker": {"line": {"color": "#E5ECF6", "width": 0.5}, "pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "bar"}],
                        "scattergeo": [{"type": "scattergeo", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scatterpolar": [{"type": "scatterpolar", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "histogram": [{"marker": {"pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "histogram"}],
                        "scattergl": [{"type": "scattergl", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scatter3d": [{"type": "scatter3d", "line": {"colorbar": {"outlinewidth": 0, "ticks": ""}}, "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scattermap": [{"type": "scattermap", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scattermapbox": [{"type": "scattermapbox", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scatterternary": [{"type": "scatterternary", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "scattercarpet": [{"type": "scattercarpet", "marker": {"colorbar": {"outlinewidth": 0, "ticks": ""}}}],
                        "carpet": [{"aaxis": {"endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f"}, "baxis": {"endlinecolor": "#2a3f5f", "gridcolor": "white", "linecolor": "white", "minorgridcolor": "white", "startlinecolor": "#2a3f5f"}, "type": "carpet"}],
                        "table": [{"cells": {"fill": {"color": "#EBF0F8"}, "line": {"color": "white"}}, "header": {"fill": {"color": "#C8D4E3"}, "line": {"color": "white"}}, "type": "table"}],
                        "barpolar": [{"marker": {"line": {"color": "#E5ECF6", "width": 0.5}, "pattern": {"fillmode": "overlay", "size": 10, "solidity": 0.2}}, "type": "barpolar"}],
                        "pie": [{"automargin": true, "type": "pie"}]
                    },
                    "layout": {
                        "autotypenumbers": "strict",
                        "colorway": ["#636efa", "#EF553B", "#00cc96", "#ab63fa", "#FFA15A", "#19d3f3", "#FF6692", "#B6E880", "#FF97FF", "#FECB52"],
                        "font": {"color": "#2a3f5f"},
                        "hovermode": "closest",
                        "hoverlabel": {"align": "left"},
                        "paper_bgcolor": "white",
                        "plot_bgcolor": "#E5ECF6",
                        "polar": {"bgcolor": "#E5ECF6", "angularaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "radialaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}},
                        "ternary": {"bgcolor": "#E5ECF6", "aaxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "baxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}, "caxis": {"gridcolor": "white", "linecolor": "white", "ticks": ""}},
                        "coloraxis": {"colorbar": {"outlinewidth": 0, "ticks": ""}},
                        "colorscale": {
                            "sequential": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]],
                            "sequentialminus": [[0.0, "#0d0887"], [0.1111111111111111, "#46039f"], [0.2222222222222222, "#7201a8"], [0.3333333333333333, "#9c179e"], [0.4444444444444444, "#bd3786"], [0.5555555555555556, "#d8576b"], [0.6666666666666666, "#ed7953"], [0.7777777777777778, "#fb9f3a"], [0.8888888888888888, "#fdca26"], [1.0, "#f0f921"]],
                            "diverging": [[0, "#8e0152"], [0.1, "#c51b7d"], [0.2, "#de77ae"], [0.3, "#f1b6da"], [0.4, "#fde0ef"], [0.5, "#f7f7f7"], [0.6, "#e6f5d0"], [0.7, "#b8e186"], [0.8, "#7fbc41"], [0.9, "#4d9221"], [1, "#276419"]]
                        },
                        "xaxis": {"gridcolor": "white", "linecolor": "white", "ticks": "", "title": {"standoff": 15}, "zerolinecolor": "white", "automargin": true, "zerolinewidth": 2},
                        "yaxis": {"gridcolor": "white", "linecolor": "white", "ticks": "", "title": {"standoff": 15}, "zerolinecolor": "white", "automargin": true, "zerolinewidth": 2},
                        "scene": {
                            "xaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2},
                            "yaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2},
                            "zaxis": {"backgroundcolor": "#E5ECF6", "gridcolor": "white", "linecolor": "white", "showbackground": true, "ticks": "", "zerolinecolor": "white", "gridwidth": 2}
                        },
                        "shapedefaults": {"line": {"color": "#2a3f5f"}},
                        "annotationdefaults": {"arrowcolor": "#2a3f5f", "arrowhead": 0, "arrowwidth": 1},
                        "geo": {"bgcolor": "white", "landcolor": "#E5ECF6", "subunitcolor": "white", "showland": true, "showlakes": true, "lakecolor": "white"},
                        "title": {"x": 0.05},
                        "mapbox": {"style": "light"}
                    }
                },
                "images": [{"layer": "below", "opacity": 0.5, "sizex": 2, "sizey": 2, "sizing": "stretch", "source": "https://images.plot.ly/language-icons/api-home/python-logo.png", "x": 0, "xref": "x", "y": 100000.0, "yref": "y"}],
                "yaxis": {"range": [99995.0, 100000.0], "title": {"text": "title"}}
            }
        };

        // Create the plot
        Plotly.newPlot('plot', jsonData.data, jsonData.layout, {scrollZoom: true});

        // Comprehensive event logging
        const plotDiv = document.getElementById('plot');

        plotDiv.on('plotly_relayout', function(eventData) {
            logEvent('Relayout', eventData);
        });

        plotDiv.on('plotly_zoom', function(eventData) {
            logEvent('Zoom', eventData);
        });

        plotDiv.on('plotly_pan', function(eventData) {
            logEvent('Pan', eventData);
        });

        function logEvent(type, data) {
            const eventsDiv = document.getElementById('events');
            const timestamp = new Date().toLocaleTimeString();
            eventsDiv.innerHTML += `<p><strong>${timestamp} - ${type}:</strong> ${JSON.stringify(data)}</p>`;
            eventsDiv.scrollTop = eventsDiv.scrollHeight;
        }
    </script>
</body>
</html>

We're doing some really cool work syncing multiple figures while displaying images, for which zoom-scrolling is the best way to interact with the figure. However, the behavior described above is proving to be very limiting, and I suspect it is impacting others as well.

Happy to provide more context or test out potential solutions :)

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions