1 Background

R ships with a built-in palette of named colors, returned by colors(). As of this writing (and for a long time now) it holds 657 names — things like "steelblue", "tomato3", or "gray42". This document walks through a classic script by efg (Stowers Institute) that turns that list into a printable reference chart, and along the way explains the underlying R color machinery: how names map to RGB, how RGB maps to HSV, and how to pick readable text/background combinations automatically.

The original script writes everything to a file called COLOR_CHART.pdf. Here we reproduce each piece as an inline, explained chunk so it renders directly in this document; the full original (PDF-producing) script is included as an appendix at the end for reference.

# The chart layout below hard-codes 25 columns x 27 rows = 675 slots for 657 colors.
# This assumes the palette size hasn't changed between R versions.
length(colors())
## [1] 657

2 How R represents colors

R gives you three interchangeable ways to specify a color:

  1. By name — a string from colors(), e.g. "firebrick".
  2. By hex code"#FF0000", the familiar web-style RRGGBB (optionally with alpha, RRGGBBAA).
  3. By numeric index — a legacy palette (palette()), rarely used now.

col2rgb() converts any of the first two into the underlying 0–255 RGB triplet:

col2rgb("yellow")
##       [,1]
## red    255
## green  255
## blue     0
col2rgb("steelblue")
##       [,1]
## red     70
## green  130
## blue   180

RGB is how the color is stored, but it is not how humans reason about color. For that, R (via rgb2hsv()) can convert to HSV — Hue, Saturation, Value — which separates what color (hue, as an angle 0–1 representing 0–360°) from how vivid (saturation) and how bright (value):

rgb2hsv(col2rgb("steelblue"))
##        [,1]
## h 0.5757576
## s 0.6111111
## v 0.7058824

Sorting colors by hue-then-saturation-then-value is what makes the second chart below (“sorted by hue”) group visually similar colors together, instead of the arbitrary alphabetical-ish order colors() returns them in.

3 Choosing readable text color: a contrast heuristic

Rectangles need a label (the color’s name and index) drawn on top of them. White text on "yellow" is unreadable; black text on "navy" is unreadable. The script uses a cheap luminance heuristic: average the R, G, and B channels, and if that average is above the midpoint (127.5), the background is “light” so use black text; otherwise use white text.

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

SetTextContrastColor("white")
## [1] "black"
SetTextContrastColor("black")
## [1] "white"
SetTextContrastColor("red")
## [1] "white"
SetTextContrastColor("yellow")
## [1] "black"

This is a simplification — perceptual luminance weights green much more heavily than blue (roughly 0.299*R + 0.587*G + 0.114*B), so a plain channel average can misjudge some saturated blues or reds. It’s good enough for a quick reference chart, though, which is the point here.

We precompute the contrast color for every entry in colors() once, so the plotting loops below can just look it up instead of recomputing it per rectangle:

TextContrastColor <- unlist(lapply(colors(), SetTextContrastColor))
table(TextContrastColor)
## TextContrastColor
## black white 
##   416   241

4 Chart 1a: colors in index order

The simplest chart just walks colors() in the order R returns it, laying out 25 colors per row. Each rectangle is drawn with rect(), positioned by its row/column, filled with the color itself, and labeled with its numeric index (SetTextContrastColor decides whether that label is black or white).

colCount <- 25  # number of rectangles per row
rowCount <- ceiling(length(colors()) / colCount)

oldpar <- par(mar = c(1, 1, 2, 1))

plot(c(1, colCount), c(0, rowCount), type = "n", ylab = "", xlab = "",
     axes = FALSE, ylim = c(rowCount, 0))
title("R colors (index order)")

for (j in 0:(rowCount - 1)) {
  base <- j * colCount
  remaining <- length(colors()) - base
  RowSize <- ifelse(remaining < colCount, remaining, colCount)
  rect((1:RowSize) - 0.5, j - 0.5, (1:RowSize) + 0.5, j + 0.5,
       border = "black", col = colors()[base + (1:RowSize)])
  text((1:RowSize), j, paste(base + (1:RowSize)), cex = 0.7,
       col = TextContrastColor[base + (1:RowSize)])
}

par(oldpar)

Notice how little visual structure there is: R’s colors() order is alphabetical by name (with numbered variants like "blue1".."blue4" interleaved), not by appearance, so neighboring cells jump around the color wheel.

5 Chart 1b: colors sorted by hue, saturation, value

To get a chart that actually reads as a gradient, we convert every color to HSV and sort by hue first, then saturation, then value. order() gives us the permutation of indices that achieves that sort, and we index into colors() with it.

RGBColors <- col2rgb(colors())
HSVColors <- rgb2hsv(RGBColors[1, ], RGBColors[2, ], RGBColors[3, ], maxColorValue = 255)
HueOrder <- order(HSVColors[1, ], HSVColors[2, ], HSVColors[3, ])

oldpar <- par(mar = c(1, 1, 2, 1))

plot(0, type = "n", ylab = "", xlab = "", axes = FALSE,
     ylim = c(rowCount, 0), xlim = c(1, colCount))
