2024-03-25 Implementation Plan: Dark Mode¶
Author: @zackkrida
Reviewers¶
[x] @obulat
[x] @sarayourfriend
Project links¶
Overview¶
This dark mode implementation plan is comprised of three work streams:
Color management in Tailwind and Vue frontend components
Toggling dark mode (new UI and dark mode detection logic)
Visual regression tests and feature flagging
Most of these work streams can happen in parallel which I will elaborate on in the “Implementation” section.
Design Philosophy¶
Understanding the way @fcoveram has designed the color system for dark mode is crucial to understanding this implementation. Quite simply and elegantly, the designs use a “palette swap” approach in which each color has a 1:1 replacement from light mode to dark mode.

While we will include easy mechanisms for exceptions to this rule, they do not appear to be necessary based on the designs. Implementing our dark mode, then, should allow component authors to write components once, using semantic color names, that will automatically switch between their light and dark mode counterparts.
Implementation¶
We will switch our color names defined in the tailwind configuration to use
semantic names, for example replacing “pink” with “primary” and “yellow” with
“complementary”. Instead of hardcoding these colors in the Tailwind
configuration, the tailwind configuration will reference CSS variables defined
in our root css file. The value of the CSS variables will be switched based on a
dark mode CSS class added to the HTML root when dark mode is enabled and the
prefers-color-scheme media query.
Tailwind’s built in dark: modifier can be used for any styles which need to
override the default behavior or add dark-mode specific styles beyond the core
palette swap. We will inform tailwind of our dark mode setup using the Tailwind
configuration’s darkMode property.
Here are simplified examples of this setup. All the logic is correct and suitable for production but the actual variables are illustrative only.
In our primary CSS file, we define CSS variables and redefine them to use our dark mode values in two scenarios:
When the
.dark-modeclass is presentwhen the user’s preferred color scheme is dark, and the
.light-modeclass is not present.
:root,
:is(.light-mode *) {
--color-primary: black;
--color-secondary: white;
}
:is(.dark-mode *) {
--color-primary: white;
--color-secondary: black;
}
@media (prefers-color-scheme: dark) {
:not(.light-mode *) {
--color-primary: white;
--color-secondary: black;
}
}
In our Tailwind configuration, we reference these variables like so:
const config = {
theme: {
colors: {
primary: "var(--color-primary)",
secondary: "var(--color-secondary)",
},
},
}
In a component, we use one Tailwind class to implement the correct color in light and dark modes:
<template>
<!-- This will be black in light mode and white in dark mode -->
<p class="text-primary">Hello World</p>
</template>
Finally, if we ever need an “escape hatch” to make sure, a component is, for example, always black regardless of dark mode:
<template>
<!-- This will be black in light mode and black in dark mode -->
<p class="text-primary dark:text-secondary">Hello World</p>
</template>
The escape hatch requires the following Tailwind configuration:
/** @type {import('tailwindcss').Config} */
module.exports = {
darkMode: [
"variant",
[
"@media (prefers-color-scheme: dark) { &:not(.light-mode *) }",
"&:is(.dark-mode *)", // :is is so the specificity matches and there's not unexpected behavior
],
],
// ...
}
You can learn more about this configuration in the Tailwind docs.
Rejected alternative approach¶
Using the dark: modifier exclusively for dark mode styling. This is more
explicit, but much, much more verbose, and would require extensive edits to
every single component we have written. Instead of writing bg-background, for
example, we would have to write bg-white dark:bg-black all throughout the
codebase.
Expected Outcomes¶
Users can switch between available color schemes in Openverse.
Openverse displays the correct color scheme for the user:
Render Dark mode for:
Users with no theme selected from our UI and
prefers-color-scheme: darkUsers who previously selected “Dark” in our UI and
prefers-color-scheme: darkUsers who previously selected “Dark” in our UI and
prefers-color-scheme: light
Render Light mode for:
Users with no theme selected from our UI and
prefers-color-scheme: lightUsers who previously selected “Light in our UI” and
prefers-color-scheme: lightUsers who previously selected “Light in our UI” and
prefers-color-scheme: dark
Opvenverse does not display a “Flash of inAccurate Color Scheme (FART)”
Secondarily, there are additional devex outcomes worth mentioning:
Users writing components will have a “dark mode compatible by default” experience. By default, they will not need to think much about dark mode.
Color names in the codebase will be replaced with semantic names.
Frontend developers will have easy tools to visually test components in light and dark mode.
Step-by-step implementation plan¶
The following plan requires approved designs and semantic color names. Each task is a discrete issue and pull request. The top-level “Work Streams” can be completed in parallel.
Work Stream A: Implement the new color palette.
Create a
FORCE_DARK_MODEfeature flag which is “off” by default and “switchable” in our staging environment. Add adark-modeclass to the root HTML tag when this flag is enabled, and alight-modeclass which is set by default. This will not result in any visual changes.The following steps can take place in parallel:
Rename all colors in the Tailwind configuration and the frontend components to use new, semantic names (specific names TBD). This will not result in any visual changes. This is likely to be the largest PR to review as it is a global find/replace across the entire frontend.
Replace the “hardcoded” color values in the Tailwind configuration file with css variables defined in the “base” layer of the
tailwind.cssfile. This will not result in any visual changes.
Add the dark mode colors as CSS variable definitions, nested under the
.dark-modeCSS class, in the “base” layer of thetailwind.cssfile. This will not result in any visual changes, except whenFORCE_DARK_MODEis enabled.Visual Regression tests. Update
frontend/test/playwright/utils/breakpoints.tsso that each breakpoint produces and expects a dark mode screenshot to pass as well as the existing light mode screenshot. This will also be a significant diff, as at the time of writing it will create 293 new screenshots to review. This is also the point of the process where @fcoveram and @wordpress/openverse-frontend should review the full dark mode appearance for correctness and sufficient color contrast (see the “Accessibility” section for more details. Existing screenshots should be renamed to<snapshot_name>_lightand the new dark mode screenshots should be named<snapshot_name>_dark.
Work Stream B: Toggling dark mode
Create a
DARK_MODE_UI_TOGGLEfeature flag which is off by default and switchable in staging.Implement logic for calculating a current “color mode” with the following state:
interface ColorMode { preference: "dark" | "light" | "system" // Defaults to "system" systemValue: "light" | "dark" // Readonly representation of the system value }
The color mode should be stored in a cookie so that a previously-selected user choice can be used when rendering via SSR and prevent a visual flash of the incorrect color mode. The “system” value is the default.
Behind the feature flag, add the new user interface element which toggles dark mode (exact design TBD, but it will be comprised of existing UI components). Supports choosing between “dark”, “light”, and “system” modes, with “system” as the default. The “dark” and “light” options will set a
.dark-modeor.light-modeclass on the HTML element of the site. The default “system” choice does not add a class to the HTML element. When there is no HTML.{color}-modeclass present, the site will default to theprefers-color-schememedia query value.Add a
TOGGLE_COLOR_SCHEMEanalytics event with a playload including the color mode preference chosen by the user.Update our Cloudflare static page caching rule for the frontend in with a Cookie bypass rule (in pseudocode, something like `and not http.cookie contains “openverse_color_scheme”))
Launch plan¶
See the “Rollback” section for details on how to revert this deployment.
Set the
DARK_MODE_UI_TOGGLEfeature flag to “on” in all environments.Test and verify the staging deploy was successful and that dark mode:
Looks correct
The toggle works correctly (chosen settings persist, the control works with keyboard, etc.)
Deploy the production frontend and verify proper behavior after deployment.
Make a post on make.wordpress.org/openverse announcing the new dark mode.
Create a “Request for Amplification” with the WordPress marketing team.
Infrastructure¶
We will need one infrastructure pull request to update our Cloudflare frontend caching. Specifically, we need to update our cache rule called “Cache static pages” to bypass the cache when the color scheme cookie is present.
Accessibility¶
The majority of accessibility considerations should have already been addressed in the design stage. When implementing dark mode the main priority is maintaining sufficient color contrast.
The actual UI toggle for dark mode should be written accessibly using our existing components.
Rollback¶
This can be rolled back in a critical scenario by hiding the UI control for dark
mode and hardcoding the “color mode” to light for all users. The later step
must be taken to guarantee that any previously-set user color preferences are
ignored.
Finally, in the event of a full rollback we would:
Remove the dark mode CSS variables from the base CSS file
Remove the test utility and feature flags
Delete the dark mode visual regression test screenshots
Delete or revise any marketing content
Delete the cookie detection logic from our static page caching rule
Risks¶
This plan is designed to limit risk intentionally. One potential risk is that our dark mode could evolve significantly over time, making the “palette swap” strategy less effective due to numerous exceptions to the rule. If this were to occur the approach chosen here would become inconvenient and verbose.