Skip to main content

repelly

PyPI Python versions CI License: GPL v3+

Repelling, non-overlapping text labels for Plotly in Python, in the spirit of R's ggrepel.

Volcano plot with repelled gene labels

Features

  • Labels are solved in the browser and re-solved on zoom, pan, resize and animation.
  • Labels stay clear of each other and of every marker in the subplot.
  • They are native Plotly annotations, so they appear in the modebar's PNG download.
  • Works with plotly.express, including facets, animations and scattergl.
  • Supports linear, log, date, category and reversed axes.
  • Runs in HTML files, the browser, notebooks and Dash, and exports to static images.

Installation

pip install repelly

Optional extras add static image export and Dash:

pip install "repelly[static]"
pip install "repelly[dash]"

Requires Python 3.10+ and Plotly 6.2+.

Quick start

import pandas as pd
import plotly.express as px
from repelly import add_text_repel

df = pd.DataFrame(
    {
        "x": [1, 1.05, 1.02, 0.98, 1.5, 2.0],
        "y": [2, 2.02, 2.05, 1.97, 2.5, 3.0],
        "gene": ["TP53", "EGFR", "MYC", "PTEN", "BRCA1", "KRAS"],
    }
)

fig = px.scatter(df, x="x", y="y")
fig = add_text_repel(fig, df.x, df.y, df.gene)
fig.show()

add_text_repel returns a copy of your figure as a RepelFigure, so keep the returned value. It is a go.Figure whose show, to_html, write_html, to_image and write_image methods include the labels. plotly.io.show(fig) bypasses them, so call the figure's own methods. If an operation hands back a plain go.Figure, as_repel_figure(fig) restores it.

Usage

Boxed labels

add_label_repel takes the same arguments and draws each label on a filled box:

from repelly import add_label_repel

fig = add_label_repel(fig, df.x, df.y, df.gene, label={"bgcolor": "lightyellow"})

Labelling existing traces

Omit x and y to label the points of scatter traces already in the figure. This is the simplest route for facets and animations. Labels hide when their trace is hidden from the legend, and each animation frame gets its own labels.

fig = px.scatter(df, x="gdpPercap", y="lifeExp", facet_col="continent", text="country")
fig.update_traces(mode="markers")  # repelly draws the text
fig = add_text_repel(fig)

fig = px.scatter(df, x="x", y="y", custom_data=["name"])
fig = add_text_repel(fig, text=lambda trace: trace.customdata[:, 0])

text defaults to each trace's text, then hovertext. Narrow the traces with selector, row and col.

Subplots

For explicit coordinates, choose the subplot with row and col, or xref and yref:

fig = add_text_repel(fig, x, y, names, row=1, col=2)

Dash

Call init_app once. Figures then work as normal, including as callback outputs:

from dash import Dash, dcc
import repelly.dash

app = Dash(__name__)
repelly.dash.init_app(app)
app.layout = dcc.Graph(figure=fig)

Each solve updates the graph's layout, so a callback listening to relayoutData also fires when labels are placed.

Notebooks

JupyterLab and VS Code default to a Plotly renderer that cannot run custom JavaScript, so repelly shows the figure in an iframe there. It loads Plotly from a CDN; for offline use call fig.show(include_plotlyjs=True), which embeds about 5 MB per output.

Static images

fig.write_image(...) and fig.to_image(...) solve the labels in headless Chrome, using Kaleido's browser or any installed Chrome, Chromium or Edge (set BROWSER_PATH to choose one). Positions are computed for the export size.

repelly.bake(fig, width=..., height=...) returns a plain figure with the positions fixed, if you need one to pass elsewhere.

Options

Argument Default Purpose
row, col, secondary_y, xref, yref primary axes Target subplot for explicit x and y
selector all traces Which traces to label when x and y are omitted
priority none Higher values survive culling first
visible all Boolean mask; hidden points still repel other labels
frame none Animation frame name per label, for explicit x and y
force 1.0 Separation strength, effective range 0.05 to 1
force_pull 1.0 Pull back towards the anchor
box_padding, point_padding 2, 2 Gaps in pixels
direction "both" Restrict movement to "x" or "y"
max_overlaps 10 Drop a label starting on more than this many higher-priority labels; None keeps all
max_labels none Maximum labels per subplot, by priority
max_iter, max_time_ms 400, 40 Solver limits for one solve
seed 42 Makes placement reproducible
min_segment_length 6 Hide the connector below this distance in pixels
avoid_points True Keep labels off every marker in the subplot
drop_unresolved False Hide labels that still overlap after solving
font, segment Plotly defaults Styling; segment takes color, width, arrowhead, arrowsize
on all events Re-solve on any of "zoom", "resize", "frame"; the first render always solves

Limitations

  • Only cartesian scatter and scattergl traces can be labelled from traces.
  • FigureWidget is not supported.
  • Configuration lives in layout.meta, so that must be unset or a dict. Replacing it afterwards removes the labels.
  • Solving freezes autoranged axes at their current range, because annotations would otherwise change the range they were placed in. Double-click still resets the view.

Development

The project uses pixi. Browser tests need Chrome or Edge installed.

pixi install
pixi run hooks      # format and lint before each commit
pixi run lint       # ruff format and ruff check --fix
pixi run test-all   # Python and JavaScript tests
pixi run -e min test-all   # against the oldest supported dependencies
pixi run check-dist # build and validate the package

If the commit hook reformats files, stage them and commit again.

To release, update __version__ in src/repelly/__init__.py and the matching VERSION in src/repelly/js/runtime.js, then publish a GitHub release tagged vX.Y.Z.

Acknowledgements

repelly brings the ideas of two R packages to Python:

  • ggrepel by Kamil Slowikowski and contributors, the original repelling-label package.
  • plotly.repel by Santiago Cano Muniz, which pioneered browser-side solving for Plotly and the add_text_repel interface.

See NOTICE for full credits.

License

Copyright (C) 2026 Moritz Peist. Licensed under the GNU General Public License, version 3 or later.

Interactive output embeds repelly's GPL-licensed JavaScript, so HTML files and Dash pages you distribute contain GPL code. The script keeps its license notice for this purpose. Static images and baked figures do not contain it.

Metadata

Release files for repelly 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for repelly 0.1.0
File Size Uploaded Provenance Actions
repelly-0.1.0.tar.gz 50.3 kB With provenance Details for repelly-0.1.0.tar.gz

Built distribution (wheel)

Table of built distributions (wheels) for repelly 0.1.0
File Interpreter ABI Platform Actions
repelly-0.1.0-py3-none-any.whl Python 3 none any Details for repelly-0.1.0-py3-none-any.whl

Total release size: 93.7 kB

Release files / repelly-0.1.0.tar.gz

Download URL repelly-0.1.0.tar.gz
Size 50.3 kB
Tags Source
SHA-256 checksum
How to use checksums
c3cd22b1fc8b176555e346b3fa9c2b7bf77489262c666407c67bacfc1be0042f
BLAKE2b-256 checksum
How to use checksums
100ef3b651fc8d210683a0eeb55ea7fd1a7b8c2d1f1e4f7606fb4cd442295f4e
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Release files / repelly-0.1.0-py3-none-any.whl

Download URL repelly-0.1.0-py3-none-any.whl
Size 43.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
0b6d9c42f821c3a10a53657a9b101a100739926ada3f42ff9d2c9364a5cc55aa
BLAKE2b-256 checksum
How to use checksums
0b9046e42a7b4cca7a503eafbc27b7a102a203a64d745e0c52c29f3160fb6e55
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

Security for repelly 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

Both files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for repelly-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Nov 9, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page