title("R colors -- sorted by hue, saturation, value")

for (j in 0:(rowCount - 1)) {
  for (i in 1:colCount) {
    k <- j * colCount + i
    if (k <= length(colors())) {
      rect(i - 0.5, j - 0.5, i + 0.5, j + 0.5,
           border = "black", col = colors()[HueOrder[k]])
      text(i, j, paste(HueOrder[k]), cex = 0.7,
           col = TextContrastColor[HueOrder[k]])
    }
  }
}

par(oldpar)

The label in each cell is still the original index into colors() — so you can look up a color visually here, then use that number (or better, colors()[index]) to reference it by name in code.

6 Getting a color’s hex and decimal RGB

For a reference sheet you usually want the exact hex code (for CSS/HTML/design tools) alongside the decimal RGB triplet (for R/plotting code). sprintf() builds both from col2rgb()’s output in one line:

GetColorHexAndDecimal <- function(color) {
  c <- col2rgb(color)
  sprintf("#%02X%02X%02X %3d %3d %3d", c[1], c[2], c[3], c[1], c[2], c[3])
}

GetColorHexAndDecimal("yellow")
## [1] "#FFFF00 255 255   0"
GetColorHexAndDecimal("steelblue")
## [1] "#4682B4  70 130 180"

7 Chart 2: paginated detail sheet (name + hex + decimal)

The final part of the original script produces a multi-page listing: two columns of 50 colors each per page, each row showing a filled swatch, the color’s index, its name, and its hex/decimal RGB (in a monospace font so the numbers align). Below is a single representative page reproduced inline; the appendix has the full paging loop that generates every page (this is what the original script writes across the multi-page PDF).

index <- paste(1:length(colors()))
HexAndDec <- unlist(lapply(colors(), GetColorHexAndDecimal))
PerColumn <- 50
PerPage <- 2 * PerColumn

page <- 1
oldpar <- par(mar = c(1, 1, 1, 1))

plot(0, type = "n", ylab = "", xlab = "", axes = FALSE,
     ylim = c(PerColumn, 0), xlim = c(0, 1))
title("R colors")
mtext(paste("page", page), side = 1, adj = 1, line = -1)

base <- PerPage * (page - 1)

# Column 1
remaining <- length(colors()) - base
ColumnSize <- ifelse(remaining < PerColumn, remaining, PerColumn)
rect(0.00, 0:(ColumnSize - 1), 0.49, 1:ColumnSize,
     border = "black", col = colors()[(base + 1):(base + ColumnSize)])
text(0.045, 0.45 + (0:(ColumnSize - 1)), adj = 1, index[(base + 1):(base + ColumnSize)],
     cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
text(0.06, 0.45 + (0:(ColumnSize - 1)), adj = 0, colors()[(base + 1):(base + ColumnSize)],
     cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
save <- par(family = "mono")
text(0.25, 0.45 + (0:(ColumnSize - 1)), adj = 0, HexAndDec[(base + 1):(base + ColumnSize)],
     cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
par(save)

# Column 2
if (remaining > PerColumn) {
  remaining2 <- remaining - PerColumn
  ColumnSize2 <- ifelse(remaining2 < PerColumn, remaining2, PerColumn)
  rect(0.51, 0:(ColumnSize2 - 1), 1.00, 1:ColumnSize2,
       border = "black", col = colors()[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)])
  text(0.545, 0.45 + (0:(ColumnSize2 - 1)), adj = 1,
       index[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)],
       cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)])
  text(0.56, 0.45 + (0:(ColumnSize2 - 1)), adj = 0,
       colors()[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)],
       cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)])
  save <- par(family = "mono")
  text(0.75, 0.45 + (0:(ColumnSize2 - 1)), adj = 0,
       HexAndDec[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)],
       cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize2)])
  par(save)
}

par(oldpar)

To generate every page (not just page 1) and get the full multi-page reference sheet, wrap that same block in a loop over page in 1:ceiling(length(colors()) / PerPage) and, when knitting, either emit one PDF page per iteration or (for HTML) let each iteration produce its own plot — see the appendix for the loop as written in the original script.

8 Takeaways

  • colors() is R’s built-in named palette (657 entries); col2rgb() and rgb2hsv() convert between name/hex, RGB, and HSV representations.
  • Sorting by HSV hue (not the default alphabetical order) is what makes a color chart visually navigable.
  • A simple average-of-channels test is a cheap-but-imperfect way to auto-pick readable black/white text on an arbitrary background color.
  • sprintf("#%02X%02X%02X ...") is the idiomatic way to format an RGB triplet as both hex and decimal in one string.

9 Appendix: original script (writes COLOR_CHART.pdf)

