---
title: 'User Preferences'
source: 'https://academia.sh/en/courses/layout-and-responsive-design/user-preferences'
course: 'Layout Systems and Responsive Design'
language: en
updated: '2026-08-17T18:09:29+00:00'
license: 'CC BY-SA 4.0'
---

# User Preferences

Querying color scheme, contrast, forced colors, and reduced-motion preferences, contrast-checking the dark palette, resolving preferences together, and what reduced motion covers.

Up to this point, style has always adapted to the medium's measurable properties: viewport
width, a container's size, the device's pixel ratio. All of these were properties of the
device.

The user themself also announces a preference. They may have chosen a dark color scheme in
their operating system or browser settings, asked for increased contrast, or asked for reduced
motion. These preferences are read with the same query notation, and the page adapts to them.

## A Preference Is a Media Feature

Four media features announce a user preference.

`prefers-color-scheme` gives a preference for a light or dark surface; its values are `light`,
`dark`, and `no-preference`. `prefers-contrast` announces a wish for increased or decreased
contrast. `prefers-reduced-motion` carries a wish for reduced motion, and its one meaningful
value is `reduce`. `forced-colors` announces a separate condition: the user has asked for the
page's colors to be forced into a limited palette they themselves have set, instead of the
page's own.

All four are written the same way as media queries and follow the same cascade rules. The
distinction is not in the question but in the subject: width belongs to the device, preference
belongs to the user.

One design decision follows immediately. Preference queries force style to be written
**additively**: the base style describes the `no-preference` state, and the query block writes
only what changes. Written the other way — with the dark scheme as the base and the light
scheme undone by a query — a user who has announced no preference becomes the exception to the
design.

## Contrast-Checking the Dark Scheme

The dark scheme is not the light scheme's colors inverted. Because relative luminance is not
linear, changing the surface requires recomputing the ratio of every foreground color.

The **contrast ratio** introduced in the Visual Presentation with CSS course is computed from
two colors' relative luminances. The program below checks the station palette in both schemes.

```js
// contrast.mjs — checking contrast ratios for the light and dark color schemes
// Relative luminance and contrast ratio are computed per the WCAG definition.

function hslRgb(h, s, l) {
  s /= 100; l /= 100;
  const k = (n) => (n + h / 30) % 12;
  const a = s * Math.min(l, 1 - l);
  const f = (n) => l - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
  return [f(0), f(8), f(4)];
}

function relativeLuminance([r, g, b]) {
  const d = (c) => (c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4));
  return 0.2126 * d(r) + 0.7152 * d(g) + 0.0722 * d(b);
}

function ratio(color1, color2) {
  const l1 = relativeLuminance(color1), l2 = relativeLuminance(color2);
  const [higher, lower] = l1 > l2 ? [l1, l2] : [l2, l1];
  return (higher + 0.05) / (lower + 0.05);
}

// light scheme: the palette built in the Visual Presentation with CSS course
const LIGHT = {
  surface: [203, 18, 97],
  text: [211, 29, 16],
  textMuted: [211, 12, 45],
  warning: [0, 66, 33],
  line: [207, 15, 86],
};

// dark scheme, only surface and main text changed; the rest left as is
const DARK_INCOMPLETE = {
  surface: [211, 22, 12],
  text: [211, 20, 92],
  textMuted: LIGHT.textMuted,
  warning: LIGHT.warning,
  line: LIGHT.line,
};

const TARGET = { text: 4.5, textMuted: 4.5, warning: 4.5, line: 3 };

function table(title, scheme) {
  console.log(`\n--- ${title} ---`);
  console.log(`surface: hsl(${scheme.surface.join(" ")})`);
  console.log("foreground   hsl           target  ratio  result");
  for (const key of ["text", "textMuted", "warning", "line"]) {
    const r = ratio(hslRgb(...scheme[key]), hslRgb(...scheme.surface));
    console.log(
      `${key.padEnd(11)}  ${("hsl(" + scheme[key].join(" ") + ")").padEnd(14)}` +
        `${(TARGET[key] + ":1").padStart(6)}  ${r.toFixed(2).padStart(5)}  ${(r >= TARGET[key] ? "passes" : "FAILS").padStart(6)}`,
    );
  }
}

table("light scheme", LIGHT);
table("dark scheme: only surface and text changed", DARK_INCOMPLETE);

// correction: for each foreground, the lowest lightness that meets the target is sought
console.log("\n--- lightness values needed in the dark scheme ---");
console.log("foreground   current L  needed L  current ratio  corrected ratio");
for (const key of ["text", "textMuted", "warning", "line"]) {
  const [h, s, l] = DARK_INCOMPLETE[key];
  let needed = l;
  while (needed <= 100 && ratio(hslRgb(h, s, needed), hslRgb(...DARK_INCOMPLETE.surface)) < TARGET[key]) {
    needed += 0.5;
  }
  console.log(
    `${key.padEnd(11)}  ${l.toFixed(1).padStart(9)}  ${needed.toFixed(1).padStart(8)}  ` +
      `${ratio(hslRgb(h, s, l), hslRgb(...DARK_INCOMPLETE.surface)).toFixed(2).padStart(14)}  ` +
      `${ratio(hslRgb(h, s, needed), hslRgb(...DARK_INCOMPLETE.surface)).toFixed(2).padStart(15)}`,
  );
}
```

