How to Customize Colors in ggplot2

Color is one of the most powerful tools in data visualization, yet it's also one of the most misused. ggplot2 provides extensive color customization capabilities, but knowing which approach to...

Key Insights

  • ggplot2 offers three primary color customization approaches: built-in scale functions (like scale_color_viridis_d()), manual specification with hex codes, and custom palette creation—each suited for different use cases and levels of control.
  • The distinction between scale_color_*() and scale_fill_*() functions is critical: color controls line and point aesthetics while fill controls area aesthetics like bars and polygons.
  • Colorblind-friendly palettes aren’t just about accessibility—they’re objectively better for data visualization because they maintain perceptual uniformity and work in grayscale, making your visualizations more effective for all audiences.

Introduction to ggplot2 Color Systems

Color is one of the most powerful tools in data visualization, yet it’s also one of the most misused. ggplot2 provides extensive color customization capabilities, but knowing which approach to use—and when—separates amateur visualizations from professional ones.

The default ggplot2 colors work fine for quick exploration, but production visualizations demand intentional color choices. Whether you’re maintaining brand consistency, ensuring accessibility, or simply making your data more interpretable, understanding ggplot2’s color system is essential.

Let’s compare default colors with a customized version:

library(ggplot2)
library(patchwork)

# Sample data
df <- data.frame(
  category = rep(c("A", "B", "C"), each = 50),
  value = c(rnorm(50, 10, 2), rnorm(50, 15, 2), rnorm(50, 12, 2))
)

# Default colors
p1 <- ggplot(df, aes(x = category, y = value, fill = category)) +
  geom_boxplot() +
  labs(title = "Default ggplot2 Colors") +
  theme_minimal()

# Customized colors
p2 <- ggplot(df, aes(x = category, y = value, fill = category)) +
  geom_boxplot() +
  scale_fill_viridis_d(option = "plasma", begin = 0.2, end = 0.8) +
  labs(title = "Customized Viridis Palette") +
  theme_minimal()

p1 + p2

The customized version immediately looks more polished and professional. Now let’s explore how to achieve this level of control.

Using Built-in Color Scales

ggplot2’s built-in scale functions provide the easiest path to better colors. The key is understanding the naming convention: scale_color_*() affects points and lines, while scale_fill_*() affects areas like bars and polygons. Both come in discrete (_d) and continuous (_c) variants.

The viridis family of palettes is my go-to recommendation. They’re perceptually uniform, colorblind-friendly, and print well in grayscale:

# Discrete categories with viridis
ggplot(mpg, aes(x = class, fill = class)) +
  geom_bar() +
  scale_fill_viridis_d(option = "magma") +
  theme_minimal() +
  theme(legend.position = "none")

Viridis offers five options: “viridis” (default), “magma”, “plasma”, “inferno”, and “cividis”. Each has distinct characteristics—magma works well for heat maps, while cividis is optimized for colorblind viewers.

For continuous data, gradient scales provide smooth color transitions:

# Continuous heatmap
library(reshape2)
cor_matrix <- cor(mtcars[, 1:7])
melted_cor <- melt(cor_matrix)

ggplot(melted_cor, aes(x = Var1, y = Var2, fill = value)) +
  geom_tile() +
  scale_fill_gradient2(low = "#2166AC", mid = "white", high = "#B2182B",
                       midpoint = 0) +
  theme_minimal() +
  labs(title = "Correlation Heatmap")

ColorBrewer palettes offer carefully designed color schemes for different data types:

# ColorBrewer for categorical data
ggplot(diamonds[sample(nrow(diamonds), 1000), ], 
       aes(x = carat, y = price, color = cut)) +
  geom_point(alpha = 0.6) +
  scale_color_brewer(palette = "Set1") +
  theme_minimal()

# For sequential data
ggplot(diamonds[sample(nrow(diamonds), 1000), ], 
       aes(x = carat, y = price, color = depth)) +
  geom_point() +
  scale_color_distiller(palette = "YlOrRd", direction = 1) +
  theme_minimal()

ColorBrewer provides three palette types: sequential (for ordered data), diverging (for data with a meaningful midpoint), and qualitative (for categorical data). Use RColorBrewer::display.brewer.all() to see all available options.

Manual Color Specification

When you need precise control—like matching brand guidelines—manual color specification is your answer. This approach uses scale_color_manual() and scale_fill_manual() with explicit color values.

# Define specific colors with hex codes
brand_colors <- c(
  "Product A" = "#FF6B6B",
  "Product B" = "#4ECDC4",
  "Product C" = "#45B7D1",
  "Product D" = "#FFA07A"
)

sales_data <- data.frame(
  product = rep(names(brand_colors), each = 12),
  month = rep(1:12, 4),
  revenue = runif(48, 1000, 5000)
)

ggplot(sales_data, aes(x = month, y = revenue, color = product)) +
  geom_line(size = 1.2) +
  scale_color_manual(values = brand_colors) +
  theme_minimal() +
  labs(title = "Revenue by Product")

R includes 657 named colors accessible via colors(). While hex codes offer more precision, named colors improve code readability:

