Skip to contents

A scale is the function that turns a data value into something you can see: a colour, a marker size, a shape, or a position along an axis. In vellumplot every mapped encoding gets a scale automatically, trained from the data, so most plots need no explicit scale at all. You reach for a scale_*() call only when you want to override the default: change the palette, set a transform, fix the limits, or rename the guide.

The key idea is training. When you map color = hp, vellumplot scans the hp values across every layer, works out the domain, and builds a continuous colour scale with a legend. Add a scale_*() and you are declaring an override on top of that trained default, not replacing the whole machine.

Colour, continuous and discrete

Continuous data get a smooth colour ramp; discrete data get a categorical palette. The palette argument takes a vector of colours or a single palette name passed to grDevices::hcl.colors() (for example "Batlow", "Blues", "Set 2").

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = hp, size = 3) |>
  scale_color_continuous(palette = "Viridis", name = "Horsepower")
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows Horsepower. Based on 32 observations.152025302345mpgwtHorsepower100150200250300

For a two-colour ramp, scale_color_gradient() takes the endpoints directly, and scale_color_gradientn() an arbitrary n-stop ramp. If you are coming from ggplot2, the familiar named constructors are here too and are simply wrappers over palette =: scale_color_viridis_c() / _d() for the viridis maps, scale_color_brewer() (discrete) / scale_color_distiller() (continuous) for ColorBrewer-style palettes. Set limits = on a continuous colour scale to fix its mapped range.

For categories, scale_color_manual() maps levels to colours; naming the values pins each level to a specific colour.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = factor(cyl), size = 3) |>
  scale_color_manual(values = c("4" = "#1b9e77", "6" = "#d95f02", "8" = "#7570b3"))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows factor(cyl). Based on 32 observations.152025302345mpgwtfactor(cyl)468

For signed or anomaly data — where a value like zero is meaningfully neutral — scale_color_gradient2() (and scale_fill_gradient2()) draw a diverging ramp around a midpoint. It rescales about the midpoint, so the neutral mid colour always lands on that value even when the data range is lopsided:

anom <- data.frame(x = 1:20, y = 1:20, resid = seq(-3, 6, length.out = 20))
vplot(anom) |>
  mark_point(x = x, y = y, color = resid, size = 3) |>
  scale_color_gradient2(low = "#2166ac", mid = "grey90", high = "#b2182b", midpoint = 0)
A scatter plot. It plots y (vertical axis) against x (horizontal axis), where colour shows resid. Based on 20 observations.51015205101520yxresid-20246

Ramps built from a plain colour vector — a scale_color_gradient(low, high), or a palette given as colours — are interpolated in the perceptually-uniform Oklab space, not sRGB, so the ramp and its colourbar read evenly instead of dipping through a muddy, over-dark middle (a blue→yellow ramp no longer passes through grey). Designed perceptual palettes such as the default Batlow or any hcl.colors() name are already uniform and unchanged. To blend in sRGB (or CIE Lab) instead, set options(vellumplot.color.interpolation = "srgb") (or "lab"). A gradient fill opts in per gradient with linear_gradient(..., interpolation = "oklab").

Binned colour

scale_fill_binned() and scale_color_binned() cut a continuous aesthetic into classes and give it a discrete legend, which is what you want for a choropleth or a heatmap you read by band rather than by exact value. Choose the classification style ("quantile", "equal", "pretty", or any classInt::classIntervals() style) and the number of classes n.

grid <- expand.grid(x = 1:10, y = 1:10)
grid$z <- with(grid, x * y)
vplot(grid) |>
  mark_tile(x = x, y = y, fill = z) |>
  scale_fill_binned(style = "quantile", n = 5, palette = "Mako")
A heatmap. It plots y (vertical axis) against x (horizontal axis), where colour shows z. Based on 100 observations.2.55.07.510.02.55.07.510.0yxz[1, 8.8)[8.8, 18)[18, 30)[30, 50)[50, 100]

Size, shape, and edge width

Non-colour aesthetics have scales too. scale_size() maps values linearly to a marker-size range (in mm); scale_shape() cycles a set of shapes over the levels of a discrete aesthetic; scale_edge_width() does for network edges what scale_size() does for points.

The shape palette is "circle", "square", "triangle", "diamond", "plus", "cross", "triangle_down", and "star" — eight in all, so a mapped shape covers up to eight levels automatically. Pass a subset (or a reordering) with scale_shape(values = ...). Filled shapes take the mark’s fill/color, so an open marker is fill = NA with a color.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, size = hp, color = factor(cyl)) |>
  scale_size(range = c(2, 9), name = "hp")
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows factor(cyl) and size shows hp. Based on 32 observations.152025302345mpgwtfactor(cyl)468hp100150200250300

