2026, Jan 13 21:00

Prevent overlapping lines in Plotly on categorical y-axes using per-cycle vertical jitter and tick relabeling

Fix overlapping Plotly lines on categorical y-axes by mapping to numeric rows, adding per-cycle vertical jitter, and restoring labels via tickvals/ticktext.

When categorical traces pile up on the same y value, even clean Plotly lines turn into a single overlapping stroke. A common case: you have a baseline column (Starting point) and want to draw small segments to multiple targets per category, color-coded by cycle. Without a vertical offset, all segments share identical y coordinates and visually merge. The goal is a slight vertical jitter per cycle so the shorter segment sits above the longer one.

Problem setup

The dataset contains a baseline and two destination columns for several categories. The naive approach loops over columns and index labels, building two-point traces per category. That produces the intended geometry, but with both cycles rendered at the same categorical y, they overlap.

import pandas as pd
import plotly.graph_objects as go
from itertools import cycle
# Data
dataset_tbl = pd.DataFrame({
    'Starting point': [0, 0, 0],
    'Walking': [8, 7, 5],
    'Biking': [4, 3, 2]
}, index=['Lunch', 'Shopping', 'Coffee'])
# Figure and styles
plot_box = go.Figure()
shade_iter = cycle(["#888888", "#E2062B"])  # two-cycle color rotation
glyph_iter = cycle(["diamond", "cross"])     # unused here, shown for parity
# Traces: for each column and index label, draw a segment from baseline to destination
for dest_key in dataset_tbl.columns:
    tone = next(shade_iter)
    for row_tag in dataset_tbl.index:
        plot_box.add_trace(
            go.Scatter(
                y=[row_tag] * len(dataset_tbl.loc[row_tag, ["Starting point", dest_key]]),
                x=dataset_tbl.loc[row_tag, ["Starting point", dest_key]],
                showlegend=False,
                mode="lines+markers",
                marker={
                    "color": tone,
                    "symbol": "diamond"
                },
            )
        )
plot_box.show()

What actually happens and why

A categorical y-axis maps each label to a single y coordinate. Both cycles per category therefore land on the same horizontal level. Since every segment for a given label shares that identical y, the strokes sit on top of each other. To separate them, the y values need to become numeric positions with small per-cycle offsets, and then the axis should be relabeled to the original category names. In short: adjust the y values, then set tickvalues to a category array.

Solution: numeric y positions with per-cycle offsets

A minimal fix is to convert category labels to integer positions via enumerate, apply a constant offset for each destination column, and finally re-map ticks back to the original labels. That preserves the categorical feel while giving full control over vertical spacing.

import pandas as pd
import plotly.graph_objects as go
from itertools import cycle
# Data
dataset_tbl = pd.DataFrame({
    'Starting point': [0, 0, 0],
    'Walking': [8, 7, 5],
    'Biking': [4, 3, 2]
}, index=['Lunch', 'Shopping', 'Coffee'])
# Figure and styles
plot_box = go.Figure()
shade_iter = cycle(["#888888", "#E2062B"])  # two-cycle color rotation
glyph_iter = cycle(["diamond", "cross"])     # unused here, shown for parity
# Horizontal offsets per destination column (jitter on y)
band_shift = {"Walking": 0.15, "Biking": -0.15}
# Only draw segments from baseline to these destinations
for dest_key in ["Walking", "Biking"]:
    tone = next(shade_iter)
    offset = band_shift[dest_key]
    for row_ix, row_tag in enumerate(dataset_tbl.index):
        y_lane = row_ix + offset
        plot_box.add_trace(
            go.Scatter(
                y=[y_lane, y_lane],
                x=[dataset_tbl.loc[row_tag, "Starting point"], dataset_tbl.loc[row_tag, dest_key]],
                showlegend=False,
                mode="lines+markers",
                marker=dict(
                    color=tone,
                    symbol="diamond"
                ),
                line=dict(color=tone)
            )
        )
# Restore categorical ticks
plot_box.update_yaxes(
    tickvals=list(range(len(dataset_tbl.index))),
    ticktext=list(dataset_tbl.index)
)
plot_box.show()

Why this detail matters

A small y offset resolves visual ambiguity without touching the underlying data. The shorter segment naturally appears above the longer one when offsets are applied consistently per cycle. Beyond clarity, this keeps interaction and hover behavior intact because each segment remains its own trace.

Takeaways

When plotting multiple cycles per category, categorical axes alone won’t prevent overlap. Map categories to numeric rows, nudge each cycle by a small offset, and then restore the category labels through tick configuration. That simple pattern produces clean, legible comparisons where relative segment lengths are immediately visible.