RizTech Academy logo
RizTech Academy
Responsive DesignLesson 6 of 730 min

Dark mode, reduced motion and respecting preferences

Your visitor has already told their device how they want to be treated. They set dark mode, or asked for less motion, or increased their font size. All of that reaches your CSS as a media query, and honouring it is mostly free.

Ignoring it is not neutral — for some of these the consequence is nausea, or a site somebody cannot read.

Dark mode

:root {
  --bg: #ffffff;
  --surface: #f7f7f7;
  --text: #1f1f1f;
  --text-muted: #5c5c5c;
  --border: #e2e2e2;
  --accent: #1a4d2e;
}

@media (prefers-color-scheme: dark) {
  :root {
    --bg: #14181a;
    --surface: #1e2427;
    --text: #e8eaea;
    --text-muted: #a3adad;
    --border: #2f3a3d;
    --accent: #6ec48f;
  }
}

body {
  background: var(--bg);
  color: var(--text);
}

Define colours as custom properties once, then redefine them in the query. Every rule that uses var(--bg) follows automatically. The alternative — a dark override for every component — is unmaintainable, and you will miss something.

This is also why the colours lesson insisted on custom properties rather than hex values scattered through the file.

color-scheme, the one line people miss

:root {
  color-scheme: light dark;
}

This tells the browser your page handles both, and it changes things you cannot style yourself: form control appearance, scrollbars, the default background before your CSS loads, and ::selection. Without it you get a dark page with light scrollbars and white-on-white form fields, which looks broken in a way that is hard to diagnose.

One line, and it is the most common omission in a dark-mode implementation.

Dark mode is not inverted light mode

Four things that go wrong if you just swap the two ends:

Pure black is worse than dark grey. #000 against light text causes halation — the text appears to glow and smear, especially for astigmatic readers. Use something around #14181a.

Pure white text is too bright. #fff on near-black is harsh over a paragraph. Use around #e8eaea.

Saturated colours vibrate. A brand green that works on white looks fluorescent on near-black. Dark-mode accents want to be lighter and less saturated — note #6ec48f above rather than #1a4d2e.

Shadows stop working. A shadow is darkness, and there is no darker. In dark mode, elevation comes from a lighter surface, which is what --surface is for:

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 1px 3px rgb(0 0 0 / 0.08);
}

@media (prefers-color-scheme: dark) {
  .card { box-shadow: none; }     /* the lighter surface does the work */
}

Images in dark mode

@media (prefers-color-scheme: dark) {
  img:not([src$=".svg"]) { filter: brightness(0.9); }
}

A slight dimming stops photographs glaring out of a dark page. Use it sparingly — never on a product photograph where colour accuracy matters to a customer.

For a logo that needs a different version:

<picture>
  <source srcset="logo-dark.svg" media="(prefers-color-scheme: dark)">
  <img src="logo-light.svg" alt="Sharma Kirana" width="160" height="40">
</picture>

And check your contrast in both modes. A muted grey that passes 4.5:1 on white frequently fails on near-black, because the two are not symmetrical.

A manual toggle

Respecting the system setting is the baseline. Some visitors want to override it for your site specifically, and the pattern that supports both:

:root { --bg: #fff; --text: #1f1f1f; color-scheme: light dark; }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) { --bg: #14181a; --text: #e8eaea; }
}

