Automatically position and repel labels in plotly charts — the ggrepel experience for interactive plots
Overlapping labels are the curse of dense scatter plots. In static charts, ggrepel solves this beautifully. But the Plotly community has been asking for equivalent functionality in interactive charts since at least 2019 — see plotly.js#4674 — with no official solution.
plotly.repel fills that gap. It gives you the familiar
add_text_repel() / add_label_repel() API (ggrepel ergonomics you already know) and
runs a fast placement solver in the browser at render time. Labels stay readable through
zoom, pan, resize, and animation — because they're placed fresh every time the viewport
changes, within a tight millisecond budget that keeps interactions smooth.
# Install from GitHub
remotes::install_github("bigomics/plotly.repel")library(plotly)
library(plotly.repel)
df <- data.frame(
x = c(1, 1.05, 1.02, 0.98, 1.5, 2.0),
y = c(2, 2.02, 2.05, 1.97, 2.5, 3.0),
gene = c("TP53", "EGFR", "MYC", "PTEN", "BRCA1", "KRAS")
)
plot_ly(df, x = ~x, y = ~y, type = "scatter", mode = "markers") |>
add_text_repel(x = ~x, y = ~y, text = ~gene)Most defaults work well out of the box, but every knob is exposed when you need it:
add_text_repel(
p,
x = ~x, y = ~y, text = ~gene,
force = 1, # repulsion strength between labels
force_pull = 1, # spring pull toward each label's anchor point
box_padding = 0.35, # gap around the text box (data units)
point_padding = 0.2, # gap around the data point (data units)
direction = "both", # constrain repulsion: "both" | "x" | "y"
max_time_ms = 25, # solver time budget in ms — raise for dense charts
max_iter = 200, # hard iteration cap
max_overlaps = 10, # drop labels when this many overlaps remain unsolvable
seed = 42, # fix starting positions for reproducible output
on = c("render", "zoom", "resize") # re-solve on these events
)Plain text labels with a thin connector line from label to point. Drop-in equivalent
of ggrepel::geom_text_repel().
add_text_repel(p, x = ~x, y = ~y, text = ~label)Labels with a filled background box — equivalent of ggrepel::geom_label_repel().
Useful for labels that need to stand out against a busy background.
add_label_repel(
p,
x = ~x, y = ~y, text = ~label,
label = list(
bgcolor = "rgba(255, 255, 255, 0.9)",
bordercolor = "rgba(0, 0, 0, 0.25)",
borderwidth = 1,
borderpad = 3
)
)Pass a logical vector or an expression string to visibility to show only the labels you care about:
# Keep only high-priority hits (expression evaluated per-label in the browser)
add_text_repel(p, x = ~x, y = ~y, text = ~gene,
priority = ~abs(log2fc),
visibility = "priority > 1.5")
# Or pass a pre-computed logical vector from R
keep <- abs(df$log2fc) > 1.5
add_text_repel(p, x = ~x, y = ~y, text = ~gene, visibility = keep)When there are more labels than fit comfortably, priority controls which ones survive:
add_text_repel(p, x = ~x, y = ~y, text = ~gene,
priority = ~abs(log2fc),
max_overlaps = 5)add_text_repel(p, x = ~x, y = ~y, text = ~gene,
segment = list(color = "rgba(120, 120, 120, 0.6)", width = 1.5),
font = list(size = 13, color = "navy"))# Static: solve once at render, don't reposition on interaction
add_text_repel(p, x = ~x, y = ~y, text = ~gene, on = "render")
# Dynamic: re-solve on zoom and resize but not pan
add_text_repel(p, x = ~x, y = ~y, text = ~gene,
on = c("render", "zoom", "resize"))The mtcars vignette walks through a complete
example: starting from bare plotly markers, adding repelled labels, filtering with
visibility, tuning with priority, and polishing with styling options. Copy-paste
ready with the built-in mtcars dataset.
Because plotly charts are interactive, label positions must be recalculated whenever the
viewport changes. plotly.repel injects a small JavaScript solver that runs in the browser
at render time and re-solves on the events you configure with the on parameter.
Placement is constrained by a time budget (max_time_ms) to keep interactions snappy,
with an iteration cap (max_iter) as a safety net.
| Feature | ggrepel | plotly.repel |
|---|---|---|
| Interactive charts | ✗ | ✓ |
| Zoom / pan updates | ✗ | ✓ |
| Animation support | ✗ | ✓ |
| API style | geom_text_repel() |
add_text_repel() |
| Where placement runs | R (once) | Browser (per viewport) |
Requires modern browsers with JavaScript support:
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
GPL-3.0 © Santiago Cano Muniz