Basic Dumbbell Chart — lets-plot

A dumbbell chart (also called a connected dot plot or Cleveland dot plot) compares two values for each category by displaying two dots connected by a line. It effectively visualizes differences, changes, or ranges between two data points such as before/after comparisons, gaps, or min/max values. The connected dots make it easy to see both the magnitude and direction of change.

Basic Dumbbell Chart rendered with lets-plot

Python source (lets-plot)

""" anyplot.ai
dumbbell-basic: Basic Dumbbell Chart
Library: letsplot 4.11.0 | Python 3.13.14
Quality: 91/100 | Updated: 2026-06-30
"""

import os

import pandas as pd
from lets_plot import (
    LetsPlot,
    aes,
    element_blank,
    element_line,
    element_rect,
    element_text,
    geom_point,
    geom_segment,
    ggplot,
    ggsize,
    labs,
    layer_tooltips,
    scale_color_manual,
    scale_x_continuous,
    scale_y_continuous,
    theme,
    theme_minimal,
)
from lets_plot.export import ggsave


LetsPlot.setup_html()

# Theme tokens
THEME = os.getenv("ANYPLOT_THEME", "light")
PAGE_BG = "#FAF8F1" if THEME == "light" else "#1A1A17"
ELEVATED_BG = "#FFFDF6" if THEME == "light" else "#242420"
INK = "#1A1A17" if THEME == "light" else "#F0EFE8"
INK_SOFT = "#4A4A44" if THEME == "light" else "#B8B7B0"
RULE = "rgba(26,26,23,0.10)" if THEME == "light" else "rgba(240,239,232,0.10)"

# Imprint palette — position 1 = After (hero), position 2 = Before, position 5 = semantic decline
BRAND = "#009E73"  # position 1 — After scores / positive change direction
LAVENDER = "#C475FD"  # position 2 — Before scores
DECLINE = "#AE3030"  # position 5 — semantic red for regressions

# Data — Employee satisfaction scores before and after policy changes
categories = [
    "Engineering",
    "Marketing",
    "Sales",
    "Customer Support",
    "HR",
    "Finance",
    "Operations",
    "Product",
    "Legal",
    "R&D",
]
before_scores = [62, 58, 71, 55, 68, 64, 72, 73, 66, 61]
after_scores = [78, 72, 85, 80, 81, 70, 65, 88, 67, 75]

df = pd.DataFrame({"category": categories, "before": before_scores, "after": after_scores})
df["diff"] = df["after"] - df["before"]
df = df.sort_values("diff", ascending=True).reset_index(drop=True)
df["y_pos"] = range(len(df))

df_improved = df[df["diff"] > 0]
df_declined = df[df["diff"] <= 0]

df_points = pd.concat(
    [
        pd.DataFrame(
            {
                "y_pos": df["y_pos"],
                "value": df["before"],
                "period": "Before",
                "category": df["category"].values,
                "diff": df["diff"].values,
            }
        ),
        pd.DataFrame(
            {
                "y_pos": df["y_pos"],
                "value": df["after"],
                "period": "After",
                "category": df["category"].values,
                "diff": df["diff"].values,
            }
        ),
    ]
)

# Scale title fontsize for longer-than-baseline title (floor: 11px)
title = "Employee Satisfaction · dumbbell-basic · python · letsplot · anyplot.ai"
n = len(title)
title_size = max(11, round(16 * 67 / n)) if n > 67 else 16

# Plot — horizontal dumbbell; segments color-coded by change direction
plot = (
    ggplot()
    + geom_segment(
        data=df_improved,
        mapping=aes(x="before", xend="after", y="y_pos", yend="y_pos"),
        color=BRAND,
        size=1.2,
        alpha=0.65,
    )
    + geom_segment(
        data=df_declined,
        mapping=aes(x="before", xend="after", y="y_pos", yend="y_pos"),
        color=DECLINE,
        size=1.2,
        alpha=0.65,
    )
    + geom_point(
        data=df_points,
        mapping=aes(x="value", y="y_pos", color="period"),
        size=5,
        tooltips=layer_tooltips().line("@category").line("@period: @value").line("Change: @diff"),
    )
    + scale_color_manual(values=[BRAND, LAVENDER], name="Period")
    + scale_x_continuous(limits=[50, 95])
    + scale_y_continuous(breaks=list(range(len(df))), labels=df["category"].tolist())
    + labs(x="Satisfaction Score", y="Department", title=title)
    + ggsize(800, 450)
    + theme_minimal()
    + theme(
        plot_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        panel_background=element_rect(fill=PAGE_BG, color=PAGE_BG),
        legend_background=element_rect(fill=ELEVATED_BG, color="transparent"),
        panel_grid_major_x=element_line(color=RULE, size=0.3),
        panel_grid_minor_x=element_blank(),
        panel_grid_major_y=element_blank(),
        panel_grid_minor_y=element_blank(),
        axis_line=element_line(color=INK_SOFT),
        axis_ticks=element_blank(),
        axis_title=element_text(size=12, color=INK),
        axis_text=element_text(size=10, color=INK_SOFT),
        plot_title=element_text(size=title_size, color=INK, hjust=0.5),
        legend_title=element_text(size=10, color=INK),
        legend_text=element_text(size=10, color=INK_SOFT),
        legend_position=[0.92, 0.2],
        legend_justification=[1, 0],
    )
)

# Save
ggsave(plot, filename=f"plot-{THEME}.png", path=".", scale=4)
ggsave(plot, filename=f"plot-{THEME}.html", path=".")

Part of Basic Dumbbell Chart on anyplot.ai.

Other implementations