```

--- light scheme ---
surface: hsl(203 18 97)
foreground   hsl           target  ratio  result
text         hsl(211 29 16) 4.5:1  13.93  passes
textMuted    hsl(211 12 45) 4.5:1   4.59  passes
warning      hsl(0 66 33)   4.5:1   8.56  passes
line         hsl(207 15 86)   3:1   1.30   FAILS

--- dark scheme: only surface and text changed ---
surface: hsl(211 22 12)
foreground   hsl           target  ratio  result
text         hsl(211 20 92) 4.5:1  13.90  passes
textMuted    hsl(211 12 45) 4.5:1   3.42   FAILS
warning      hsl(0 66 33)   4.5:1   1.83   FAILS
line         hsl(207 15 86)   3:1  12.08  passes

--- lightness values needed in the dark scheme ---
foreground   current L  needed L  current ratio  corrected ratio
text              92.0      92.0           13.90            13.90
textMuted         45.0      53.0            3.42             4.54
warning           33.0      61.0            1.83             4.53
line              86.0      86.0           12.08            12.08
```

In the light scheme, all three text colors meet the 4.5:1 criterion. The divider line, at a
1.30 ratio, is below the 3:1 criterion — this is not a flaw, it is a question of scope. The
WCAG 1.4.11 criterion requires non-text contrast for the boundaries of interface components
and status indicators; a row divider in a table is decorative. If the same color were used as
an input's border or a focus indicator, it would fail the criterion and would need to be
darkened.

In the dark scheme, the table flips. The surface and main text were changed; the ratio between
the two is 13.90 and looks fine. But the two colors that were not changed fall: muted text to
3.42, the warning color to 1.83. The warning color is the color meant to alert the user, and it
is nearly unreadable on a dark surface.

The third block computes the fix. Once muted text's lightness is raised from 45 to 53, and the
warning color's from 33 to 61, the criterion is met. The 28-point increase needed for the
warning color shows directly why a plain inversion is not enough: red's relative luminance is
low, and it needs to move to a much lighter tone to be distinguishable on a dark surface.

The rule is this: a dark scheme is a recomputation of **every** color in the light scheme. An
unchanged color is a color that silently falls outside the criterion.

## Resolving Preferences Together

A user can announce more than one preference at once. Since the queries are independent of
each other, the result is the cascade combination of every matching block.

