Color and Colorspace in ggplot2


1. Introduction

Color is one of the most powerful — and most misused — channels in data visualization. Used well it guides attention, encodes magnitude, and groups categories. Used carelessly it misleads, confuses colorblind readers, and looks garish in print.

This post covers the theory first (why different color spaces exist), then the practical tools R provides, and ends with a set of named, hex-tagged project palettes you can copy into any ggplot2 project.


2. Color Models: RGB, HSV, HCL, and LUV

2.1 RGB — What Computers See

Every color on screen is an additive mixture of Red, Green, and Blue light. Values run 0–255 (or 0–1) per channel. RGB is perfect for rendering but terrible for human reasoning: #1A6FAD and #5B9DC9 look nothing like “the same blue at different brightness” even though they are.

2.2 HSV — Artist’s Intuition

Hue–Saturation–Value rearranges RGB into something more intuitive:

Dimension Range Meaning
H Hue 0–360° Position on the color wheel (red → green → blue → back)
S Saturation 0–1 Grey (0) to pure color (1)
V Value 0–1 Black (0) to full brightness (1)

HSV is better than RGB for picking colors by hand but is not perceptually uniform — equal steps in HSV do not look equal to the human eye.

2.3 HCL — Perceptually Uniform and Human-Friendly

Hue–Chroma–Luminance is the gold standard for data visualization:

Dimension Meaning
H Hue Same as HSV: angle on the color wheel
C Chroma Colorfulness (perceptual equivalent of saturation)
L Luminance Perceived brightness (not the same as RGB brightness)

Equal steps in HCL look equal to human eyes. This means palettes built in HCL space automatically have balanced visual weight across colors.

2.4 LUV (CIELUV) — The Math Behind HCL

HCL is the polar form of the CIELUV color space. LUV uses Cartesian coordinates (L, U, V) where L is luminance and the (U, V) plane encodes chromaticity. We will plot all of R’s 657 named colors in this space in Section 4.


3. R’s Color Vocabulary

3.1 Named Colors

# R has 657 named colors
length(colors())
## [1] 657
head(colors(), 20)
##  [1] "white"         "aliceblue"     "antiquewhite"  "antiquewhite1"
##  [5] "antiquewhite2" "antiquewhite3" "antiquewhite4" "aquamarine"   
##  [9] "aquamarine1"   "aquamarine2"   "aquamarine3"   "aquamarine4"  
## [13] "azure"         "azure1"        "azure2"        "azure3"       
## [17] "azure4"        "beige"         "bisque"        "bisque1"

3.2 Hex Codes and Conversion

# Hex → RGB
col2rgb("#268bd2")
##       [,1]
## red     38
## green  139
## blue   210
# RGB → Hex
rgb(38, 139, 210, maxColorValue = 255)
## [1] "#268BD2"
# Named color → Hex
col2rgb("steelblue")
##       [,1]
## red     70
## green  130
## blue   180
# HSV → Hex
hsv(h = 0.61, s = 0.73, v = 0.82)
## [1] "#386CD1"
# HCL → Hex (base R)
hcl(h = 220, c = 80, l = 55)
## [1] "#0097C1"

3.3 Plotting Named Colors by Hue

R’s 657 named colors sorted by Hue–Saturation–Value:

set_text_contrast <- function(color) {
  avg <- mean(col2rgb(color))
  ifelse(avg > 127, "black", "white")
}

txt_contrast <- sapply(colors(), set_text_contrast)

rgb_mat   <- col2rgb(colors())
hsv_mat   <- rgb2hsv(rgb_mat[1,], rgb_mat[2,], rgb_mat[3,], maxColorValue = 255)
hue_order <- order(hsv_mat[1,], hsv_mat[2,], hsv_mat[3,])

col_count <- 15
row_count <- ceiling(length(colors()) / col_count)

plot(0, type = "n", axes = FALSE, xlab = "", ylab = "",
     xlim = c(0.5, col_count + 0.5), ylim = c(row_count + 0.5, 0.5))
title("R Named Colors — Sorted by Hue, Saturation, Value", cex.main = 0.9)

for (j in seq(0, row_count - 1)) {
  for (i in seq(1, col_count)) {
    k <- j * col_count + i
    if (k <= length(colors())) {
      idx <- hue_order[k]
      rect(i - 0.5, j + 0.5, i + 0.5, j - 0.5,
           col = colors()[idx], border = NA)
      text(i, j, idx, cex = 0.35, col = txt_contrast[idx])
    }
  }
}