# efg, Stowers Institute for Medical Research
# efg's Research Notes: http://research.stowers-institute.org/efg/R/Color/Chart 6 July 2004. Modified 23 May 2005.
pdf("COLOR_CHART.pdf", width = 10, height = 10)
# save to reset at end
oldparameters <- par(mar = c(1, 1, 2, 1), mfrow = c(2, 1))
# Be cautious in case definition of "colors" changes. Use some hard-coded constants since this is not expected to change.
stopifnot(length(colors()) == 657)
# 0. Setup
SetTextContrastColor <- function(color) {ifelse(mean(col2rgb(color)) > 127, "black", "white")}
TextContrastColor <- unlist(lapply(colors(), SetTextContrastColor))
# 1a. Plot matrix of R colors, in index order, 25 per row.
colCount <- 25
rowCount <- 27
plot(c(1, colCount), c(0, rowCount), type = "n", ylab = "", xlab = "", axes = FALSE, ylim = c(rowCount, 0))
title("R colors")
mtext("http://research.stowers-institute.org/efg/R/Color/Chart", cex = 0.6)
for (j in 0:(rowCount - 1)) {
  base <- j * colCount
  remaining <- length(colors()) - base
  RowSize <- ifelse(remaining < colCount, remaining, colCount)
  rect((1:RowSize) - 0.5, j - 0.5, (1:RowSize) + 0.5, j + 0.5, border = "black", col = colors()[base + (1:RowSize)])
  text((1:RowSize), j, paste(base + (1:RowSize)), cex = 0.7, col = TextContrastColor[base + (1:RowSize)])
}
# 1b. Plot matrix of R colors, in "hue" order, 25 per row.
RGBColors <- col2rgb(colors()[1:length(colors())])
HSVColors <- rgb2hsv(RGBColors[1, ], RGBColors[2, ], RGBColors[3, ], maxColorValue = 255)
HueOrder <- order(HSVColors[1, ], HSVColors[2, ], HSVColors[3, ])
plot(0, type = "n", ylab = "", xlab = "", axes = FALSE, ylim = c(rowCount, 0), xlim = c(1, colCount))
title("R colors -- Sorted by Hue, Saturation, Value")
for (j in 0:(rowCount - 1)) {
  for (i in 1:colCount) {
    k <- j * colCount + i
    if (k <= length(colors())) {
      rect(i - 0.5, j - 0.5, i + 0.5, j + 0.5, border = "black", col = colors()[HueOrder[k]])
      text(i, j, paste(HueOrder[k]), cex = 0.7, col = TextContrastColor[HueOrder[k]])
    }
  }
}
# 2. Create 7-page color chart showing rectangle block of color, along with index, color name, and RGB constants in hex and decimal.
GetColorHexAndDecimal <- function(color) {
  c <- col2rgb(color)
  sprintf("#%02X%02X%02X %3d %3d %3d", c[1], c[2], c[3], c[1], c[2], c[3])
}
par(oldparameters)
oldparameters <- par(mar = c(1, 1, 1, 1))
index <- paste(1:length(colors()))
HexAndDec <- unlist(lapply(colors(), GetColorHexAndDecimal))
PerColumn <- 50
PerPage <- 2 * PerColumn
for (page in 1:(trunc(length(colors()) + (PerPage - 1)) / PerPage)) {
  plot(0, type = "n", ylab = "", xlab = "", axes = FALSE, ylim = c(PerColumn, 0), xlim = c(0, 1))
  title("R colors")
  mtext(paste("page ", page), SOUTH <- 1, adj = 1, line = -1)
  base <- PerPage * (page - 1)
  # Column 1
  remaining <- length(colors()) - base
  ColumnSize <- ifelse(remaining < PerColumn, remaining, PerColumn)
  rect(0.00, 0:(ColumnSize - 1), 0.49, 1:ColumnSize, border = "black", col = colors()[(base + 1):(base + ColumnSize)])
  text(0.045, 0.45 + (0:(ColumnSize - 1)), adj = 1, index[(base + 1):(base + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
  text(0.06, 0.45 + (0:(ColumnSize - 1)), adj = 0, colors()[(base + 1):(base + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
  save <- par(family = "mono")
  text(0.25, 0.45 + (0:(ColumnSize - 1)), adj = 0, HexAndDec[(base + 1):(base + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + 1):(base + ColumnSize)])
  par(save)
  # Column 2
  if (remaining > PerColumn) {
    remaining <- remaining - PerColumn
    ColumnSize <- ifelse(remaining < PerColumn, remaining, PerColumn)
    rect(0.51, 0:(ColumnSize - 1), 1.00, 1:ColumnSize, border = "black", col = colors()[(base + PerColumn + 1):(base + PerColumn + ColumnSize)])
    text(0.545, 0.45 + (0:(ColumnSize - 1)), adj = 1, index[(base + PerColumn + 1):(base + PerColumn + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize)])
    text(0.56, 0.45 + (0:(ColumnSize - 1)), adj = 0, colors()[(base + PerColumn + 1):(base + PerColumn + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize)])
    save <- par(family = "mono")
    text(0.75, 0.45 + (0:(ColumnSize - 1)), adj = 0, HexAndDec[(base + PerColumn + 1):(base + PerColumn + ColumnSize)], cex = 0.6, col = TextContrastColor[(base + PerColumn + 1):(base + PerColumn + ColumnSize)])
    par(save)
  }
}
par(oldparameters)
dev.off()