Skip to contents

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 live in vignettes/d3-drawing-diagnostics.md.

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. See vignettes/d3-drawing-diagnostics.md for the detailed v1.13 release validation commands and FUT deferrals.

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()

Bars and columns

# geom_bar (counts)
(ggplot(mpg, aes(class, fill = class)) +
  geom_bar()) |>
  gg2d3()

# geom_col (values) with stacking
(ggplot(mpg, aes(class, fill = drv)) +
  geom_bar(position = "stack")) |>
  gg2d3()

Rectangles, tiles, and text

# Heatmap with geom_tile
(ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
  geom_tile()) |>
  gg2d3()

# Text labels
(ggplot(mtcars, aes(wt, mpg, label = rownames(mtcars))) +
  geom_text(size = 3)) |>
  gg2d3()

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. See vignettes/d3-drawing-diagnostics.md for detailed caveats.

Area and ribbon

# Area chart
(ggplot(economics, aes(date, unemploy)) +
  geom_area(fill = "steelblue", alpha = 0.5)) |>
  gg2d3()

# Ribbon (confidence band)
(ggplot(economics, aes(date, unemploy)) +
  geom_ribbon(aes(ymin = unemploy - 500, ymax = unemploy + 500),
              alpha = 0.3) +
  geom_line()) |>
  gg2d3()

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-anchor geom_sf_text() and geom_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 geometries and 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. See vignettes/d3-drawing-diagnostics.md for the detailed residual-risk list.

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.

# Secondary axis showing unemployment in millions
(ggplot(economics, aes(date, unemploy)) +
  geom_line() +
  scale_y_continuous(
    name = "Unemployment (thousands)",
    sec.axis = sec_axis(~ . / 1000, name = "Millions")
  )) |>
  gg2d3()

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.

# Default: dim others to 30% opacity
gg2d3(p) |> d3_hover()

# 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.

# Default: zoom both axes, 1x to 8x
gg2d3(p) |> d3_zoom()

# Zoom x-axis only, up to 20x
gg2d3(p) |> d3_zoom(direction = "x", scale_extent = c(1, 20))

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.

# Default: 2D brush with blue overlay
gg2d3(p) |> d3_brush()

# Horizontal brush only
gg2d3(p) |> d3_brush(direction = "x")

# 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:

  1. Non-finite valuesNA, NaN, and Inf are 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.
  2. Unsupported geoms — when a geom type has no D3 renderer, gg2d3 emits a browser-console warning instead of rendering marks for that layer.
  3. 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 + and gg2d3() 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.width and fig.height or pass width/height to gg2d3().
  • Performance: For large datasets (>10k points), consider using alpha to reduce overdraw and limit interactivity features to what you need.