```js
// preferences.mjs — resolving user preference queries and determining the outcome

// Rules are in source order; the last one whose condition is true wins.
const RULES = [
  { condition: null, value: { surface: "light", line: "thin", transition: "200ms", scroll: "smooth" } },
  { condition: "prefers-color-scheme: dark", value: { surface: "dark" } },
  { condition: "prefers-contrast: more", value: { line: "bold" } },
  { condition: "prefers-reduced-motion: reduce", value: { transition: "1ms", scroll: "instant" } },
  { condition: "forced-colors: active", value: { surface: "system", line: "system" } },
];

const PROFILES = [
  { name: "default", "prefers-color-scheme": "light", "prefers-contrast": "no-preference", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "dark scheme", "prefers-color-scheme": "dark", "prefers-contrast": "no-preference", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "dark + reduced motion", "prefers-color-scheme": "dark", "prefers-contrast": "no-preference", "prefers-reduced-motion": "reduce", "forced-colors": "none" },
  { name: "high contrast", "prefers-color-scheme": "light", "prefers-contrast": "more", "prefers-reduced-motion": "no-preference", "forced-colors": "none" },
  { name: "forced colors", "prefers-color-scheme": "dark", "prefers-contrast": "more", "prefers-reduced-motion": "reduce", "forced-colors": "active" },
];

function matches(condition, profile) {
  if (condition === null) return true;
  const [feature, value] = condition.split(":").map((s) => s.trim());
  return profile[feature] === value;
}

function resolve(profile) {
  const result = {};
  const matched = [];
  for (const r of RULES) {
    if (!matches(r.condition, profile)) continue;
    matched.push(r.condition ?? "base");
    Object.assign(result, r.value);
  }
  return { result, matched };
}

console.log("rules:");
for (const r of RULES) {
  console.log(`  ${(r.condition ?? "(base)").padEnd(32)} ${JSON.stringify(r.value)}`);
}

console.log("\nprofile                 surface  line     trans  scroll    matched rule count");
for (const p of PROFILES) {
  const { result, matched } = resolve(p);
  console.log(
    `${p.name.padEnd(23)}  ${result.surface.padEnd(7)}  ${result.line.padEnd(7)}  ` +
      `${result.transition.padStart(5)}  ${result.scroll.padEnd(8)}  ${String(matched.length).padStart(19)}`,
  );
}

// color-scheme declaration: which scheme browser controls and the scrollbar use
console.log("\n--- the color-scheme declaration's effect on browser controls ---");
console.log("user preference    color-scheme decl.     controls    page     compatible");
for (const preference of ["light", "dark"]) {
  for (const declaration of ["normal", "light", "light dark"]) {
    const supported = declaration.split(" ").includes(preference);
    const controls = supported ? preference : declaration === "normal" ? "light" : declaration.split(" ")[0];
    const page = preference;   // the page's own colors are already adapted by query
    console.log(
      `${preference.padEnd(19)}  ${declaration.padEnd(22)}  ${controls.padEnd(10)}  ${page.padEnd(7)}  ` +
        `${(controls === page ? "yes" : "NO").padStart(9)}`,
    );
  }
}

// what reduced motion reduces: policy by property kind
console.log("\n--- duration by property kind under the reduced-motion preference ---");
const MOTIONS = [
  { name: "translate/scale", kind: "displacement", base: 300 },
  { name: "rotate", kind: "displacement", base: 400 },
  { name: "opacity", kind: "visibility", base: 200 },
  { name: "background-color", kind: "color", base: 150 },
  { name: "continuously spinning ribbon", kind: "continuous motion", base: 8000 },
];
console.log("property                       kind                base    reduced     decision");
for (const m of MOTIONS) {
  const removed = m.kind === "displacement" || m.kind === "continuous motion";
  const reduced = removed ? 0 : m.base;
  const decision = removed ? "removed" : "kept";
  console.log(
    `${m.name.padEnd(30)}  ${m.kind.padEnd(18)}  ${String(m.base + "ms").padStart(6)}  ` +
      `${String(reduced + "ms").padStart(10)}  ${decision.padStart(11)}`,
  );
}
```

```
rules:
  (base)                           {"surface":"light","line":"thin","transition":"200ms","scroll":"smooth"}
  prefers-color-scheme: dark       {"surface":"dark"}
  prefers-contrast: more           {"line":"bold"}
  prefers-reduced-motion: reduce   {"transition":"1ms","scroll":"instant"}
  forced-colors: active            {"surface":"system","line":"system"}

profile                 surface  line     trans  scroll    matched rule count
default                  light    thin     200ms  smooth                      1
dark scheme              dark     thin     200ms  smooth                      2
dark + reduced motion    dark     thin       1ms  instant                     3
high contrast            light    bold     200ms  smooth                      2
forced colors            system   system     1ms  instant                     5

--- the color-scheme declaration's effect on browser controls ---
user preference    color-scheme decl.     controls    page     compatible
light                normal                  light       light          yes
light                light                   light       light          yes
light                light dark              light       light          yes
dark                 normal                  light       dark            NO
dark                 light                   light       dark            NO
dark                 light dark              dark        dark           yes

--- duration by property kind under the reduced-motion preference ---
property                       kind                base    reduced     decision
translate/scale                 displacement         300ms         0ms      removed
rotate                          displacement         400ms         0ms      removed
opacity                         visibility           200ms       200ms         kept
background-color                color                150ms       150ms         kept
continuously spinning ribbon    continuous motion   8000ms         0ms      removed
```

The first table shows that preferences do not block each other. When dark scheme and reduced
motion are both announced, both apply; each query writes its own property and does not
overwrite what the others wrote.

The last row flags one exception. While forced colors is active, most of the page's color
declarations are ignored and a limited palette set by the system takes their place. This is a
strict requirement for the user; what the page must do is leave **another** distinguishing tool
in place of color. A state distinguished by color alone — showing a missing measurement in red,
for instance — is entirely lost under forced colors; the same information must also be given
with an icon or text.