The index number shown on each tile is its position in colors() — use colors()[n] to retrieve any color by index.


4. The LUV Perceptual Color Space

Plotting all named R colors in CIELUV space reveals the geometry of human color perception. The horizontal U axis runs roughly green → red; the vertical V axis runs blue → yellow. Colors that look similar are close together; colors that are perceptually distant are far apart.

library(ggplot2)

# Convert all named colors to LUV via grDevices
rgb_mat <- t(col2rgb(colors())) / 255
luv_mat <- convertColor(rgb_mat, from = "sRGB", to = "Luv",
                         to.ref.white = "D65")
luv_df  <- data.frame(L = luv_mat[,1], U = luv_mat[,2], V = luv_mat[,3],
                       col = colors())

ggplot(luv_df, aes(x = U, y = V)) +
  geom_point(color = luv_df$col, size = 6, alpha = 0.75) +
  labs(
    title    = "R Named Colors in CIELUV Space",
    subtitle = "Each point colored with the color it represents",
    x        = "U  (green ← → red)",
    y        = "V  (blue ← → yellow)"
  ) +
  theme_gray(base_size = 13)

Notice how saturated colors cluster near the origin, while the colorful hues fan outward. White, grays, and black all sit at (U=0, V=0), since they have zero chrominance.


5. The colorspace Package

The colorspace package provides perceptually-grounded palette generators directly in HCL space. It generates three palette families that match three data types:

Palette family Data type Example function
Qualitative Unordered categories qualitative_hcl(n)
Sequential Ordered low → high sequential_hcl(n)
Diverging Ordered around a midpoint diverging_hcl(n)
library(colorspace)

par(mfrow = c(3, 1), mar = c(1, 4, 2, 1))

# Qualitative: equal C and L, evenly-spaced hues
barplot(rep(1, 8), col = qualitative_hcl(8, palette = "Dark 3"),
        border = NA, main = "Qualitative — Dark 3  (categorical data)", axes = FALSE)

# Sequential: fixed hue, varying C and L
barplot(rep(1, 9), col = sequential_hcl(9, palette = "Blues 3"),
        border = NA, main = "Sequential — Blues 3  (ordered data)", axes = FALSE)

# Diverging: two hues meeting at a neutral midpoint
barplot(rep(1, 11), col = diverging_hcl(11, palette = "Blue-Red 2"),
        border = NA, main = "Diverging — Blue-Red 2  (data centred at zero)", axes = FALSE)

par(mfrow = c(1, 1))

Using colorspace in ggplot2

library(dplyr)

# qualitative: scale_color_discrete_qualitative()
# sequential:  scale_fill_continuous_sequential()
# diverging:   scale_fill_continuous_diverging()

set.seed(1)
demo_df <- data.frame(
  x     = rnorm(120),
  y     = rnorm(120),
  group = rep(LETTERS[1:6], 20),
  value = rnorm(120)
)

ggplot(demo_df, aes(x = x, y = y, color = group)) +
  geom_point(size = 2.5, alpha = 0.8) +
  scale_color_discrete_qualitative(palette = "Dark 3") +
  labs(title = "qualitative_hcl via scale_color_discrete_qualitative()",
       color = "Group") +
  theme_gray(base_size = 13)

You can inspect any palette’s HCL trajectory with specplot():

specplot(sequential_hcl(9, "Blues 3"), asp = 1/3)

The three lines show Hue (H), Chroma (C), and Luminance (L) as the palette progresses. A good sequential palette has monotonically increasing L and decreasing C — this is what makes it readable in grayscale.


6. ColorBrewer in ggplot2

ColorBrewer (Cynthia Brewer, Penn State) provides hand-tuned palettes originally designed for cartography. They are colorblind-safe, print-safe, and photocopy-safe.

library(RColorBrewer)
display.brewer.all()

Key ggplot2 scales

# Qualitative fill: scale_fill_brewer(palette = "Set2")
# Sequential fill:  scale_fill_distiller(palette = "Blues")
# Diverging fill:   scale_fill_distiller(palette = "RdBu")

