A simple trick for applying accessible colour palettes within dataviz

data-visualisation
ggplot2
r-how-to
dataviz-design-system
One of my favourite things to do is create colour palettes for clients. One issue we need to wrestle with is that light colours in a palette aren’t distinguishable enough against a white/light backdrop. Thankfully, there’s a relatively straightforward solution: add a dark contour.
Author
Affiliation

Building Stories with Data

Published

July 3, 2026

One of my favourite things to do is create colour palettes for clients. On brand, accessible (colourblind and neurodivergent friendly), meaningful in the context of the data stories, and (though I say so myself) nice to look at.

One issue we need to wrestle with is that light colours in a palette aren’t distinguishable against a white/light backdrop. We know that we need a foreground/background ratio of 3:1 for graphical elements, and a sense of panic quickly sets in when we realise this isn’t mathematically possible for more than a handful of colours if we want them to also be easy to distinguish from each other. The light-to-dark variation within the palette is what makes the colour combinations colourblind friendly, but it’s also what makes us fail the 3:1 ratio test.

Thankfully, there’s a relatively straightforward solution! Add a contour. I’ve talked about this in recent presentations, and, as a means of checking I hadn’t made it up, returned Frank Elavsky’s super helpful Chartability Workbook. I highly recommend a read, regardless of what tools you’re using. Frank has really clear illustrations of the issues he addresses, and it’s worth checking your dataviz against the criteria he lays out.

So, what does this look like?

First, an accessible colour palette. We can tell already from the white text that some of the colours are too light to be distinguished from the background.

monochromeR::view_palette(c("#ffccff", "#76ad9d", "#2c3d4f", "#4c6dc6"))

Four bars of colours side by side: a light pink, a mid green, a dark grey/blue and a mid blue. One each of the bars, we see the hex codes for the colours written in both black and white: #ffccff,#76ad9d, #2c3d4f, #4c6dc6

But we know that the colours are colourblind friendly, not only as they stand, but also when we interpolate them:

So, here’s our original graph, where the lighter colours aren’t clear enough against their background.

library(ggplot2)
penguins |>
  ggplot(aes(x = flipper_len, y = bill_len, colour = species)) +
  geom_point(size = 5) +
  scale_colour_manual(
    values = c(
      "Chinstrap" = "#ffccff",
      "Gentoo" = "#76ad9d",
      "Adelie" = "#4c6dc6"
    )
  ) +
  theme_minimal()

A scatterplot, showing three clusters of dots, each with their own colour (one per species). We're using the pink, green and blue from the palette above. The pink dots are hard to see against a white background.

And here it is again with a contour added to the dots, which allows us to use the lighter colours because they are now encapsulated from their background, and maintain the colourblind-friendly palette 🥳.

penguins |>
  ggplot(aes(x = flipper_len, y = bill_len, fill = species)) +
  geom_point(size = 5, shape = 21, colour = "#2c3d4f") +
  scale_fill_manual(
    values = c(
      "Chinstrap" = "#ffccff",
      "Gentoo" = "#76ad9d",
      "Adelie" = "#4c6dc6"
    )
  ) +
  theme_minimal()

A scatterplot, showing three clusters of dots, each with their own colour (one per species). This time we've added a dark contour around each dot. The pink dots are now clearly separated from their background.

Let’s check that works:

colorblindr::cvd_grid()

A simulation of what this graph looks like with Deutanomaly, Protanomaly, Tritanomaly and a total desaturation of the colours. The pink dots are clearly visible against the background, thanks to their dark contour, and the colour are also very distinct from each other in all four simulations.

With ggplot2’s theme(geom = element_geom()) we can also set it by default within our custom theme! Note that we can’t just use shape =, we need to use pointshape =. The same applies to how we set a default size for out points.

my_theme <- function() {
  theme_minimal() +
    theme(
      text = element_text(family = "Noah", face = "bold"),
      plot.background = element_rect(fill = "#E7E7F1", colour = "#E7E7F1"),
      plot.margin = margin_auto(11),
      geom = element_geom(
        pointshape = 21,
        pointsize = 5,
        ink = "#2c3d4f"
      ),
      panel.grid = element_line(colour = "white")
    )
}

We can now just use geom_point() and let the theme do the heavy lifting.

penguins |>
  ggplot(aes(x = flipper_len, y = bill_len, fill = species)) +
  geom_point() +
  scale_fill_manual(
    values = c(
      "Chinstrap" = "#ffccff",
      "Gentoo" = "#76ad9d",
      "Adelie" = "#4c6dc6"
    )
  ) +
  my_theme() # This sets the shape, size and contour colour of our dots - no more copy-pasting across graphs!

Exactly the same graph as the previous one: a scatterplot, showing three clusters of dots, each with their own colour (one per species). We're using the pink, green and blue from the palette above. The pink dots are hard to see against a white background.

One last thing to check… Does this also apply to bars?

penguins |>
  ggplot(aes(x = island, fill = species)) +
  geom_bar(stat = "count") +
  scale_fill_manual(
    values = c(
      "Chinstrap" = "#ffccff",
      "Gentoo" = "#76ad9d",
      "Adelie" = "#4c6dc6"
    )
  ) +
  my_theme()

A bar graph showing the count of penguins per species and per island. Each species has its own colour. The pink bar is hard to see against a light grey/blue background.

Checking this graph with the colorblindr::cvd_grid() helps illustrate the problem. The Chinstraps basically disappear!

colorblindr::cvd_grid()

A simulation of what this graph looks like with Deutanomaly, Protanomaly, Tritanomaly and a total desaturation of the colours. The pink bars are nearly impossible to see in three of the four simulations.

To fix this, we can either specify a colour for our geom_bar(), or we can add the colour argument to our theme’s geom definitions:

my_theme <- function() {
  theme_minimal() +
    theme(
      text = element_text(family = "Noah", face = "bold"),
      plot.background = element_rect(fill = "#E7E7F1", colour = "#E7E7F1"),
      plot.margin = margin_auto(11),
      geom = element_geom(
        pointshape = 21,
        pointsize = 5,
        ink = "#2c3d4f",
        # This is what we've added 👇
        colour = "#2c3d4f"
      ),
      panel.grid = element_line(colour = "white")
    )
}

Let’s give that another go…

penguins |>
  ggplot(aes(x = island, fill = species)) +
  geom_bar(stat = "count") +
  scale_fill_manual(
    values = c(
      "Chinstrap" = "#ffccff",
      "Gentoo" = "#76ad9d",
      "Adelie" = "#4c6dc6"
    )
  ) +
  my_theme()

A bar graph showing the count of penguins per species and per island. Each species has its own colour. Each bar segment now has a dark blue contour, making the light pink stand out from the background. The bar segments are now clearly visible across the four simulations of colourblindness / desaturation.

colorblindr::cvd_grid()

A bar graph showing the count of penguins per species and per island. Each species has its own colour. Each bar segment now has a dark blue contour, making the light pink stand out from the background. The bar segments are now clearly visible across the four simulations of colourblindness / desaturation.

It worked! 🥳

So there we go, an easy fix for applying colourblind-friendly colour combinations to our graphs, and a way to automate it in R and ggplot.

Happy vizzing!


P.S. It is more tricky for spaghetti graphs, but that’s where dataviz design systems come in handy, clarifying which colours can be used for what!

Reuse

Citation

For attribution, please cite this work as:
Thompson, Cara. 2026. “A Simple Trick for Applying Accessible Colour Palettes Within Dataviz.” July 3, 2026. https://www.cararthompson.com/posts/2026-07-03-accessible-colour-palettes/.