:root[data-theme="dark"] { --bg: #14181a; --text: #e8eaea; }

Three blocks: the light default, the system preference unless the visitor chose light, and an explicit dark choice. Then a button sets data-theme on <html> and stores it. The JavaScript is four lines and outside this course, but the CSS structure is the part that has to be right — and note that a toggle with only [data-theme="dark"] and no prefers-color-scheme block ignores the system setting for everybody who never touches the button.

prefers-reduced-motion

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

This is not a taste setting. Somebody who switched on "reduce motion" may have a vestibular disorder, where parallax scrolling and large sliding transitions cause genuine nausea and dizziness. Others have switched it on because motion triggers migraine, or because they get motion sickness.

The block above is the standard safety net. !important is justified — it is one of the few genuine exceptions, because it must win against every animation in the codebase including ones added later by somebody who forgot.

0.01ms rather than 0 deliberately: a duration of zero can prevent transitionend and animationend events from firing, and JavaScript that waits for them then hangs.

Better than switching motion off is offering less of it. The rule is about vestibular triggers — large movement, parallax, zoom, spin — not about all change. A fade is usually fine:

.panel { transition: transform 300ms, opacity 300ms; }

@media (prefers-reduced-motion: reduce) {
  .panel { transition: opacity 300ms; }    /* keep the fade, drop the slide */
}

And the opposite approach, which is arguably the right default: animate only when it is welcome.

@media (prefers-reduced-motion: no-preference) {
  .card { transition: transform 200ms; }
  .card:hover { transform: translateY(-2px); }
}

Now no motion exists unless the visitor has expressed no objection. Nothing to remember to switch off later.

Also note scroll-behavior: smooth — a page-length smooth scroll is exactly the kind of motion that causes trouble, so it belongs inside no-preference or be overridden in the safety net above.

prefers-contrast and forced-colors

@media (prefers-contrast: more) {
  :root { --text: #000; --text-muted: #333; --border: #000; }
  .button { border: 2px solid currentColor; }
}

Somebody asking for more contrast is telling you your muted greys are not readable. Darken the text and make borders explicit.

forced-colors: active is Windows high contrast mode, and it is different in kind: the OS replaces your colours entirely with the user's chosen palette. You are not being asked to adjust; you are being overridden.

@media (forced-colors: active) {
  .button { border: 1px solid ButtonText; }
  .card { border: 1px solid CanvasText; }
}

What breaks there, and why it matters:

  • box-shadow and background-image are not rendered. So a focus ring made of box-shadow vanishes, and a button distinguished only by its background becomes invisible. This is why the backgrounds lesson recommended outline for focus.
  • Anything conveyed only by colour disappears, because the colours are replaced.
  • Use system colour keywords — CanvasText, Canvas, ButtonText, LinkText, Highlight — which map to whatever the user chose.

The practical rule: give every meaningful boundary a border, not just a background or a shadow. Then forced-colors mode works with no special handling.

The others worth knowing

@media (prefers-reduced-transparency: reduce) {
  .glass { backdrop-filter: none; background: var(--surface); }
}

@media (prefers-reduced-data: reduce) {
  /* skip decorative background images */
}

@media (inverted-colors: inverted) { }

prefers-reduced-data is the interesting one for an Indian audience — a visitor on a metered connection signalling they want less. Support is limited, so treat it as an enhancement rather than a strategy; the real answer to data cost is the responsive-images lesson.

Testing all of this

You do not need to change your OS settings every time. In Chrome devtools, open the command menu with Ctrl + Shift + P (Cmd + Shift + P), type "Show Rendering", and the Rendering panel lets you emulate:

  • prefers-color-scheme
  • prefers-reduced-motion
  • prefers-contrast
  • forced-colors
  • vision deficiencies, including several kinds of colour blindness

Firefox has the same under its own Rendering settings. Emulate every one of these once on your own page — it takes five minutes and you will find something.

Do also test dark mode against your real OS setting at least once, because the emulation does not cover the browser chrome or the pre-CSS flash.

Check your work

How to implement dark mode maintainably. Custom properties redefined in the query, not per-component overrides.

The one line people miss. color-scheme: light dark, which fixes form controls, scrollbars and the pre-CSS background.

Why not pure black. Halation — light text on #000 appears to smear.

What happens to saturated colours. They vibrate; dark-mode accents want to be lighter and less saturated.

Why shadows stop working in dark mode. There is nothing darker; elevation comes from a lighter surface.

What to check in both modes. Contrast — the two ends are not symmetrical.

What a toggle must not omit. The prefers-color-scheme block, or the system setting is ignored for anybody who never presses the button.

Why reduced motion is not a taste setting. Vestibular disorders, migraine, motion sickness.

Why 0.01ms rather than 0. Zero can stop transitionend firing and hang JavaScript that waits for it.

The better approach than switching motion off. Only animate inside (prefers-reduced-motion: no-preference).

What forced-colors: active does. Replaces your colours entirely — and does not render box-shadow or background-image.

The practical rule for forced colours. Give every meaningful boundary a real border.

Where to test all of this. The devtools Rendering panel.

Practice

  1. Convert a page's colours to custom properties and add a prefers-color-scheme: dark block. Toggle it in the devtools Rendering panel.
  2. Omit color-scheme: light dark, then add it. Compare the scrollbars and a <select>.
  3. Set the dark background to #000 and read a paragraph of light text. Then #14181a.
  4. Use your light-mode brand colour as the dark-mode accent, then lighten and desaturate it. Compare.
  5. Check your muted grey's contrast in both modes with the devtools Accessibility pane. Find one that passes in light and fails in dark.
  6. Put a box-shadow card in dark mode, then replace the elevation with a lighter surface.
  7. Swap a logo for a dark version with <picture> and prefers-color-scheme.
  8. Build the three-block toggle CSS. Then remove the prefers-color-scheme block and explain who is now ignored.
  9. Add the reduced-motion safety net, emulate the setting, and confirm your animations stop.
  10. Use 0 instead of 0.01ms for a transition whose transitionend you listen for.
  11. Rewrite one animation to live inside (prefers-reduced-motion: no-preference).
  12. Keep a fade but drop a slide under reduced motion.
  13. Emulate forced-colors: active and find every element that becomes invisible. Fix them with borders.
  14. Build a focus ring with box-shadow, emulate forced colours, and watch it vanish. Switch to outline.
  15. Emulate a vision deficiency in the Rendering panel and check nothing in your page depends on colour alone.

Official documentation

Next: testing on devices you do not own.

Stuck on this lesson?

Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.

About the internship