scale_size() maps the value to the marker’s radius. For bubble charts prefer scale_size_area(), which maps to the area (value 0 → size 0) so the ink is proportional to the value — the honest encoding:

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, size = hp) |>
  scale_size_area(max_size = 10)
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where size shows hp. Based on 32 observations.152025302345mpgwthp100150200250300

Opacity and line type

alpha and linetype are mapped aesthetics too. scale_alpha() maps a continuous variable to opacity (its range defaults to c(0.1, 1)), a good way to let density show through an overplotted cloud.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, alpha = hp, size = 3) |>
  scale_alpha(range = c(0.2, 1))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.152025302345mpgwthp100150200250300

scale_linetype() maps a discrete variable to line types, cycling "solid", "dashed", "dotted", and so on. It applies to line-like marks (mark_line(), mark_step()).

series <- data.frame(
  x = rep(1:20, 3),
  y = c(cumsum(rnorm(20)), cumsum(rnorm(20)), cumsum(rnorm(20))),
  series = rep(c("a", "b", "c"), each = 20)
)
vplot(series) |>
  mark_line(x = x, y = y, linetype = series)
A line chart. It plots y (vertical axis) against x (horizontal axis). Based on 60 observations.-4-2025101520yxseriesabc

Identity scales

Sometimes a column already holds the exact aesthetic values you want: actual colour names, sizes in millimetres, shape names. An identity scale uses them verbatim and draws no legend. There is a variant for each aesthetic: scale_color_identity(), scale_fill_identity(), scale_size_identity(), scale_shape_identity(), scale_alpha_identity(), scale_linetype_identity().

df <- data.frame(
  x = 1:5, y = 1:5,
  col = c("firebrick", "goldenrod", "forestgreen", "steelblue", "purple")
)
vplot(df) |>
  mark_point(x = x, y = y, color = col, size = 6) |>
  scale_color_identity()
A scatter plot. It plots y (vertical axis) against x (horizontal axis), where colour shows col. Based on 5 observations.1234512345yx

Position scales

scale_x_continuous() and scale_y_continuous() control the axes. By default they train from the data with a small expansion; override them to set limits, apply a trans ("log10", "sqrt", "symlog", "reverse"), or supply explicit breaks and labels. Categorical axes use scale_x_discrete() and scale_y_discrete(), whose limits set the order or subset of levels.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  scale_y_continuous(limits = c(10, 35), breaks = seq(10, 35, 5)) |>
  scale_x_continuous(trans = "log10", name = "Weight (log scale)")
A scatter plot. It plots mpg (vertical axis) against Weight (log scale) (horizontal axis). Based on 32 observations.101520253035235mpgWeight (log scale)

Where "log10" cannot go — data that crosses zero or spans positive and negative values over several orders of magnitude — "symlog" gives a symmetric-log axis: linear through zero, logarithmic in each tail, with ticks at zero and signed powers of ten.

signed <- data.frame(v = c(-1000, -100, -10, 0, 10, 100, 1000), y = 1:7)
vplot(signed) |>
  mark_point(x = v, y = y) |>
  scale_x_continuous(trans = "symlog")
A scatter plot. It plots y (vertical axis) against v (horizontal axis). Based on 7 observations.1234567-1000-100-10-101101001000yv

To set limits without spelling out a whole scale, use the shortcuts xlim(), ylim(), and lims() (the last takes one named argument per aesthetic):

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  lims(x = c(0, 6), y = c(0, 40))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.0102030400123456mpgwt

Scale transform vs display transform

A scale_*(trans=) (above) transforms the data: it picks its breaks in the transformed space, so a "log10" axis is labelled 1, 10, 100. coord_trans() instead warps only the display, after the scale has trained — the breaks stay at their original data values, so the axis keeps those labels but they sit at warped positions (gridlines bunch up, and straight lines curve). Use it to show data on a log display without relabelling the axis in powers of ten:

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  mark_line(x = wt, y = mpg) |>
  coord_trans(y = "log10")
A plot combining scatter plot and line chart. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.152025302345mpgwt

Each of x / y takes a transform name ("log10", "sqrt", "identity") or a scales::transform_*() object. It applies to the common marks (points, lines, areas, bars, tiles, smooths, text); interval/segment, boxplot, and raster marks are not warped yet.

Secondary axes

A secondary axis adds a second set of ticks and labels on the opposite edge (top for x, right for y), computed as a 1:1 monotonic transform of the primary axis. Pass sec_axis() to the sec.axis argument of a continuous position scale. The classic use is a unit conversion — plot in Celsius, label the top in Fahrenheit:

vplot(data.frame(celsius = 0:100, y = (0:100)^2)) |>
  mark_line(x = celsius, y = y) |>
  scale_x_continuous(name = "°C", sec.axis = sec_axis(~ . * 1.8 + 32, name = "°F"))
A line chart. It plots y (vertical axis) against °C (horizontal axis). Based on 101 observations.025005000750010000025507510050100150200y°C°F