## The Browser's Own Parts

The second table shows an area where changing the page's colors is not enough.

The scrollbar, the default look of form controls, the text selection color — these are parts
the browser draws, and they are not affected by the page's color declarations. The
`color-scheme` declaration tells them which scheme to use.

When the declaration is not written, or only `light` is written, controls stay light even if
the user prefers a dark scheme: a bright white scrollbar and light-surfaced inputs appear on a
dark page. A `color-scheme: light dark` declaration announces support for both schemes, and the
controls then follow the user's preference.

The declaration has a second consequence: the `light-dark()` function only works once a scheme
is selected. The function takes two values and picks one based on the active scheme; it allows
separating dark-scheme rules at the value level instead of writing them into a separate query
block.

## What Reduced Motion Reduces

The third table arrives at this lesson's real question.

The name of the `prefers-reduced-motion` preference can be misleading: the preference asks not
for **animation to be turned off** but for motion to be reduced. The distinction is made by
looking at which property changes.

An element's **displacement** — sliding, rotating, scaling — produces movement for the eye.
For users with vestibular sensitivity, large or unexpected movement can cause dizziness and
nausea. A user who announces the preference wants to avoid this movement.

A change in an element's **visibility** or **color** produces no movement. A button's
background changing over 150 milliseconds on hover can be kept even under the reduced-motion
preference — it should even be kept, because this transition makes a state change easier to
read.

The table's decision column applies this distinction: motion that produces displacement and
repeats continuously is removed, visibility and color transitions are kept. Zeroing every
transition with a single rule — writing a zero duration on the universal selector — is an easy
fix, but it makes the interface abrupt and hard to read.

The WCAG 2.3.3 criterion describes this policy directly: motion animation triggered by
interaction must be able to be turned off, unless it is essential to the function.

```css
/* station.css — step 12: user preferences */
:root {
  color-scheme: light dark;

  --surface:    hsl(203 18% 97%);
  --text:       hsl(211 29% 16%);
  --text-muted: hsl(211 12% 45%);
  --warning:    hsl(0 66% 33%);
  --line:       hsl(207 15% 86%);
  --transition-duration: 200ms;
}

@media (prefers-color-scheme: dark) {
  :root {
    --surface:    hsl(211 22% 12%);
    --text:       hsl(211 20% 92%);
    --text-muted: hsl(211 12% 53%);
    --warning:    hsl(0 66% 61%);
    --line:       hsl(207 15% 30%);
  }
}

@media (prefers-contrast: more) {
  :root {
    --text-muted: var(--text);
    --line: var(--text);
  }
}

@media (prefers-reduced-motion: reduce) {
  :root { --transition-duration: 1ms; }
  html { scroll-behavior: auto; }
}

@media (forced-colors: active) {
  .measurement-table .missing { forced-color-adjust: none; }
  .measurement-table .missing::before { content: "! "; }
}
```

Five blocks, five separate questions. The base block defines the light scheme and the default
duration; the remaining four write only the custom properties that change. The lightness
values in the dark scheme are the result of the computation above: muted text at 53, warning at
61. The forced-colors block adds a text distinction in place of color — a missing measurement
stays visible with a mark even when color is gone.

Because every declaration passes through custom properties, component rules never change. When
a card, a table row, or a button writes `var(--text)`, the combination of all four preferences
is already resolved.

## Summary

- Four media features announce a user preference: color scheme, contrast, reduced motion, and
  forced colors; all are written the same way as media queries.
- The base style describes a user who has announced no preference; query blocks write only
  what changes and undo none of it.
- A dark scheme is not the light scheme translated: when the surface changes, every foreground
  color's ratio has to be recomputed; in the station palette, the warning color's lightness has
  to rise from 33 to 61.
- Preferences are independent of each other and all apply when announced together; while
  forced colors is active, distinctions given by color alone are lost and need a text or icon
  fallback.
- The `color-scheme` declaration tells the browser's own drawn parts which scheme to follow;
  when not written, light controls remain on a dark page.
- The reduced-motion preference removes motion that produces displacement and repeats
  continuously; visibility and color transitions are kept, because they produce no movement.

## Next Step

The last table built a distinction but used it without defining it: which property "produces
displacement"? Declarations that slide, rotate, and scale an element share a common name and a
common mathematics; deciding which motion to reduce without seeing both is left to intuition.
The next topic starts from here: how is an element translated, scaled, and rotated without
breaking its own box, in what order are these operations applied, and why does the order
change the result?
