Lesson 12 / 23
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.
Contents
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.
// 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.
// 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.
/* 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-schemedeclaration 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?
To keep your progress and take notes, Log in
My notes
Log in to take notes.