dup_axis() is the identity special case — it simply duplicates the axis on the far edge, handy for reading a wide plot from either side:

vplot(mtcars) |>
  mark_point(x = wt, y = mpg) |>
  scale_y_continuous(sec.axis = dup_axis())
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis). Based on 32 observations.15202530234515202530mpgwt

The transform can be a formula (~ . * 2), a function, or a scales::transform_*() object, and must be monotonic. This is a labelling convenience, not an independent second axis with its own data. It is currently supported on continuous position scales under the default Cartesian coordinate system, with shared scales across facets; combining it with coord_flip() / coord_polar() / coord_trans(), with free facet scales, or with add_marginal() raises an error, and in a plot composition the secondary axis is not drawn.

Date and time axes

A Date or POSIXct column gets a date axis automatically. To control the break interval or the label format, declare scale_x_date() (or scale_x_datetime() / scale_x_time()): date_breaks takes an interval string like "6 months", and date_labels a strftime() format.

econ <- data.frame(
  day = as.Date("2020-01-01") + 0:729,
  value = cumsum(rnorm(730))
)
vplot(econ) |>
  mark_line(x = day, y = value) |>
  scale_x_date(date_breaks = "6 months", date_labels = "%b %Y")
A line chart. It plots value (vertical axis) against day (horizontal axis). Based on 730 observations.-50-40-30-20-10010Jan 2020Jul 2020Jan 2021Jul 2021valueday

Guides come for free

Every scale that needs a legend produces one, and vellumplot stacks multiple legends automatically. Map two aesthetics and you get two guides without asking.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = hp, size = disp) |>
  scale_color_continuous(palette = "Batlow", name = "Horsepower") |>
  scale_size(range = c(1, 8), name = "Displacement")
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows Horsepower and size shows Displacement. Based on 32 observations.152025302345mpgwtHorsepower100150200250300Displacement100200300400

Controlling a legend

guides() overrides a single legend without respelling its scale. Pass "none" to hide it, or guide_legend() to reverse the key order or override the title.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = factor(cyl)) |>
  guides(color = guide_legend(title = "Cylinders", reverse = TRUE))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows factor(cyl). Based on 32 observations.152025302345mpgwtCylinders864

Hiding a legend keeps the mapping; the marks stay coloured, only the guide disappears:

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = factor(cyl)) |>
  guides(color = "none")
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows factor(cyl). Based on 32 observations.152025302345mpgwt

override.aes styles the legend keys independently of the data — the fix for a scatter of small, translucent points whose keys you still want big and readable. Pass a named list of aesthetics (size, alpha, colour, fill, shape, linewidth); only the swatches change, never the marks.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = factor(cyl), alpha = 0.15, size = 0.6) |>
  guides(color = guide_legend(override.aes = list(size = 5, alpha = 1)))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows factor(cyl). Based on 32 observations.152025302345mpgwtfactor(cyl)468

For a continuous colour scale the guide is a colour bar. guide_colourbar() sizes it (barwidth / barheight, in mm), toggles the break ticks (and their colour), and moves the labels to the left of the bar.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = hp) |>
  guides(color = guide_colourbar(
    barwidth = 8, barheight = 60, ticks = FALSE, label.position = "left"
  ))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows hp. Based on 32 observations.152025302345mpgwthp100150200250300

A binned colour scale draws as discrete swatches by default; guide_coloursteps() renders it as a segmented bar instead, labelled at the bin boundaries (and it takes the same barwidth / barheight / ticks tunables).

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = hp) |>
  scale_color_binned() |>
  guides(color = guide_coloursteps())
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows hp. Based on 32 observations.152025302345mpgwthp52.093.4110.0165.0200.0335.0

A size legend defaults to stacked keys; guide_legend(nested = TRUE) draws it as a proportional-symbol legend — concentric circles sharing a baseline, each with a leader to its label. It reads size directly and is compact, and suits a wide size range so the circles are large enough to tell apart.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, size = hp) |>
  scale_size(range = c(2, 12)) |>
  guides(size = guide_legend(nested = TRUE))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where size shows hp. Based on 32 observations.152025302345mpgwthp100150200250300

Rich titles

Any scale name (and any labs() title) accepts md(), a small markdown subset for bold, italic, superscript, subscript, and coloured spans. Handy for units and formulae.

vplot(mtcars) |>
  mark_point(x = wt, y = mpg, color = hp) |>
  scale_color_continuous(name = md("Power (hp m^2^)"))
A scatter plot. It plots mpg (vertical axis) against wt (horizontal axis), where colour shows hp. Based on 32 observations.152025302345mpgwt100150200250300

Faceted plots add one more question: should panels share a scale or train their own? That is the resolve_scale() lattice, covered in Facets and composition. For stat-derived aesthetics like after_stat(count), see Statistical marks.