gg2d3 renders ggplot2 plots as interactive D3.js SVG widgets in the
browser. Pass any ggplot object to gg2d3() and get an
htmlwidget you can view in RStudio, R Markdown, Shiny, or any web
page.
Basic usage
library(ggplot2)
library(gg2d3)
p <- ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
geom_point() +
ggtitle("Motor Trend Cars")
gg2d3(p)The widget size defaults to the viewer/container size. Override with
width and height (in pixels or CSS units):
gg2d3(p, width = 800, height = 500)Supported geoms
gg2d3 supports the core Cartesian geoms below, ordinary
geom_polygon(), polygon-family, point-family, and
line-family geom_sf(), plus projected-anchor
geom_sf_text() and geom_sf_label()
annotations. All aesthetics that ggplot2 maps (color, fill, size, shape,
alpha, linewidth) are carried through to D3. Detailed geometry caveats
are described in the public support sections below.
v1.13 validation and caveat summary
The v1.13 support contract is source-first and intentionally bounded.
Browser visual validation uses the dedicated browser visual smoke
workflow and the same test_output/browser-visual-smoke/
artifact layout documented in diagnostics; local runs may skip cleanly
when optional browser tooling is unavailable, while CI mode treats
browser-level skips as failures. Renderer and interactivity wiring are
guarded by geom-contracts.js, and representative IR
helper-boundary tests cover the selected theme and geom-parameter helper
boundaries without claiming full as_d3_ir()
modularization.
Geometry caveats are also explicit. Ordinary
geom_label() support covers bounded SVG label boxes and
small placement fields. Ordinary geom_polygon() support
remains a grouped closed-path renderer, not topology or hole repair.
geom_rect() and geom_tile() use ggplot2-built
transformed bounds and filter non-finite transformed SVG bounds before
drawing. Collision avoidance, rich text, path-following text, broad
GIS-style topology repair, committed pixel thresholds, and generated
renderer reference docs remain future work.
Points, lines, and paths
# Scatter plot
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3)) |>
gg2d3()
# Line chart (connects points in x order)
(ggplot(economics, aes(date, unemploy)) +
geom_line()) |>
gg2d3()
# Path (connects points in data order)
(ggplot(data.frame(x = cos(seq(0, 2 * pi, length.out = 60)),
y = sin(seq(0, 2 * pi, length.out = 60))),
aes(x, y)) +
geom_path() +
coord_fixed()) |>
gg2d3()Rectangles, tiles, and text
Ordinary polygons
Ordinary geom_polygon() renders each group as a grouped
closed SVG path while preserving ggplot2’s built row order. Fill,
stroke, alpha, linewidth, linetype, facets, zoom/update behavior, and
the existing tooltip, hover, brush, handler, and linked-view hooks are
supported at the polygon path level.
poly <- data.frame(
id = rep(c("a", "b"), each = 4),
x = c(0, 1, 1.2, 0, 1.5, 2.6, 2.2, 1.3),
y = c(0, 0.2, 1, 0.8, 0.1, 0.4, 1.2, 0.9)
)
(ggplot(poly, aes(x, y, group = id, fill = id)) +
geom_polygon(color = "white", linewidth = 0.4, alpha = 0.8) +
coord_fixed()) |>
gg2d3()This is a grouped-path contract, not a GIS topology engine: topology/hole repair outside clean ggplot2 built groups is deferred.
Area and ribbon
Segments and reference lines
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
geom_hline(yintercept = 20, linetype = "dashed", color = "red") +
geom_vline(xintercept = 3, linetype = "dotted", color = "blue")) |>
gg2d3()geom_segment and geom_abline are also
supported.
Statistical geoms
These geoms are pre-computed in R (via ggplot2’s stat system) and rendered by D3. No JavaScript statistics are needed.
# Boxplot
(ggplot(mpg, aes(class, hwy)) +
geom_boxplot()) |>
gg2d3()
# Violin
(ggplot(mpg, aes(class, hwy, fill = class)) +
geom_violin()) |>
gg2d3()
# Density
(ggplot(diamonds, aes(price, fill = cut)) +
geom_density(alpha = 0.5)) |>
gg2d3()
# Smooth (loess or lm)
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
geom_smooth(method = "loess")) |>
gg2d3()
#> `geom_smooth()` using formula = 'y ~ x'sf family maps with geom_sf
geom_sf() supports polygon-family (POLYGON,
MULTIPOLYGON), point-family (POINT,
MULTIPOINT), and line-family (LINESTRING,
MULTILINESTRING) geometries. Polygon-family choropleths and
overlays render as D3 path marks; point-family rows render
as .geom-sf-point marks; and line-family rows render as
.geom-sf-line paths. geom_sf_text() and
geom_sf_label() render labels at projected anchors aligned
with those accepted sf families. This example uses the nc
shapefile bundled with sf and renders county boundaries as
D3 path marks.
has_sf <- requireNamespace("sf", quietly = TRUE)
has_geojsonsf <- requireNamespace("geojsonsf", quietly = TRUE)
missing_sf_packages <- c(
if (!has_sf) "sf",
if (!has_geojsonsf) "geojsonsf"
)
if (length(missing_sf_packages) > 0) {
cat(
"PKGDOWN_SF_OPTIONAL_SKIP: sf example not rendered; missing ",
paste(missing_sf_packages, collapse = ", "),
".\n",
sep = ""
)
} else {
sf_pkg <- asNamespace("sf")
nc <- sf_pkg$st_read(system.file("shape/nc.shp", package = "sf"), quiet = TRUE)
(ggplot(nc, aes(fill = AREA)) +
geom_sf(color = "white", linewidth = 0.2) +
scale_fill_gradient(low = "#eff3ff", high = "#08519c") +
labs(fill = "Area")) |>
gg2d3() |>
d3_tooltip(fields = c("NAME", "AREA")) |>
d3_hover(opacity = 0.35, stroke = "#111827", stroke_width = 1.5) |>
d3_brush(fill = "#f59e0b", opacity = 0.2)
}The geom_sf() support contract is intentionally
explicit:
- Accepted families are polygon-family (
POLYGON,MULTIPOLYGON), point-family (POINT,MULTIPOINT), and line-family (LINESTRING,MULTILINESTRING), including projected-anchorgeom_sf_text()andgeom_sf_label()annotations for those families. - known CRS inputs are normalized to WGS84 in R before serialization.
- Missing CRS emits
geom_sf layer has missing CRS; coordinates will be serialized as-is. - Rows that are unsupported, empty, invalid, or missing emit
geom_sf layer skipped %d unsupported, empty, invalid, or missing geometriesand are skipped while accepted rows remain renderable. - Optional browser validation is R/testthat/chromote based and may skip cleanly; when available, it covers sf family interactivity, stacked overlays, faceted and empty panels, projected anchor placement, sanitized interactivity payloads, and zoom suppression.
- gg2d3 does not provide tile basemaps, slippy map controls, JavaScript-side CRS reprojection, true geometry-overlap brushing, or large-map performance guarantees.
- sf annotations do not provide ggrepel collision avoidance, rich text, rotation parity, or path-following placement.
Scales
Continuous transforms
Log, sqrt, and reverse transforms work as expected:
(ggplot(diamonds, aes(carat, price)) +
geom_point(alpha = 0.1) +
scale_y_log10()) |>
gg2d3()Date and datetime scales
Date and POSIXct columns are automatically detected and rendered with
temporal D3 scales. Axis tick labels use the format from ggplot2’s
date_labels argument:
df <- data.frame(
date = seq(as.Date("2024-01-01"), as.Date("2024-12-31"), by = "month"),
value = cumsum(rnorm(12))
)
(ggplot(df, aes(date, value)) +
geom_line() +
geom_point() +
scale_x_date(date_labels = "%b %Y")) |>
gg2d3()POSIXct (datetime) works the same way:
df <- data.frame(
time = as.POSIXct("2024-01-01") + (0:23) * 3600,
temp = 15 + 5 * sin(seq(0, 2 * pi, length.out = 24)) + rnorm(24, sd = 0.5)
)
(ggplot(df, aes(time, temp)) +
geom_line() +
scale_x_datetime(date_labels = "%H:%M")) |>
gg2d3()Timezone information from
scale_x_datetime(timezone = ...) is preserved in
tooltips.
Secondary axes
Secondary axes are fully rendered — ticks, labels, and the axis title
from sec_axis() all appear on the opposite side of the
panel.
Color scales
Continuous color and fill aesthetics render as a true colorbar legend (a gradient with axis ticks), not a stack of discrete keys. Discrete palettes — viridis, brewer, manual — produce identical hex codes to ggplot2’s own output.
# Viridis continuous → colorbar legend
(ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
geom_tile() +
scale_fill_viridis_c()) |>
gg2d3()
# Brewer discrete
(ggplot(mpg, aes(displ, hwy, color = class)) +
geom_point() +
scale_color_brewer(palette = "Set2")) |>
gg2d3()
# Manual
(ggplot(mtcars, aes(wt, mpg, color = factor(cyl))) +
geom_point(size = 3) +
scale_color_manual(values = c("4" = "#1b9e77", "6" = "#d95f02", "8" = "#7570b3"))) |>
gg2d3()Coordinates
coord_flip
Swaps x and y axes. All geoms and scales adapt automatically:
(ggplot(mpg, aes(class, hwy)) +
geom_boxplot() +
coord_flip()) |>
gg2d3()coord_fixed
Enforces a fixed aspect ratio between x and y units:
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
coord_fixed(ratio = 1)) |>
gg2d3()Faceting
facet_wrap
Wraps panels into rows by one or more variables:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_wrap(~class)) |>
gg2d3()With free scales:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_wrap(~class, scales = "free")) |>
gg2d3()facet_grid
Lays out panels in a grid defined by row and column variables:
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_grid(drv ~ cyl)) |>
gg2d3()Free scales work per-row ("free_y") or per-column
("free_x"):
(ggplot(mpg, aes(displ, hwy)) +
geom_point() +
facet_grid(drv ~ cyl, scales = "free")) |>
gg2d3()Legends
Legends are generated automatically from mapped aesthetics. All standard legend types are supported:
- Discrete color/fill — color swatches with labels
- Continuous colorbar — gradient bar for continuous color/fill scales
- Size — graduated circles
- Shape — different point shapes
- Alpha — opacity levels
Legends can be positioned with
theme(legend.position = ...):
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point() +
theme(legend.position = "bottom")) |>
gg2d3()Use theme(legend.position = "none") to hide legends
entirely.
When multiple aesthetics share the same variable, guides are merged into a single legend automatically.
Theming
gg2d3 translates ggplot2 theme elements to SVG styling:
(ggplot(mtcars, aes(wt, mpg)) +
geom_point() +
theme_minimal() +
ggtitle("Minimal theme") +
labs(subtitle = "Rendered with D3", caption = "Source: mtcars")) |>
gg2d3()Theme elements that are translated include:
- Plot, panel, and legend backgrounds
- Major and minor grid lines
- Axis lines, ticks, and text
- Plot title, subtitle, and caption
- Legend title and text styling
Interactivity
gg2d3 provides a composable pipe-based API for adding interactivity. Each function takes a widget and returns a widget, so they chain naturally:
(ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 3)) |>
gg2d3() |>
d3_tooltip() |>
d3_hover() |>
d3_zoom() |>
d3_brush()You can use any combination — they are all optional and independent.
Tooltips
d3_tooltip() shows data values on hover. By default it
displays all mapped aesthetics.
# Default: show all aesthetics
gg2d3(p) |> d3_tooltip()
# Show specific fields only
gg2d3(p) |> d3_tooltip(fields = c("wt", "mpg"))
# Custom JavaScript formatter
gg2d3(p) |> d3_tooltip(formatter = "function(d) { return d.mpg + ' mpg'; }")Tooltips automatically format date/datetime values using the browser’s locale.
Hover highlighting
d3_hover() dims non-hovered elements so the hovered
group stands out.
# Softer dimming + highlight stroke
gg2d3(p) |> d3_hover(opacity = 0.5, stroke = "black", stroke_width = 2)When a brush selection is active, hover highlighting is automatically disabled to avoid visual conflicts.
Zoom and pan
d3_zoom() enables scroll-to-zoom and drag-to-pan.
Double-click resets to the original view.
Axes update dynamically during zoom. Temporal axes preserve their date formatting.
Brush selection
d3_brush() lets users drag to select a rectangular
region. Selected elements stay at full opacity while others dim.
# Custom callback receiving selected data
gg2d3(p) |> d3_brush(
on_brush = "function(data) { console.log(data.length + ' points selected'); }"
)Double-click clears the brush selection.
Linked views with Crosstalk
gg2d3 supports crosstalk for linking multiple widgets. Brushing in one widget highlights the same observations in all linked widgets.
has_crosstalk <- requireNamespace("crosstalk", quietly = TRUE)
has_htmltools <- requireNamespace("htmltools", quietly = TRUE)
if (!has_crosstalk || !has_htmltools) {
missing_crosstalk_packages <- c(
if (!has_crosstalk) "crosstalk",
if (!has_htmltools) "htmltools"
)
cat(
"PKGDOWN_CROSSTALK_OPTIONAL_SKIP: linked-view example not rendered; missing ",
paste(missing_crosstalk_packages, collapse = ", "),
".\n",
sep = ""
)
} else {
shared <- crosstalk::SharedData$new(
iris,
key = rownames(iris),
group = "pkgdown_crosstalk_iris"
)
p1 <- ggplot(iris, aes(Sepal.Length, Sepal.Width, color = Species)) +
geom_point(size = 2)
p1$data <- shared
p2 <- ggplot(iris, aes(Petal.Length, Petal.Width, color = Species)) +
geom_point(size = 2)
p2$data <- shared
w1 <- gg2d3(p1) |> d3_tooltip() |> d3_brush()
w2 <- gg2d3(p2) |> d3_tooltip() |> d3_brush()
htmltools::tagList(
htmltools::div(
style = paste(
"display: grid;",
"grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));",
"gap: 1rem;"
),
w1,
w2
)
)
}Crosstalk works in static HTML documents — no Shiny server required.
Combining features
A realistic example combining multiple features:
# warning = FALSE: loess emits "neighborhood too small" / "pseudoinverse"
# notes when fit per (class × year) — some classes have <4 points per
# facet. The fit still renders; the messages are expected for this demo.
(ggplot(mpg, aes(displ, hwy, color = class)) +
geom_point(size = 2) +
geom_smooth(method = "loess", se = TRUE) +
facet_wrap(~year) +
scale_color_brewer(palette = "Set2") +
labs(
title = "Engine displacement vs highway MPG",
subtitle = "By vehicle class and model year",
x = "Displacement (L)",
y = "Highway MPG"
) +
theme_minimal()) |>
gg2d3() |>
d3_tooltip() |>
d3_hover() |>
d3_zoom()Error handling and edge cases
gg2d3 provides three observable guarantees when data or geoms fall outside normal rendering scope:
-
Non-finite values —
NA,NaN, andInfare filtered from each layer with a single R warning per layer. Remaining finite points render normally; line/path geoms show a visible gap where the non-finite rows were removed. - Unsupported geoms — when a geom type has no D3 renderer, gg2d3 emits a browser-console warning instead of rendering marks for that layer.
-
R-level errors during build — if
ggplot_build()itself errors (e.g., incompatible stat/geom combinations), the error is surfaced as an R condition before any D3 rendering is attempted.
# Non-finite values: filtered with a single warning per layer;
# remaining points render with visible gaps
df <- data.frame(x = 1:10, y = c(1:4, NA, 6:9, NaN))
(ggplot(df, aes(x, y)) +
geom_point() +
geom_line()) |>
gg2d3()
# Warning: Removed 2 rows containing non-finite values (geom_point).
# The geom_line connects the finite segments and shows a visible gap.
# Ordinary polygons: grouped closed paths with row-order preservation
poly_edges <- data.frame(
group = rep(c("left", "right"), each = 4),
x = c(0, 1, 1, 0, 1.4, 2.4, 2.1, 1.2),
y = c(0, 0, 1, 0.8, 0.1, 0.2, 1, 0.9)
)
(ggplot(poly_edges, aes(x, y, group = group, fill = group)) +
geom_polygon(color = "grey35", linewidth = 0.4, alpha = 0.75) +
coord_fixed()) |>
gg2d3()
# topology/hole repair beyond grouped closed paths remains outside the shipped
# support contract.Tips
-
Pipe from ggplot directly: wrap the ggplot
expression in parentheses so
+resolves before|>:(ggplot(...) + geom_point()) |> gg2d3(). Without the parens,|>binds tighter than+andgg2d3()receives only the last geom — not the plot. The cleaner alternative is to assign first:p <- ggplot(...) + geom_point(); gg2d3(p). -
Inspect the IR: Use
gg2d3:::as_d3_ir(p)to see exactly what data is sent to D3. Useful for debugging unexpected rendering. -
Widget sizing: In R Markdown, set chunk options
fig.widthandfig.heightor passwidth/heighttogg2d3(). -
Performance: For large datasets (>10k points),
consider using
alphato reduce overdraw and limit interactivity features to what you need.