ggplot(demo_df, aes(x = x, y = y, color = group)) +
  geom_point(size = 2.5, alpha = 0.85) +
  scale_color_brewer(palette = "Set2") +
  labs(title = "ColorBrewer Set2 — scale_color_brewer(palette = 'Set2')",
       color = "Group") +
  theme_gray(base_size = 13)

Choosing a ColorBrewer palette

# Which palettes are colorblind-safe?
subset(brewer.pal.info, colorblind == TRUE)
##         maxcolors category colorblind
## BrBG           11      div       TRUE
## PiYG           11      div       TRUE
## PRGn           11      div       TRUE
## PuOr           11      div       TRUE
## RdBu           11      div       TRUE
## RdYlBu         11      div       TRUE
## Dark2           8     qual       TRUE
## Paired         12     qual       TRUE
## Set2            8     qual       TRUE
## Blues           9      seq       TRUE
## BuGn            9      seq       TRUE
## BuPu            9      seq       TRUE
## GnBu            9      seq       TRUE
## Greens          9      seq       TRUE
## Greys           9      seq       TRUE
## Oranges         9      seq       TRUE
## OrRd            9      seq       TRUE
## PuBu            9      seq       TRUE
## PuBuGn          9      seq       TRUE
## PuRd            9      seq       TRUE
## Purples         9      seq       TRUE
## RdPu            9      seq       TRUE
## Reds            9      seq       TRUE
## YlGn            9      seq       TRUE
## YlGnBu          9      seq       TRUE
## YlOrBr          9      seq       TRUE
## YlOrRd          9      seq       TRUE

7. Color Harmony — Building Matched Sets

Color harmony describes combinations of hues that are visually pleasing and functionally distinct. All systems below work in HCL hue space (0–360°).

7.1 The HCL Hue Strip

library(tidyr)

h_vals <- seq(0, 360, length.out = 721)
fills  <- hcl(h_vals, c = 70, l = 55)
fills[is.na(fills)] <- "#AAAAAA"

strip_df <- data.frame(h = h_vals, fill = fills)

# Anchor points for the three palettes defined in Section 8
harm_df <- data.frame(
  h       = c(220, 40,                   # Tide: complementary
               30,  150, 270,             # Grove: triadic
               20,  110, 200, 290),       # Studio: tetradic
  palette = c(rep("Tide (Comp. 180°)", 2),
              rep("Grove (Tri. 120°)",  3),
              rep("Studio (Tet. 90°)",  4))
)

ggplot() +
  geom_tile(data = strip_df,
            aes(x = h, y = 0, fill = fill), width = 0.5, height = 0.55) +
  geom_point(data = harm_df,
             aes(x = h, y = 0, shape = palette),
             size = 4.5, color = "white", stroke = 1.8) +
  scale_fill_identity() +
  scale_x_continuous(breaks = seq(0, 360, 30),
                     labels = paste0(seq(0, 360, 30), "°"),
                     expand = c(0, 0)) +
  scale_shape_manual(values = c(21, 22, 24)) +
  labs(title = "HCL Hue Strip — Anchor Points for Three Project Palettes",
       x = "Hue (degrees)", shape = NULL) +
  theme_minimal(base_size = 12) +
  theme(
    axis.title.y    = element_blank(),
    axis.text.y     = element_blank(),
    panel.grid      = element_blank(),
    legend.position = "bottom"
  )

7.2 Complementary — 2 Hues, 180° Apart

Maximum contrast. Ideal for binary comparisons (treated vs control, before vs after). Risk of visual vibration when used at full saturation — reduce chroma or luminance on one member.

7.3 Triadic — 3 Hues, 120° Apart

Balanced and vibrant. Good for three-category data. All three hues share the same HCL chroma and luminance so no color dominates.

7.4 Tetradic (Square) — 4 Hues, 90° Apart

Rich four-color palettes. Harder to balance — typically keep chroma moderate (50–65) to avoid visual noise. One color should dominate and the others support.

7.5 Analogous — Adjacent Hues (30–60° Apart)

Low contrast, high harmony. Good for backgrounds, shading, or when you need many steps of the same “temperature.” Not suitable as a qualitative palette because the colors are too similar.


8. Project Palettes — Named Swatches with Hex IDs

Three ready-to-use palettes, each generated in HCL space with equal chroma and luminance so the colors have balanced visual weight.

