Basic Raincloud Plot — Plotly

A raincloud plot combines three visualization elements—a half-violin (the "cloud"), jittered strip points (the "rain"), and a box plot—to provide a comprehensive view of data distribution. This hybrid approach shows distribution shape, summary statistics, and individual observations simultaneously, addressing the limitation of box plots that can hide multimodal distributions. Often called the "gold standard" for transparent statistical visualization in scientific publications.

Basic Raincloud Plot rendered with Plotly

Python source (Plotly)

""" anyplot.ai
raincloud-basic: Basic Raincloud Plot
Library: plotly 6.7.0 | Python 3.13.13
Quality: 89/100 | Updated: 2026-05-26
"""

import os

import numpy as np
import plotly.graph_objects as go


# 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"
GRID = "rgba(26,26,23,0.15)" if THEME == "light" else "rgba(240,239,232,0.15)"

# anyplot categorical palette — first series is always brand green
IMPRINT_PALETTE = ["#009E73", "#C475FD", "#4467A3", "#BD8233"]

# Data — reaction times (ms) across experimental conditions
np.random.seed(42)
conditions = ["Control", "Treatment A", "Treatment B", "Treatment C"]
n_per_group = 80

data = {
    "Control": np.random.normal(450, 60, n_per_group),
    "Treatment A": np.random.normal(380, 45, n_per_group),
    "Treatment B": np.concatenate(
        [np.random.normal(350, 30, n_per_group // 2), np.random.normal(480, 35, n_per_group // 2)]
    ),
    "Treatment C": np.random.normal(400, 80, n_per_group),
}
data["Control"] = np.append(data["Control"], [620, 650, 280])
data["Treatment C"] = np.append(data["Treatment C"], [600, 620, 250])

# X-axis range from data with padding
all_values = np.concatenate(list(data.values()))
x_min, x_max = all_values.min(), all_values.max()
x_padding = (x_max - x_min) * 0.05
x_range = [x_min - x_padding, x_max + x_padding]

# Plot
fig = go.Figure()

cloud_width = 0.7
box_width = 0.10
rain_offset = -0.22
rain_jitter_amp = 0.08

for i, (condition, values) in enumerate(data.items()):
    color = IMPRINT_PALETTE[i]
    y_base = np.full(len(values), i)

    # Cloud — half-violin extending upward above the category baseline
    fig.add_trace(
        go.Violin(
            x=values,
            y=y_base,
            side="positive",
            width=cloud_width,
            line_color=color,
            fillcolor=color,
            opacity=0.55,
            meanline_visible=False,
            box_visible=False,
            points=False,
            name=condition,
            legendgroup=condition,
            showlegend=False,
            hoveron="violins",
            hoverinfo="x+name",
            orientation="h",
            scalemode="width",
        )
    )

    # Box plot — centered on the category baseline, outline tinted with category color
    fig.add_trace(
        go.Box(
            x=values,
            y=y_base,
            width=box_width,
            marker_color=color,
            line={"color": color, "width": 1.6},
            fillcolor=ELEVATED_BG,
            boxpoints=False,
            name=condition,
            legendgroup=condition,
            showlegend=False,
            orientation="h",
            hoverinfo="skip",
        )
    )

    # Rain — idiomatic scatter falling downward below the baseline
    rng = np.random.default_rng(42 + i)
    y_jitter = rng.uniform(-rain_jitter_amp, rain_jitter_amp, size=len(values))
    fig.add_trace(
        go.Scatter(
            x=values,
            y=i + rain_offset + y_jitter,
            mode="markers",
            marker={"size": 6, "color": color, "opacity": 0.6, "line": {"width": 0.5, "color": PAGE_BG}},
            name=condition,
            legendgroup=condition,
            showlegend=False,
            hovertemplate=f"<b>{condition}</b><br>%{{x:.0f}} ms<extra></extra>",
        )
    )

# Title — length-scaled fontsize (baseline 16px @ 67 chars)
title = "raincloud-basic · python · plotly · anyplot.ai"
title_fontsize = max(11, round(16 * 67 / len(title))) if len(title) > 67 else 16

# Layout
fig.update_layout(
    autosize=False,
    width=800,
    height=450,
    margin={"l": 110, "r": 40, "t": 60, "b": 60},
    paper_bgcolor=PAGE_BG,
    plot_bgcolor=PAGE_BG,
    title={
        "text": title,
        "font": {"size": title_fontsize, "color": INK},
        "x": 0.5,
        "xanchor": "center",
        "y": 0.96,
        "yanchor": "top",
    },
    font={"color": INK},
    yaxis={
        "title": {"text": "Experimental Condition", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 11, "color": INK_SOFT},
        "tickmode": "array",
        "tickvals": list(range(len(conditions))),
        "ticktext": conditions,
        "range": [-0.45, len(conditions) - 0.45],
        "showgrid": False,
        "zeroline": False,
        "showline": False,
        "ticks": "",
    },
    xaxis={
        "title": {"text": "Reaction Time (ms)", "font": {"size": 12, "color": INK}},
        "tickfont": {"size": 11, "color": INK_SOFT},
        "gridcolor": GRID,
        "gridwidth": 1,
        "range": x_range,
        "zeroline": False,
        "showline": True,
        "linecolor": INK_SOFT,
        "linewidth": 1,
    },
    violingap=0,
    violinmode="overlay",
    showlegend=False,
    hoverlabel={"bgcolor": ELEVATED_BG, "bordercolor": INK, "font": {"size": 11, "color": INK}},
)

# Save PNG (static) at the canonical landscape canvas: 3200×1800
fig.write_image(f"plot-{THEME}.png", width=800, height=450, scale=4)

# Save HTML with range slider for interactive exploration
fig.update_xaxes(rangeslider={"visible": True, "thickness": 0.05})
fig.write_html(f"plot-{THEME}.html", include_plotlyjs="cdn")

Part of Basic Raincloud Plot on anyplot.ai.

Other implementations