# Using named R colors
ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, color = Species)) +
  geom_point(size = 3) +
  scale_color_manual(values = c("setosa" = "steelblue",
                                "versicolor" = "darkorange",
                                "virginica" = "forestgreen")) +
  theme_minimal()

You can also specify colors using RGB values with rgb(), though this is less common in practice.

Creating Custom Color Palettes

For consistent branding across multiple visualizations, create reusable custom palettes. The colorRampPalette() function generates interpolated color sequences:

# Create a custom gradient palette function
custom_gradient <- colorRampPalette(c("#0D1B2A", "#1B263B", "#415A77", "#778DA9", "#E0E1DD"))

# Generate 10 colors from this palette
my_colors <- custom_gradient(10)

# Apply to a plot
ggplot(diamonds[sample(nrow(diamonds), 1000), ], 
       aes(x = carat, y = price, color = depth)) +
  geom_point() +
  scale_color_gradientn(colors = custom_gradient(100)) +
  theme_minimal()

For corporate branding, define palette functions that can be reused across projects:

# Corporate brand color scheme
corporate_palette <- function(n, type = "discrete") {
  colors <- c(
    primary = "#1A1A2E",
    secondary = "#16213E",
    accent1 = "#0F3460",
    accent2 = "#E94560",
    neutral = "#EAEAEA"
  )
  
  if (type == "discrete") {
    return(colors[1:min(n, length(colors))])
  } else {
    return(colorRampPalette(colors)(n))
  }
}

# Use in plots
ggplot(mpg, aes(x = class, fill = class)) +
  geom_bar() +
  scale_fill_manual(values = corporate_palette(7)) +
  theme_minimal()

This approach ensures consistency across all visualizations while maintaining flexibility.

Advanced Techniques: Transparency and Color Blindness

Alpha transparency solves the common problem of overplotting without changing your color scheme:

# Without alpha - hard to see overlapping points
p1 <- ggplot(diamonds[sample(nrow(diamonds), 5000), ], 
             aes(x = carat, y = price, color = cut)) +
  geom_point() +
  scale_color_viridis_d() +
  theme_minimal() +
  labs(title = "Without Transparency")

# With alpha - overlapping regions visible
p2 <- ggplot(diamonds[sample(nrow(diamonds), 5000), ], 
             aes(x = carat, y = price, color = cut)) +
  geom_point(alpha = 0.3) +
  scale_color_viridis_d() +
  theme_minimal() +
  labs(title = "With Alpha = 0.3")

p1 + p2

The alpha parameter accepts values from 0 (completely transparent) to 1 (completely opaque). For most overplotting scenarios, values between 0.2 and 0.5 work well.

Colorblind-friendly palettes aren’t optional—approximately 8% of men and 0.5% of women have some form of color vision deficiency. The viridis palettes are designed with this in mind:

library(colorspace)

# Standard palette (problematic for colorblind viewers)
p1 <- ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, color = Species)) +
  geom_point(size = 3) +
  scale_color_manual(values = c("red", "green", "blue")) +
  theme_minimal() +
  labs(title = "Standard Colors (Poor Choice)")

# Colorblind-safe palette
p2 <- ggplot(iris, aes(x = Sepal.Length, y = Sepal.Width, color = Species)) +
  geom_point(size = 3) +
  scale_color_viridis_d() +
  theme_minimal() +
  labs(title = "Viridis (Colorblind-Safe)")

p1 + p2

The colorspace package provides additional tools for testing colorblind safety with functions like deutan(), protan(), and tritan() to simulate different types of color blindness.

Best Practices and Common Pitfalls

Avoid rainbow palettes for continuous data. Rainbow scales are perceptually non-uniform—the human eye perceives yellow as brighter than blue, creating artificial emphasis. Use sequential or diverging palettes instead.

Match color scale to data type. Sequential data (0-100) needs sequential colors. Diverging data (temperature anomalies) needs diverging palettes. Categorical data needs distinct, qualitative colors.

Limit categorical colors to 8-10 maximum. Beyond this, colors become indistinguishable. If you have more categories, consider faceting or grouping.

Here’s a before-and-after comparison:

# Poor choice: rainbow palette for continuous data
p1 <- ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
  geom_tile() +
  scale_fill_gradientn(colors = rainbow(7)) +
  labs(title = "Poor: Rainbow Palette") +
  theme_minimal()

# Better choice: sequential palette
p2 <- ggplot(faithfuld, aes(waiting, eruptions, fill = density)) +
  geom_tile() +
  scale_fill_viridis_c(option = "magma") +
  labs(title = "Better: Sequential Palette") +
  theme_minimal()

p1 + p2

The viridis version immediately communicates the data structure more clearly. The rainbow version creates false boundaries and draws attention to arbitrary color transitions rather than actual data patterns.

Test in grayscale. Your visualization should remain interpretable when printed in black and white. Viridis palettes pass this test automatically.

Color is a tool, not decoration. Every color choice should serve your data story. Start with sensible defaults like viridis, customize only when you have a specific reason, and always prioritize clarity over aesthetics. Your readers—and their eyes—will thank you.

Liked this? There's more.

Every week: one practical technique, explained simply, with code you can use immediately.