# All generated with grDevices::hcl(h, c, l)
# Equal C and L within each palette = equal perceptual weight

# --- Tide: Complementary (H = 220° blue  ↔  40° amber) ---
tide <- c(
  tide_deep  = hcl(220, 80, 42),
  tide_mid   = hcl(220, 50, 62),
  tide_amber = hcl( 40, 80, 55),
  tide_gold  = hcl( 40, 45, 73)
)

# --- Grove: Triadic (H = 30°, 150°, 270°, 120° steps) ---
grove <- c(
  grove_copper = hcl( 30, 68, 52),
  grove_fern   = hcl(150, 58, 52),
  grove_violet = hcl(270, 55, 45)
)

# --- Studio: Tetradic (H = 20°, 110°, 200°, 290°, 90° steps) ---
studio <- c(
  studio_coral  = hcl( 20, 68, 52),
  studio_olive  = hcl(110, 52, 55),
  studio_steel  = hcl(200, 62, 48),
  studio_plum   = hcl(290, 52, 45)
)

# Print hex codes for reference
cat("=== Tide (Complementary) ===\n")
## === Tide (Complementary) ===
print(toupper(tide))
##  tide_deep   tide_mid tide_amber  tide_gold 
##  "#0078A1"  "#3DA2BD"  "#BD7214"  "#DAA989"
cat("\n=== Grove (Triadic) ===\n")
## 
## === Grove (Triadic) ===
print(toupper(grove))
## grove_copper   grove_fern grove_violet 
##    "#B56841"    "#00905E"    "#6B62A6"
cat("\n=== Studio (Tetradic) ===\n")
## 
## === Studio (Tetradic) ===
print(toupper(studio))
## studio_coral studio_olive studio_steel  studio_plum 
##    "#BA6453"    "#6B8E3D"    "#008690"    "#85599E"
# Helper: luminance-based text contrast
contrast_col <- function(hex) {
  rgb_v <- col2rgb(hex)
  lum   <- (0.299 * rgb_v[1,] + 0.587 * rgb_v[2,] + 0.114 * rgb_v[3,]) / 255
  ifelse(lum > 0.48, "#222222", "#FFFFFF")
}

draw_swatches <- function(palettes, title = "Project Palettes") {
  df <- lapply(names(palettes), function(pname) {
    cols <- palettes[[pname]]
    data.frame(
      palette  = pname,
      col_name = names(cols),
      hex      = toupper(cols),
      fill     = as.character(cols),
      x        = seq_along(cols),
      stringsAsFactors = FALSE
    )
  }) |> bind_rows()

  df$txt_col <- contrast_col(df$fill)

  ggplot(df, aes(x = x, y = 0)) +
    geom_tile(aes(fill = fill), width = 0.95, height = 0.8) +
    geom_text(aes(label = col_name, color = txt_col),
              vjust = -0.25, size = 3.4, fontface = "bold") +
    geom_text(aes(label = hex, color = txt_col),
              vjust = 1.6, size = 3.0, family = "mono") +
    scale_fill_identity() +
    scale_color_identity() +
    scale_x_continuous(breaks = NULL) +
    scale_y_continuous(breaks = NULL, expand = expansion(mult = 0.6)) +
    facet_wrap(~ palette, ncol = 1, scales = "free_x") +
    labs(title = title) +
    theme_void(base_size = 12) +
    theme(
      strip.text   = element_text(face = "bold", size = 12, hjust = 0,
                                   margin = margin(b = 4, t = 10)),
      panel.spacing = unit(1.5, "lines"),
      plot.title   = element_text(face = "bold", size = 14,
                                   margin = margin(b = 10))
    )
}
all_palettes <- list(
  "Tide — Complementary (H: 220° / 40°)"   = tide,
  "Grove — Triadic (H: 30° / 150° / 270°)" = grove,
  "Studio — Tetradic (H: 20° / 110° / 200° / 290°)" = studio
)

draw_swatches(all_palettes)

Quick-reference table

Palette Name Hex Harmony H. C L
tide_deep Tide tide_deep #0078A1 Complementary 220 80 42
tide_mid Tide tide_mid #3DA2BD Complementary 220 50 62
tide_amber Tide tide_amber #BD7214 Complementary 40 80 55
tide_gold Tide tide_gold #DAA989 Complementary 40 45 73
grove_copper Grove grove_copper #B56841 Triadic 30 68 52
grove_fern Grove grove_fern #00905E Triadic 150 58 52
grove_violet Grove grove_violet #6B62A6 Triadic 270 55 45
studio_coral Studio studio_coral #BA6453 Tetradic 20 68 52
studio_olive Studio studio_olive #6B8E3D Tetradic 110 52 55
studio_steel Studio studio_steel #008690 Tetradic 200 62 48
studio_plum Studio studio_plum #85599E Tetradic 290 52 45

9. Using Palettes in ggplot2

9.1 Qualitative — scale_color_manual() / scale_fill_manual()

# Three-group example using Grove (triadic)
set.seed(7)
df3 <- data.frame(
  x     = rnorm(90),
  y     = rnorm(90),
  group = rep(c("Alpha", "Beta", "Gamma"), 30)
)

ggplot(df3, aes(x = x, y = y, color = group)) +
  geom_point(size = 3, alpha = 0.85) +
  scale_color_manual(values = unname(grove)) +
  labs(title = "Grove palette — scale_color_manual(values = unname(grove))",
       color = "Group") +
  theme_gray(base_size = 13)

9.2 Two-Group Contrast — Tide Complementary Pair

df2 <- data.frame(
  group = rep(c("Control", "Treated"), each = 40),
  value = c(rnorm(40, 10, 2), rnorm(40, 13, 2.5))
)

ggplot(df2, aes(x = group, y = value, fill = group)) +
  geom_boxplot(alpha = 0.85, width = 0.5) +
  geom_jitter(aes(color = group), width = 0.12, size = 1.8, alpha = 0.6) +
  scale_fill_manual(values  = c(Control = tide[["tide_mid"]],
                                Treated = tide[["tide_amber"]])) +
  scale_color_manual(values = c(Control = tide[["tide_deep"]],
                                Treated = tide[["tide_gold"]])) +
  labs(title = "Tide palette — complementary pair for binary comparison") +
  guides(fill = "none", color = "none") +
  theme_gray(base_size = 13)

9.3 Four-Category — Studio Tetradic

df4 <- data.frame(
  category = rep(c("Q1", "Q2", "Q3", "Q4"), each = 30),
  x        = rnorm(120),
  y        = c(rnorm(30, 1), rnorm(30, -1), rnorm(30, 1, 0.5), rnorm(30, -0.5))
)

ggplot(df4, aes(x = x, y = y, color = category)) +
  geom_point(size = 2.5, alpha = 0.8) +
  scale_color_manual(values = unname(studio)) +
  labs(title = "Studio palette — scale_color_manual(values = unname(studio))",
       color = "Quarter") +
  theme_gray(base_size = 13)

9.4 Storing Palettes for Reuse

Define your palettes once in a project-level colors.R file and source() it at the top of each script:

# colors.R — project palette definitions

tide <- c(
  tide_deep  = "#1B4E8A",   # adjust to your actual hex values
  tide_mid   = "#4A82B8",
  tide_amber = "#C96A1A",
  tide_gold  = "#E8AB68"
)

grove <- c(
  grove_copper = "#A84C25",
  grove_fern   = "#2D7A52",
  grove_violet = "#4D3B8C"
)

studio <- c(
  studio_coral = "#B54230",
  studio_olive = "#5E7A2A",
  studio_steel = "#1E5E88",
  studio_plum  = "#5A3576"
)

10. Summary

Topic Key points
Color models RGB for rendering; HSV for intuition; HCL for perceptual balance; LUV is HCL’s Cartesian parent
HCL Equal C and L = equal visual weight; hue angle drives harmony
colorspace qualitative_hcl, sequential_hcl, diverging_hcl for the three data types; drop-in ggplot2 scales
ColorBrewer Hand-tuned, colorblind/print-safe; scale_color_brewer() / scale_fill_distiller()
Harmony types Complementary (180°, binary contrast), Triadic (120°, 3-way balance), Tetradic (90°, 4-way); Analogous (adjacent, low contrast)
Project palettes Tide (blue/amber, 2+2), Grove (copper/fern/violet, 3), Studio (coral/olive/steel/plum, 4)
In ggplot2 scale_color_manual(values = palette_vector) — name the vector elements to match factor levels