The Four User-Preference Media Queries Your CSS Should Honor

Want more of these in your Google results?
An article card looks finished in the design file. It has a hero with a bright orb that floats across it, a title, a summary, and a Save button, and it’s lovely. Then it ships, and it starts answering the wrong questions for real people. Someone who turned on Reduce Motion still gets the hero orb gliding around the whole time. Someone on a touch screen never finds the Save button, because it only appears on hover. If that hero were a large image or video, someone on a metered connection would still get the full asset, and that last one turns out to be a question no media query can answer for us today, a lesson we’ll reach near the end. The card resizes itself perfectly as the window narrows, so it feels responsive, but the only thing it actually responds to is the width of the viewport.
The CSS that produced it is the CSS most of us write first.
global.css
/* global.css, the breakpoint-only card */
.card {
display: grid;
gap: 0.75rem;
}
@media (width >= 40rem) {
.card {
grid-template-columns: 12rem 1fr;
}
}That’s a real adaptation, and width is a real signal. It just isn’t the only thing the browser knows about the person in front of it. The same media-query syntax that reports the viewport width also reports whether the user asked for reduced motion, whether they want more contrast, whether the operating system has forced a high-contrast palette, what kind of pointer they’re using, and a few more besides. Each of those is an input the user or the device already handed you. The card above reads exactly one of them.
Drag the width slider in the first demo and watch the layout reflow once it passes the breakpoint. Then flip the Reduce motion switch beside it, and the readout reports that the card has no branch for it, so the request changes nothing. The card answers the viewport and ignores the person.
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
/**
* The naive "before" card the article opens against, the one that answers the viewport and ignores the person.
*
* It adapts to a single `width` breakpoint and nothing else, so it feels responsive while reading exactly one signal.
* The simulated reduced-motion switch is ignored on purpose, so it never stops the floating orb the way it does on the finished card, which is the failure the opener demonstrates.
* The real `prefers-reduced-motion` query is still honored, so a visitor who set Reduce Motion never gets the loop, since the published page owes a real person stillness even while it teaches broken code.
* The Save action is hidden with `display: none` until `:hover`, with no `:focus-within`, so it leaves the tab order and a touch or keyboard user can never reach it.
* This is deliberately broken so the opener is literally true on screen; the finished `<adaptive-card>` is the version that honors every signal.
*
* It accepts the same demo hooks as `<adaptive-card>` so one harness can drive both cards, but it reads only `wide`.
* The `sim-reduced-motion`, `sim-contrast`, and `sim-forced-colors` hooks are accepted and ignored, which is exactly the broken behavior: a switch that visibly changes the finished card does nothing here.
*
* @element broken-card
* @attr wide - The one demo hook the broken card honors, forcing the two-column width layout the way a slider drives the real width query.
* @attr sim-reduced-motion - Accepted for harness parity and ignored, so the hero keeps animating under it, the broken behavior.
* @attr sim-contrast - Accepted for harness parity and ignored, so the hierarchy never strengthens under it, the broken behavior.
* @attr sim-forced-colors - Accepted for harness parity and ignored, so the card never adopts the system palette under it, the broken behavior.
*/
@customElement( 'broken-card' )
export class BrokenCard extends LitElement {
/**
* The article title shown as the card's heading and its accessible name.
*/
@property( { type: String } )
heading = '';
/**
* The article summary shown under the heading.
*/
@property( { type: String } )
summary = '';
/**
* A demo-only hook that forces the two-column width layout, the one signal this card reads.
*
* Production ships the reflow as a real `@media ( width >= 40rem )` query, and this `:host([wide])` twin stands in for it so a width slider can drive the same reflow inside a fixed iframe a viewport query cannot read.
* The demo component drives the reflow from this attribute alone, so a 360px frame stays one column instead of reading the wide iframe around it.
* It is the only hook the broken card honors, which is the whole point: it answers width and nothing else.
* Defaults to `false`, so the card stays one column until the slider passes the breakpoint.
*/
@property( { type: Boolean, reflect: true } )
wide = false;
/**
* A demo hook accepted for parity with `<adaptive-card>` and deliberately ignored.
*
* The finished card stops the hero under this hook, and the broken card ignores it on purpose, so the simulated request never stops the orb, which is the failure the demo shows.
* The real `prefers-reduced-motion` query is still honored in CSS, separately from this hook, so an actual Reduce Motion visitor never sees the loop.
*/
@property( { type: Boolean, attribute: 'sim-reduced-motion', reflect: true } )
simReducedMotion = false;
/**
* A demo hook accepted for parity with `<adaptive-card>` and deliberately ignored.
*
* The finished card strengthens its whole hierarchy under this hook, and the broken card carries no contrast branch, so nothing changes.
* It reflects so a harness can target both cards the same way, but no CSS here responds to it, which is the failure the demo shows.
*/
@property( { type: Boolean, attribute: 'sim-contrast', reflect: true } )
simContrast = false;
/**
* A demo hook accepted for parity with `<adaptive-card>` and deliberately ignored.
*
* The finished card adopts the system color keywords under this hook, and the broken card holds its brand colors, so it fights the palette instead of integrating.
* It reflects so a harness can target both cards the same way, but no CSS here responds to it, which is the failure the demo shows.
*/
@property( { type: Boolean, attribute: 'sim-forced-colors', reflect: true } )
simForcedColors = false;
/**
* Styles for the broken card, scoped to its shadow root.
*
* The only adaptation here is the width breakpoint, on purpose, mirrored by the `wide` attribute for the demo slider.
* There is no branch for the simulated contrast or forced-colors hooks, so the hierarchy never strengthens and the palette is never adopted, and Save is gated behind hover alone, so it never reaches touch or keyboard.
* The one real preference the broken card still honors is `prefers-reduced-motion`, a safety floor that leaves the orb still for a visitor who asked for no motion even while the simulated switch stays ignored.
*/
static styles = css`
/* The host is the layout container, so the two-column reflow can key off the card's own width and never off a fixed iframe. */
:host {
display: block;
container-type: inline-size;
color: var( --ink, #17171a );
font-family: var( --font-sans, system-ui, sans-serif );
}
.card {
display: grid;
gap: var( --space-4, 16px );
max-width: 34rem;
padding: var( --space-4, 16px );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-lg, 14px );
}
/* The one signal the broken card reads: the viewport width. */
/* This is the real production query, the broken card's single correct adaptation, since width is the one signal it answers. */
/* Both tracks are minmax( 0, ... ) so neither can overflow its share, and align-items: start keeps each column at its own content height, so the two columns never overlap. */
@media ( width >= 40rem ) {
.card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
align-items: start;
}
}
/* The demo twin of the width query above: the wide attribute drives the same reflow from the width slider, since a viewport query cannot read the fixed demo frame. */
:host( [wide] ) .card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
align-items: start;
}
/* This :host( :not( [wide] ) ) pin is a demo-only seam that holds the reflow to the wide hook so the slider drives it, outranking and overriding the viewport @media that would otherwise leak through the demo iframe and force two columns early, and production deletes it along with the other seams. */
:host( :not( [wide] ) ) .card {
grid-template-columns: minmax( 0, 1fr );
}
/* A production robustness floor that keeps the card one-column whenever the card box itself is under 30rem, so a viewport width breakpoint never forces two columns inside a narrow container. */
/* The :host( [wide] ) selector is listed too because a container query adds no specificity: the bare .card floor at (0,1,0) would lose to the (0,3,0) wide seam above, while the @media reflow only ties .card against .card and falls to source order. */
/* This matches the finished card, so the only difference between the two stays the signals each one reads, not their layout. */
@container ( width <= 30rem ) {
.card,
:host( [wide] ) .card {
grid-template-columns: minmax( 0, 1fr );
}
}
/* The hero floats under a real no-preference and stops under a real reduce, but the card has no branch for the simulated switch, so the demo's reduced-motion request slides past it. */
/* The cover sits at the start of its row and takes its column width, so its 4 / 3 box is derived from the width and never inflates past the column. */
.cover {
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
align-self: start;
aspect-ratio: 4 / 3;
border-radius: var( --radius-md, 11px );
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
/* The floating orb, the moving part, kept identical to the finished card so the only difference is that nothing here turns it off for the simulated switch. */
.cover::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 56%;
aspect-ratio: 1;
margin: -28% 0 0 -28%;
border-radius: 50%;
background: radial-gradient( circle at 30% 30%, #ffffff, rgb( 255 255 255 / 0 ) 70% );
opacity: 0.85;
}
/* The card ignores the simulated switch on purpose, but never the real OS setting: the loop exists only under a real no-preference, so a real Reduce Motion request leaves the orb still and the published page never floats motion at a visitor who asked for none. */
@media ( prefers-reduced-motion: no-preference ) {
.cover::after {
animation: float 4s ease-in-out infinite;
}
}
@keyframes float {
0% {
transform: translate( -55%, -30% ) scale( 0.7 );
}
25% {
transform: translate( 55%, -45% ) scale( 1.05 );
}
50% {
transform: translate( 60%, 45% ) scale( 0.8 );
}
75% {
transform: translate( -50%, 50% ) scale( 1.1 );
}
100% {
transform: translate( -55%, -30% ) scale( 0.7 );
}
}
/* The body groups the heading, summary, and footer, so the wide two-column layout matches the finished card's shape. */
/* The min-width of 0 lets the body shrink and wrap inside its track instead of pushing the cover or overflowing the card. */
.body {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var( --space-3, 12px );
min-width: 0;
align-content: start;
}
.heading {
margin: 0;
font-size: var( --fs-18, 18px );
line-height: 1.25;
}
.summary {
margin: 0;
color: var( --ink-soft, #56565c );
font-size: var( --fs-14, 14px );
line-height: 1.6;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var( --space-3, 12px );
align-self: end;
}
.read {
color: var( --accent, #2257e6 );
font-size: var( --fs-13, 13px );
text-decoration: none;
}
/* The hover trap: Save is removed from the layout, and from the tab order, until a hover that touch and keyboard never produce. */
.save {
display: none;
align-items: center;
gap: 6px;
padding: 8px 14px;
font: inherit;
font-size: var( --fs-13, 13px );
color: var( --ink, #17171a );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
cursor: pointer;
}
/* Hover is the only way Save ever appears, so there is no path to it without a mouse. */
.card:hover .save {
display: inline-flex;
}
`;
/**
* Renders the broken card: a hero that keeps animating and a Save action that only a hover can reveal.
*
* The heading, summary, and footer are grouped in a `.body` element, so the `wide` two-column layout matches the finished card's shape.
*/
render() {
return html`
<article class="card" aria-labelledby="heading">
<div class="cover" role="presentation"></div>
<div class="body">
<h3 class="heading" id="heading">${this.heading}</h3>
<p class="summary">${this.summary}</p>
<div class="footer">
<a class="read" href="#">Read article</a>
<button class="save" type="button">Save</button>
</div>
</div>
</article>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'broken-card': BrokenCard;
}
}So we’re going to fix this card, and use it to practice one skill. A media query is an input. Read what kind of input it is before you decide what to do with it. The card is just the vehicle here. What you keep is the method for reading any media feature and deciding whether it’s a requirement, an enhancement, or something you can’t depend on yet.
Reading the signal, then writing the review comment
A senior doesn’t ask “does this media query exist?” first. They ask “what product promise would I be making if I shipped behavior behind this signal, and can I keep it?” prefers-reduced-motion is a promise you can keep, because the user can set it and the browser honors it. prefers-reduced-data is a promise you can’t keep, because almost no one can set it. That single question, can I keep the promise, is what sorts a media feature into a tier before you write a line of CSS behind it.
For each signal this build touches, the goal is that you could leave the review comment a senior would leave on a pull request that misread it. Those comments are the spine of the whole piece, so the table below puts them up front, with the kind of signal each one is and the tier it lands in.
| Signal | Kind of signal | Tier (checked 2026-06-24) | The review comment a senior leaves |
|---|---|---|---|
prefers-reduced-motion | accessibility preference | Baseline, a requirement | This animation is optional, so the reduced-motion path should remove it without removing the feedback the user still needs. |
prefers-contrast | accessibility preference | Baseline, a requirement | You raised text contrast but left borders, separation, and focus states weak; the request was for the whole hierarchy. |
forced-colors | browser/system mode | Baseline, a requirement | This design is fighting the system palette instead of integrating with it; let the forced palette stand and repair only what it flattens. |
any-pointer / hover | input capability | Baseline, a reachability requirement | This action lives only behind hover, so touch and keyboard users can lose it. |
prefers-reduced-data | data preference | Not supported by any user agent, a product-owned fallback | This CSS query carries no policy today, and Save-Data is Chromium-only, so ship a real, product-owned data-saving path with an explicit user setting. |
The comment is the output. The thing that gets you there, even on a signal this article never covered, is a short rubric. Four questions route any media feature to one of three tiers, and this is the part worth memorizing, because it’s the reusable piece you carry to the next feature.
| Question | What a “no” tells you |
|---|---|
| Can you rely on it as an input here? | This covers both a user-set preference and a device-reported capability. If almost no one can set it and no device reports it, it’s unsupported as a product input, whatever the spec says. |
| Does a core task here depend on it? | If a core task on this card is worse without honoring it (harmful motion, a weak hierarchy, an unreadable palette), it’s a requirement; if the card is already fine either way, it’s an enhancement. |
| Does the base survive without it? | If the base breaks when the query never matches, you’ve made the signal load-bearing, which an enhancement must never be. |
| Who owns the fallback? | If only the browser can answer it and the browser doesn’t, the product owns a real fallback or the feature waits. |
Those questions weigh two different things, and it helps to keep them apart. One is whether you can rely on the signal as an input at all, which is a fact about delivery. The other is whether a core task on this card depends on the response, which is a fact about this product. Together they sort a signal into three tiers. A requirement is a reliable signal whose response the product is obligated to provide: the base still functions when the query never matches, but it fails the user’s need until you honor it, so the response ships in CSS, and motion, contrast, forced-colors integration, and hover reachability all land here. An enhancement is a reliable signal the product is not obligated to honor: it layers onto a base that’s already complete and is allowed to do nothing where it’s absent, so prefers-reduced-transparency and dynamic-range land here, since the same preference or capability can be a real user need in general while no core task on this particular card depends on it. The unsupported tier is the signal you can’t rely on yet: prefers-reduced-data drives no product behavior because almost no one can set it, so when the product genuinely needs the result the obligation doesn’t vanish, it moves into a product-owned path while the query itself stays speculative.
A quick note on letting AI code this
This is also where an AI fits, now that you hold the rubric to check its work against. You should hand the responsive card to an AI, because it writes that part well and fast. Describe an article card with a width breakpoint and maybe dark mode and you get clean, working code in seconds. The value you bring is reading the signals the model didn’t. You classify motion, contrast, forced colors, and input as you go, keep every CSS preference in CSS, and build a real product-owned path for the one signal that isn’t reliable enough to drive behavior. That is the difference between the generic card with a breakpoint and the one shaped by those questions. So when the model hands you a card, your first move is to run each signal it touched through those four questions before you approve a line of it.
Reading what each signal reports
Before any styling depends on a signal, it helps to see what each one actually reports. The second demo is the one read-only instrument in the set, reading all five build signals live through matchMedia and showing what your own engine returns for each, right now. If you do change a real OS preference while it’s open, the matching row flips on the spot, which is the honest read no in-demo switch can fake. Two of the rows carry a flag, and the flags teach two different lessons. A false forced-colors read genuinely means the mode is off right now, it just never comes on for Apple users because there’s no OS path into it, while a false reduced-data read carries no information at all, since no user agent lets anyone set it.
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
import type { SignalReading, SignalRow } from './types';
/**
* Reads a media query and calls back whenever its value changes.
*
* It lives in the demo layer, not in the production card, since the card honors its CSS preferences in CSS and never needs to read them in JavaScript.
* Uses `addEventListener( 'change' )`, the modern form, rather than the deprecated `addListener` alias.
* Calls `onChange` once immediately with the current value, so a subscriber starts in sync.
*
* @param query - The media query string to watch.
* @param onChange - Called with the current match state now and on every change.
* @returns A teardown function that removes the listener.
*/
export function watchMedia( query: string, onChange: ( matches: boolean ) => void ): () => void {
const $list = window.matchMedia( query );
const handler = ( event: MediaQueryListEvent ): void => onChange( event.matches );
$list.addEventListener( 'change', handler );
onChange( $list.matches );
return () => $list.removeEventListener( 'change', handler );
}
/**
* A read-only live read-out of the five build signals, so a reader sees what each one reports before any styling depends on it.
*
* Each row reads its real `matchMedia()` value and updates through {@link watchMedia}.
* Forced colors and reduced data are flagged as unreliable for different reasons, which each row's own note states.
* A false forced-colors read genuinely means the mode is not active right now, though Apple platforms offer no OS path into it, while a false reduced-data read carries no information because no user agent lets a user set it.
*
* @element signal-dashboard
*/
@customElement( 'signal-dashboard' )
export class SignalDashboard extends LitElement {
/**
* The five build signals, in the order the article introduces them.
*
* The two flagged rows carry `reliable: false` and their own `note`, since a false read means something different on each.
*/
private readonly signals: SignalRow[] = [
{ label: 'prefers-reduced-motion', query: '(prefers-reduced-motion: reduce)', reliable: true },
{ label: 'prefers-contrast: more', query: '(prefers-contrast: more)', reliable: true },
{ label: 'any-pointer: coarse', query: '(any-pointer: coarse)', reliable: true },
{
label: 'forced-colors',
query: '(forced-colors: active)',
reliable: false,
note: 'false really does mean the mode is off right now, and Apple platforms offer no OS path into it',
},
{
label: 'prefers-reduced-data',
query: '(prefers-reduced-data: reduce)',
reliable: false,
note: 'false carries no information, since no user agent lets a user set it',
},
];
/**
* The current match state for every signal, recomputed by the live listeners.
*/
@state()
private readings: SignalReading[] = this.signals.map( ( signal ) => ( { ...signal, matches: false } ) );
/**
* The teardown functions returned by each {@link watchMedia} subscription, cleared on disconnect.
*/
private teardowns: ( () => void )[] = [];
/**
* Subscribes every signal to its live media-query changes when the element connects.
*/
connectedCallback(): void {
super.connectedCallback();
this.teardowns = this.signals.map( ( signal, index ) =>
watchMedia( signal.query, ( matches ) => this.updateReading( index, matches ) ),
);
}
/**
* Removes every media-query listener when the element disconnects, so no subscription leaks.
*/
disconnectedCallback(): void {
super.disconnectedCallback();
this.teardowns.forEach( ( teardown ) => teardown() );
this.teardowns = [];
}
/**
* Styles for the dashboard, scoped to its shadow root.
*/
static styles = css`
:host {
display: block;
color: var( --ink, #17171a );
font-family: var( --font-sans, system-ui, sans-serif );
}
.grid {
display: grid;
gap: var( --space-2, 8px );
}
.row {
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var( --space-3, 12px );
padding: 10px 12px;
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius, 8px );
}
.name {
font-family: var( --font-mono, monospace );
font-size: var( --fs-12, 12px );
}
/* The note and the on-badge text sit at 11px, so both hold at least 4.5:1 against their grounds, the AA floor for body-size text. */
.note {
display: block;
margin-top: 3px;
font-family: var( --font-sans, sans-serif );
font-size: 11px;
color: var( --ink-soft, #56565c );
}
.value {
font-family: var( --font-mono, monospace );
font-size: 11px;
padding: 3px 9px;
border-radius: var( --radius-pill, 999px );
}
.value.on {
color: #157347;
background: rgb( 31 157 91 / 0.12 );
}
.value.off {
color: var( --ink-soft, #56565c );
background: var( --line-soft, #f2f1ec );
}
`;
/**
* Updates one signal's match state from its live listener.
*
* @param index - The signal's position in the readings array.
* @param matches - Whether the query now matches.
*/
private updateReading( index: number, matches: boolean ): void {
this.readings = this.readings.map( ( reading, i ) => ( i === index ? { ...reading, matches } : reading ) );
}
/**
* Renders the live read-out, one row per signal, with each flagged row's own honesty note.
*
* Each value cell is a polite live region, so a screen reader hears a row flip when a real OS preference changes, the exact moment the dashboard exists to show.
*/
render() {
return html`
<div class="grid">
${this.readings.map(
( reading ) => html`
<div class="row">
<span class="name">
${reading.label}
${reading.note
? html`<span class="note">${reading.note}</span>`
: null}
</span>
<span class="value ${reading.matches ? 'on' : 'off'}" aria-live="polite">${reading.matches ? 'matches' : 'no match'}</span>
</div>
`,
)}
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'signal-dashboard': SignalDashboard;
}
}The bar we’re holding it to
The finished component has to:
- start from a semantic, readable base card that works with no preference styling at all,
- adapt to
prefers-reduced-motionandprefers-contrastin pure CSS, with the correct distinct response to each, cutting non-essential motion while keeping feedback, and strengthening the whole hierarchy including borders, separation, and focus states, - integrate with
forced-colorsusing the CSS system color keywords rather than a separate forced-colors design, - keep the Save action visible and focusable by default so touch and keyboard users always reach it, using hover only to enhance its presentation, and read
any-pointer: coarseonly for touch-target sizing, - let CSS own every preference adaptation, and leave reduced data, the one signal CSS can’t carry, to a product-owned setting the card receives, with the browser hint as optional input,
- expose a small, typed API, which is the shape it ships in rather than where the judgment lives,
- stay fully usable when any given query never matches, so no signal is load-bearing for a core task,
- classify every signal in the final review as requirement, enhancement, or unsupported, with the product-owned fallback named where one is needed.
A few concrete cases to measure your own version against:
- Turn Reduce Motion on and the hero animation stops while the focus and hover feedback stays.
- Ask for higher contrast and the borders, separation, and focus states strengthen alongside the text contrast.
- Under a Windows Contrast theme the card uses system colors and stays legible. The same block effectively never activates on Apple platforms, with the one Firefox-on-macOS wrinkle noted below, which the build calls out where it matters.
- In a touch or keyboard context the Save affordance is present and reachable without hover, and
any-pointer: coarseonly adjusts target size. - Ask for reduced data and the CSS query does nothing in a shipping browser, so the product’s own setting plus asset policy makes the swap, which the card’s pure-CSS hero stands in for.
What you’ll need
You’ll want to be comfortable with TypeScript and the DOM. No Lit experience required, since it’s a thin layer over web components and I’ll explain the Lit-specific parts inline. Beyond that you need Lit and TypeScript, and nothing else, since the demos in this article carry their own switches and you can read every branch without touching a setting.
When you go to verify your own component later, the most useful thing in your toolbox is your browser’s devtools Rendering pane, which has “Emulate CSS media feature” dropdowns for prefers-reduced-motion, prefers-contrast, forced-colors, and more. Emulation gets you most of the way without being the real thing, so the genuine final check on your build is your OS accessibility settings for motion and contrast, plus a Windows machine or VM for forced colors, since that’s where it actually activates.
One honest word on support before we build, because “read every signal” should never read as “every signal is reliable.” The four signals we ship behavior behind, reduced motion, contrast, forced colors, and the pointer features, are all Baseline as of today, so you can use them now. The fifth, prefers-reduced-data, is supported by no user agent, which is the entire reason it gets handled differently. We’ll get to exactly what that means at the step where it matters.
Try it yourself first
Give it a go before reading on. In the first demo, flip the Reduce motion switch, then the Higher contrast one, and watch which part of the card flatly ignores each request. Then try the hardest one with your hands off the mouse. Park the cursor and press Tab through that same card, trying to land on the Save button. The places where the card leaves you stuck are the actual work, and finding them yourself first makes the rest of this stick.
Building it
Each step shows only the new or changed slice, and the full index.ts and types.ts come together at the very end, so you can read the build as the sum of the steps plus the demo seams the live card adds on top. Most of it is CSS, because most of it is CSS preferences that CSS already knows how to honor. The one preference CSS can’t resolve is reduced data, so the production component receives a product-set value instead of reading a browser query, the only place the choice isn’t a CSS one.
Step 1: A semantic base that works with no preferences at all
The base card is plain HTML and a little layout, and the most important thing about it is that it’s complete before any preference styling exists. A real article, a heading, the summary, and a real Save button that’s visible by default. Nothing here is load-bearing on a media query, which is the property every later step has to preserve.
html
<!-- the semantic base -->
<article class="card" aria-labelledby="heading">
<div class="cover" role="presentation"></div>
<h3 class="heading" id="heading">Designing for the person behind the viewport</h3>
<p class="summary">A worked card that reads the user and the device, beyond the viewport width.</p>
<div class="footer">
<a class="read" href="/writing/example-article">Read article</a>
<button class="save" type="button">Save</button>
</div>
</article>This is the markup the build defends, where the Save button is a real, focusable control that survives every media query going missing. The broken card you opened with shares these same semantic elements, but it hides that button behind display: none until :hover, which is the bug Step 6 fixes, so on touch and keyboard it isn’t a usable card yet. The base shown here is the target the rest of the build builds back toward, with Save visible by default once that hover gate comes off.
Step 2: One width query, named as the only breakpoint
The card answers the viewport with a single width query, and I’m calling it out here precisely because it’s the only breakpoint in the whole piece. It’s written in the range syntax, width >= 40rem rather than min-width: 40rem, which every current engine ships and which states the inclusive boundary outright instead of encoding it in a prefix. Everything after this responds to the person or the device instead.
The two-column layout introduces one structural change worth naming now, since it carries through to the assembled file. The heading, summary, and footer get grouped in a .body wrapper, so the cover can sit beside the whole text block. The cover cap tightens from the opener’s flat 12rem to minmax( 0, 11rem ), both columns are minmax( 0, ... ), and the body keeps min-width: 0, so the text wraps inside its track and never gets pushed or clipped. The breakpoint also waits for real room, switching to two columns only from 40rem up, where the body still has a comfortable width beside the capped cover, and anything narrower stays a single column.
global.css
/* global.css, the one width breakpoint */
.body {
min-width: 0;
}
@media (width >= 40rem) {
.card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
}
}One senior robustness move belongs right here, because a viewport width breakpoint alone can break a card dropped into a narrow column. The @media query reads the viewport, not the card, so a wide window forces two columns even when this card sits in a 320px sidebar and the body collapses to nothing. Pair the breakpoint with container-type: inline-size on the host and an @container ( width <= 30rem ) floor that forces one column whenever the card box itself is narrow, and the layout stays correct no matter what container it lands in.
The review comment on this step is short, because this answers the viewport and nothing else. The query is doing its job here at the starting point, and the work is everything the viewport can’t tell you. The live demo drives this reflow from a wide hook on a demo-adaptive-card subclass, so a slider can move it inside a fixed iframe, while the production card you copy keeps the @media query shown here and carries no demo hooks at all.
Step 3: Motion, a requirement honored in pure CSS
The hero animates, and for a lot of people that’s fine, even nice. For someone with a vestibular disorder, motion like an orb sweeping across the hero can cause real discomfort, which is why the platform exposes prefers-reduced-motion. There’s a second rule in play, since WCAG Success Criterion 2.2.2, Pause, Stop, Hide (Level A) governs motion the page starts on its own: once autoplaying motion runs past five seconds, you owe the user an explicit way to pause, stop, or hide it, and honoring prefers-reduced-motion does not count as that control. The senior move is to not owe it in the first place. This build makes the hero a finite settle that plays once on arrival and ends in under three seconds, so it stays clear of 2.2.2 without a pause button, and the reduced-motion branch still removes even that for anyone who wants no motion at all. The Nielsen Norman Group’s work on animation in UX makes the same point from the usability side: animation should serve a purpose, and decorative motion is the first thing to cut.
The senior detail is what you keep. A blanket reduced-motion reset can sweep away interaction feedback that has nothing to do with the decorative motion, so the focus cue lives outside the animation, declared on its own, where the reduced-motion branch can’t touch it.
index.ts
/* index.ts, inside static styles */
/* The focus cue is declared on its own, outside the hero animation, so removing motion never removes feedback. */
.card:focus-within {
outline: 2px solid var( --adaptive-card-accent, var( --accent, #2257e6 ) );
outline-offset: 4px;
}
/* The hero is a gradient field, and a bright orb settles into place on a pseudo-element, so the moving part is separate from the field. */
.cover {
position: relative;
overflow: hidden;
aspect-ratio: 4 / 3;
border-radius: var( --radius-md, 11px );
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
.cover::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 56%;
aspect-ratio: 1;
margin: -28% 0 0 -28%;
border-radius: 50%;
background: radial-gradient( circle at 30% 30%, #ffffff, rgb( 255 255 255 / 0 ) 70% );
opacity: 0.85;
animation: settle 2.4s ease-out both;
}
@keyframes settle {
0% {
transform: translate( -55%, -35% ) scale( 0.7 );
opacity: 0;
}
55% {
transform: translate( 30%, 20% ) scale( 1.08 );
opacity: 0.9;
}
100% {
transform: translate( 0%, 0% ) scale( 1 );
opacity: 0.85;
}
}
@media ( prefers-reduced-motion: reduce ) {
.cover::after {
animation: none;
}
}The finite settle plays once and ends, so it never trips 2.2.2, and the reduced-motion branch removes even that entrance. The focus ring, which is interaction feedback the orb never carried, stays exactly where it was. That is the point the table made: cut the optional motion without cutting the feedback that lives near it.
Step 4: Contrast, a requirement with a different response
It’s tempting to lump every accessibility preference into one bucket and give them one response. That’s the mistake. prefers-contrast reports no-preference, less, more, or custom, and the right response to more reaches past darker text. The user asked for a stronger hierarchy, so you strengthen the whole thing, the contrast ratios, the borders, the separation between regions, and the focus states.
This build handles the more branch, which is the common one. less and custom are genuinely different product questions, and one style block doesn’t answer all of them.
index.ts
/* index.ts, inside static styles */
@media ( prefers-contrast: more ) {
.card {
border-color: var( --ink, #17171a );
border-width: 2px;
}
.cover {
border: 2px solid var( --ink, #17171a );
}
.summary {
color: var( --ink, #17171a );
}
.read {
text-decoration: underline;
text-decoration-thickness: 2px;
}
.save {
color: var( --ink, #17171a );
border-color: var( --ink, #17171a );
border-width: 2px;
}
.card:focus-within {
outline-width: 3px;
}
}Notice this touches the card border, the cover outline, the summary text, the read link, and the Save button’s border, all moving with the focus ring and the body copy. That list is what a senior scans for on a diff that only bumped the text color.
The third demo carries both requirements on one live card. Flip the Reduced motion switch and the looping demo hero stops while the focus ring and the Save tint stay, the interaction feedback the motion sat next to but never carried. Flip the Higher contrast switch and the border, the separation, and the focus ring all thicken together, with a readout naming each part that changed so the difference is hard to miss. The switches hold independently, so turn on both and watch the two responses stack. The demo runs a demo-adaptive-card subclass whose sim-* hooks mirror each real @media branch, so what the switch shows on screen is what the preference produces in production.
import { css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import { AdaptiveCard } from '../adaptive-card';
/**
* A demo-only subclass of `adaptive-card` that adds the attribute hooks a sandboxed harness needs.
*
* A demo iframe cannot set a real OS preference or resize the real viewport, so this subclass forces each branch on screen from an attribute a switch or slider controls.
* None of these hooks exist on the production `adaptive-card`, so the code a reader copies stays clean and the simulation seams live only here.
* Each seam mirrors the exact response its real `@media` query produces, so what the switch shows on screen is what the preference produces in production.
*
* @element demo-adaptive-card
* @attr wide - Forces the two-column width layout, so a width slider can drive the reflow a viewport `@media` cannot inside a fixed iframe.
* @attr sim-reduced-motion - Forces the reduced-motion response, so a switch can stop the hero while the feedback stays.
* @attr sim-contrast - Forces the `prefers-contrast: more` response, so a switch can strengthen the whole hierarchy.
* @attr sim-forced-colors - Forces the forced-colors integration, so a non-Windows reader still sees the system-color adoption.
*/
@customElement( 'demo-adaptive-card' )
export class DemoAdaptiveCard extends AdaptiveCard {
/**
* Forces the two-column width layout regardless of the real viewport.
*
* A harness sets this once its width slider passes the breakpoint, so the reflow lands exactly where the label says.
* Defaults to `false`, so the card stays one column until the demo forces the wide layout.
*/
@property( { type: Boolean, reflect: true } )
wide = false;
/**
* Forces the reduced-motion response without an OS setting.
*
* Defaults to `false`, so the demo hero animates until a switch requests reduced motion.
*/
@property( { type: Boolean, attribute: 'sim-reduced-motion', reflect: true } )
simReducedMotion = false;
/**
* Forces the `prefers-contrast: more` response without an OS setting.
*
* Defaults to `false`, so the demo starts on the baseline hierarchy until a switch strengthens it.
*/
@property( { type: Boolean, attribute: 'sim-contrast', reflect: true } )
simContrast = false;
/**
* Forces the forced-colors integration without a Windows Contrast theme.
*
* The real query never returns true on Apple platforms, so this hook is the only way a non-Windows reader sees the card adopt the system palette.
* Defaults to `false`, so the demo leaves the integration to the real query until a switch forces it.
*/
@property( { type: Boolean, attribute: 'sim-forced-colors', reflect: true } )
simForcedColors = false;
/**
* The parent card's styles plus the demo-only seams that force each branch on screen.
*
* The seams sit after the parent styles, so they win where they apply and leave the production rules untouched everywhere else.
*/
static styles = [
AdaptiveCard.styles,
css`
/* The demo loops the hero so a reader can watch the reduced-motion switch stop an ongoing motion, while the shipped card plays its finite settle once. */
/* The loop is gated on the real no-preference query and stopped under a real reduce request, so a visitor who set Reduce Motion never gets the loop, even before touching the simulation switch, which stays the seam for everyone else. */
@media ( prefers-reduced-motion: no-preference ) {
.cover::after {
animation: demo-float 4s ease-in-out infinite;
}
}
@media ( prefers-reduced-motion: reduce ) {
.cover::after {
animation: none;
}
}
@keyframes demo-float {
0%,
100% {
transform: translate( -30%, -20% ) scale( 0.85 );
}
50% {
transform: translate( 30%, 20% ) scale( 1.05 );
}
}
/* Width: force the two-column reflow from the wide hook, and pin one column otherwise, so a slider drives the layout instead of the viewport a fixed iframe cannot change. */
:host( [wide] ) .card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
align-items: start;
}
:host( :not( [wide] ) ) .card {
grid-template-columns: minmax( 0, 1fr );
}
/* The parent's one-column container floor targets .card at (0,1,0), which the (0,3,0) wide seam above would outrank, since a container query adds no specificity; restating the floor against the seam keeps a narrow card one column even with wide set. */
@container ( width <= 30rem ) {
:host( [wide] ) .card {
grid-template-columns: minmax( 0, 1fr );
}
}
/* Reduced motion: stop the looping hero and drop the Save lift, the full result a real reduce request produces. */
/* The lift rules live in the parent's no-preference block, which still matches while the switch simulates reduce, so the seam overrides them the way the real query's absence would. */
:host( [sim-reduced-motion] ) .cover::after {
animation: none;
}
:host( [sim-reduced-motion] ) .save {
transition: background-color 120ms ease;
}
:host( [sim-reduced-motion] ) .card:focus-within .save,
:host( [sim-reduced-motion] ) .card:hover .save {
transform: none;
}
/* Contrast: the same hierarchy the real @media (prefers-contrast: more) branch strengthens. */
:host( [sim-contrast] ) .card {
border-color: var( --ink, #17171a );
border-width: 2px;
}
:host( [sim-contrast] ) .cover {
border: 2px solid var( --ink, #17171a );
}
:host( [sim-contrast] ) .summary {
color: var( --ink, #17171a );
}
:host( [sim-contrast] ) .read {
text-decoration: underline;
text-decoration-thickness: 2px;
}
:host( [sim-contrast] ) .save {
color: var( --ink, #17171a );
border-color: var( --ink, #17171a );
border-width: 2px;
}
:host( [sim-contrast] ) .card:focus-within {
outline-width: 3px;
}
/* Forced colors: paint the CSS system keywords the real @media (forced-colors: active) branch adopts, so a non-Windows reader still sees the integration. */
:host( [sim-forced-colors] ) .card {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
:host( [sim-forced-colors] ) .cover {
background: Canvas;
border: 1px solid CanvasText;
}
:host( [sim-forced-colors] ) .cover::after {
animation: none;
background: none;
}
:host( [sim-forced-colors] ) .summary {
color: CanvasText;
}
:host( [sim-forced-colors] ) .read {
color: LinkText;
}
:host( [sim-forced-colors] ) .save {
color: ButtonText;
background: ButtonFace;
border-color: ButtonText;
}
:host( [sim-forced-colors] ) .card:focus-within {
outline-color: CanvasText;
}
`,
];
}
declare global {
interface HTMLElementTagNameMap {
'demo-adaptive-card': DemoAdaptiveCard;
}
}Step 5: Forced colors, a system mode you integrate with
This is the step where a junior’s instinct is most wrong, so it’s worth slowing down. When a user turns on a high-contrast theme at the OS level, the browser enters forced-colors mode and replaces your colors with a small palette the user chose. The wrong move is to detect that mode and design a separate, clever forced-colors look that reasserts your brand. The right move is to let the forced palette stand and integrate with it. Forced-colors mode already remaps your colors to the user’s palette on its own, so the work isn’t repainting every surface, it’s adopting the CSS system color keywords for the parts that carry meaning and repairing what the automatic mapping flattens, a border that disappeared or decoration that no longer belongs. This card reaches for five of the keywords, Canvas, CanvasText, LinkText, ButtonText, and ButtonFace, since those cover its surface, text, link, and button. Field and FieldText are the input-control pair you’d add if the card had a text field, so they round out the set worth knowing even though this build never paints them.
index.ts
/* index.ts, inside static styles */
@media ( forced-colors: active ) {
.card {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
.cover {
background: Canvas;
border: 1px solid CanvasText;
}
/* The decorative orb carries no meaning, so it drops out of the system theme entirely. */
.cover::after {
animation: none;
background: none;
}
.summary {
color: CanvasText;
}
.read {
color: LinkText;
}
.save {
color: ButtonText;
background: ButtonFace;
border-color: ButtonText;
}
.card:focus-within {
outline-color: CanvasText;
}
}Two things about this mode trip people up, and both are worth carrying as plain facts. First, forced colors is in practice a Windows feature. The OS-level path is Windows Contrast themes, and the query does not return true on Apple platforms, where there’s no equivalent concept. (Firefox on macOS is the one wrinkle, since it has a browser-level preference of its own that can trigger it.) Second, the forced-color-adjust escape hatch, the property you’d reach for to opt a specific element out, isn’t supported in Safari at all. The demo below simulates the palette so the response reads on any platform, and the real thing effectively only activates under a Windows Contrast theme, aside from the Firefox-on-macOS wrinkle noted above, which is where genuine forced-colors testing happens.
One more correctness point, because it’s a common conflation. forced-colors: active reflects the colors the user chose; it doesn’t by itself mean they want more contrast. If you want to detect a contrast preference, that’s what prefers-contrast is for, and the two are separate signals with separate responses.
The fourth demo puts a card that fights the palette next to one that integrates with it. Off, the two look identical, both branded and readable. Flip the Forced colors switch and both drop into a simulated high-contrast theme, so the divergence reads on any platform. The fighting card re-asserts its brand styling over the theme, the opt-out a real product takes with forced-color-adjust: none, and its text still reads while the card refuses the palette the user chose and muddies the guarantees the theme was set up to carry. Real forcing would repaint plain author colors into the system palette on its own, so an opted-out block like this is the genuine failure mode, and the integrating card beside it adopts the system keywords and sits crisp inside the theme. The question to ask yourself looking at it is whether your design would integrate with the system palette or override it.
import { LitElement, html, css } from 'lit';
import { customElement, state } from 'lit/decorators.js';
/**
* Demo harness for the forced-colors lesson, driven entirely from inside the demo.
*
* One toggle drops both cards into a simulated high-contrast palette, a black canvas with white text and yellow link and button accents, the shape of a Windows Contrast theme.
* A sandboxed iframe cannot enter real forced-colors mode and Apple platforms never report it, so the toggle paints the palette on screen instead, standing in for a state the visitor has not set.
* The fighting card re-asserts its brand styling over the theme, the opt-out a real product takes with `forced-color-adjust: none`, while the integrating card adopts the system keywords and sits inside the palette.
* Real forcing would repaint untouched plain author colors into system colors at paint time, so the failure dramatized here is a card refusing the user's chosen palette and muddying the theme's guarantees.
*
* With the toggle off, both cards show the same branded baseline, so the before reads as one card and the after splits them apart.
*
* @element demo-user-preference-media-queries-forced-colors
*/
@customElement( 'demo-user-preference-media-queries-forced-colors' )
export class MediaQueriesForcedColorsDemo extends LitElement {
/**
* Whether the simulated forced-colors palette is painted on the stage.
*
* Off leaves both cards on their branded baseline, the before; on paints the high-contrast theme so the fighting and integrating responses split apart, the after.
*/
@state()
private forced = false;
/**
* Styles for the harness, scoped to its shadow root.
*
* The stage-left, controls-right split and the toggle chrome follow the site's shared demo convention, so the article's demos read as one set.
* The `.theme` wrapper stands in for a forced-colors theme, switching from the page surface to a literal high-contrast palette the moment `.on` is set, so a reader on any OS sees the divergence a Windows theme would force.
*/
static styles = css`
:host {
display: block;
height: 100%;
font-family: var( --font-sans, system-ui, sans-serif );
color: var( --ink, #17171a );
}
.wrap {
display: grid;
grid-template-columns: minmax( 0, 1fr ) 16rem;
gap: 0;
min-height: 360px;
height: 100%;
}
/* The stage holds the two cards on the left, divided from the control the way the published harnesses divide stage from controls. */
.stage {
display: grid;
align-content: start;
padding: 22px;
border-right: 1px solid var( --line, #e7e6e2 );
}
/* The controls column carries the one toggle and the Windows note, on the right. */
.controls {
display: grid;
gap: 12px;
align-content: start;
padding: 18px;
}
/* The toggle: a labeled button with a track and a knob that slides on, the shared control across the article demos. */
.toggle {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
background: var( --bg, #fcfcfb );
color: var( --ink, #17171a );
border: 1px solid var( --line, #e7e6e2 );
border-radius: 7px;
padding: 9px 12px;
font: inherit;
font-size: 13px;
cursor: pointer;
}
.toggle:focus-visible {
outline: 2px solid var( --accent, #2257e6 );
outline-offset: 2px;
}
.track {
position: relative;
width: 36px;
height: 20px;
border-radius: 999px;
background: var( --line, #e7e6e2 );
transition: background 0.2s ease;
flex: none;
}
.track.on {
background: var( --accent, #2257e6 );
}
.knob {
position: absolute;
top: 2px;
left: 2px;
width: 16px;
height: 16px;
border-radius: 50%;
background: #ffffff;
transition: transform 0.2s ease;
}
.track.on .knob {
transform: translateX( 16px );
}
/* The stage surface: a branded surface when off, the simulated high-contrast palette when on. */
.theme {
display: grid;
grid-template-columns: 1fr 1fr;
gap: var( --space-4, 16px );
padding: var( --space-4, 16px );
background: var( --bg, #fcfcfb );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-md, 11px );
transition: background 0.2s ease, border-color 0.2s ease;
}
/* The simulated forced-colors theme: black canvas, white text, yellow link and button accents, the palette a Windows Contrast theme forces. */
.theme.on {
--sim-canvas: #000000;
--sim-canvas-text: #ffffff;
--sim-link-text: #ffff00;
--sim-button-text: #000000;
--sim-button-face: #ffff00;
background: var( --sim-canvas );
border-color: var( --sim-canvas-text );
}
/* Both cards share one fixed brand look, so off they read as a single identical pair, and the toggle is the only thing that splits them. */
.card {
display: grid;
gap: var( --space-3, 12px );
padding: var( --space-4, 16px );
background: #14275e;
border: 1px solid #14275e;
border-radius: var( --radius-md, 11px );
}
/* The hero: a deep brand gradient with a bright orb, so the brand has real presence to either hold or surrender. */
.cover {
position: relative;
overflow: hidden;
min-height: 6.5rem;
border-radius: var( --radius, 8px );
background: linear-gradient( 135deg, #2257e6, #5b8bff );
}
.cover::after {
content: '';
position: absolute;
top: 50%;
left: 30%;
width: 46%;
aspect-ratio: 1;
margin: -23% 0 0 -23%;
border-radius: 50%;
background: radial-gradient( circle at 30% 30%, #ffffff, rgb( 255 255 255 / 0 ) 70% );
opacity: 0.8;
}
.heading {
margin: 0;
font-size: var( --fs-15, 15px );
line-height: 1.3;
color: #c9d6ff;
}
.summary {
margin: 0;
font-size: var( --fs-13, 13px );
line-height: 1.55;
color: #9fb2e6;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var( --space-3, 12px );
}
.read {
font-size: var( --fs-12, 12px );
color: #9fb2e6;
text-decoration: underline;
}
.save {
padding: 7px 12px;
font: inherit;
font-size: var( --fs-12, 12px );
color: #ffffff;
background: #3a63c9;
border: 1px solid #3a63c9;
border-radius: var( --radius-pill, 999px );
}
.tag {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var( --font-mono, monospace );
font-size: 10px;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9fb2e6;
}
.tag::before {
content: '';
width: 7px;
height: 7px;
border-radius: 50%;
background: currentColor;
}
/* Fighting is the base brand card and never changes, so when the theme comes on it holds these colors against the palette, the way forced-color-adjust: none holds them in a real product. */
/* Integrating: when the theme is on, adopt the system keywords so the card sits inside the chosen palette and stays legible. */
.theme.on .card.integrating {
background: var( --sim-canvas );
color: var( --sim-canvas-text );
border-color: var( --sim-canvas-text );
}
.theme.on .card.integrating .cover {
background: var( --sim-canvas );
border: 1px solid var( --sim-canvas-text );
}
.theme.on .card.integrating .cover::after {
content: none;
}
.theme.on .card.integrating .heading,
.theme.on .card.integrating .summary,
.theme.on .card.integrating .tag {
color: var( --sim-canvas-text );
}
.theme.on .card.integrating .read {
color: var( --sim-link-text );
}
.theme.on .card.integrating .save {
color: var( --sim-button-text );
background: var( --sim-button-face );
border-color: var( --sim-button-text );
}
.verdict {
margin: 0;
font-size: var( --fs-12, 12px );
line-height: 1.5;
color: #9fb2e6;
}
.theme.on .card.integrating .verdict {
color: var( --sim-canvas-text );
}
.note {
margin: 0;
font-family: var( --font-mono, monospace );
font-size: 10.5px;
line-height: 1.6;
color: var( --ink-soft, #56565c );
}
@media ( width <= 640px ) {
.wrap {
grid-template-columns: 1fr;
}
.stage {
border-right: none;
border-bottom: 1px solid var( --line, #e7e6e2 );
}
.theme {
grid-template-columns: 1fr;
}
}
/* The harness chrome honors a reduced-motion request too, so the toggle track and knob snap instead of sliding. */
@media ( prefers-reduced-motion: reduce ) {
.track,
.knob {
transition: none;
}
}
`;
/**
* Toggles the simulated forced-colors palette.
*/
private onToggle = (): void => {
this.forced = ! this.forced;
};
/**
* Renders the two cards on the simulated stage on the left, the toggle and the Windows note on the right.
*
* The fighting card keeps the brand class in every state, so its colors stay fixed while the theme changes underneath it.
* The integrating card only adopts the system keywords once `.on` is set, so off it matches the fighting card and on it splits away into the palette.
*/
render() {
return html`
<div class="wrap">
<div class="stage">
<div class=${ this.forced ? 'theme on' : 'theme' }>
<div class="card fighting">
<span class="tag">Fights the palette</span>
<div class="cover"></div>
<h3 class="heading">Designing for the person</h3>
<p class="summary">Brand styling re-asserted over the theme, the move forced-color-adjust: none makes in a real product, so the palette the user chose never lands here.</p>
<div class="footer">
<span class="read">Read article</span>
<button class="save" type="button">Save</button>
</div>
<p class="verdict">${ this.forced ? 'Brand held against the theme, so the card refuses the palette the user chose and muddies the guarantees the theme was set to carry.' : 'Branded and readable, the same as its neighbor for now.' }</p>
</div>
<div class="card integrating">
<span class="tag">Integrates with it</span>
<div class="cover"></div>
<h3 class="heading">Designing for the person</h3>
<p class="summary">System color keywords adopted, so the card drops into whatever palette the user chose and keeps every part legible.</p>
<div class="footer">
<span class="read">Read article</span>
<button class="save" type="button">Save</button>
</div>
<p class="verdict">${ this.forced ? 'Canvas, CanvasText, LinkText, ButtonText, and ButtonFace adopted, so the card reads cleanly inside the theme.' : 'Branded and readable, the same as its neighbor for now.' }</p>
</div>
</div>
</div>
<div class="controls">
<button type="button" class="toggle" @click=${ this.onToggle } aria-pressed=${ this.forced }>
<span>Forced colors</span>
<span class=${ this.forced ? 'track on' : 'track' }><span class="knob"></span></span>
</button>
<p class="note">
Simulated palette, painted here so the response is visible on any OS.
Real forced colors is a Windows Contrast themes path: the query never returns true on Apple platforms, and forced-color-adjust is a Safari no-op.
</p>
</div>
</div>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'demo-user-preference-media-queries-forced-colors': MediaQueriesForcedColorsDemo;
}
}Step 6: Input reachability, and why hover is never the gate
This is the other step where the instinct is wrong, and it’s the one that strands the most people. The pattern shows up everywhere, where a Save or a delete action is revealed only on :hover so the card looks clean at rest. It demos beautifully on a laptop with a mouse. Then it ships, and a touch user, who never produces a hover, never sees the action at all. An action that surfaces only on hover offers no affordance to a touch or keyboard user, so for them it reads as simply absent.
Keyboard users get hit even harder, and this is the part that turns a UX nicety into a compliance problem. If the control is hidden with display: none or visibility: hidden until hover, it’s also out of the tab order, so a keyboard user can never focus it. That fails WCAG 2.1.1 Keyboard (Level A), and it implicates 1.4.13 Content on Hover or Focus (Level AA), whose intent points right back at keyboard access. WebAIM’s keyboard accessibility guidance puts it plainly, that all content has to be operable with the keyboard alone.
So the rule is that an essential action is visible and focusable by default, and hover only enhances how it looks. The action is already available; hover just adds a little emphasis to it.
index.ts
/* index.ts, inside static styles */
.save {
opacity: 1;
transition: background-color 120ms ease;
}
/* The focus tint is feedback that must reach keyboard users regardless of pointer capability, so it is never gated by hover. */
.card:focus-within .save {
background-color: var( --tint, #eef2fe );
}
/* Hover only enhances an already-available action, on devices that actually hover. */
@media ( hover: hover ) {
.card:hover .save {
background-color: var( --tint, #eef2fe );
}
}
/* The lift is motion, so it drops out under a reduced-motion request, but it is not gated by hover, so keyboard focus gets it too. */
@media ( prefers-reduced-motion: no-preference ) {
.save {
transition: transform 120ms ease, background-color 120ms ease;
}
.card:focus-within .save {
transform: translateY( -1px );
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.card:hover .save {
transform: translateY( -1px );
}
}The shipped card never hides Save. It stays at opacity: 1, and the focus tint is declared on its own, outside the hover query, so a keyboard user tabbing in gets the same emphasis a mouse user gets on hover, even on a device that never hovers. Gating that focus tint behind hover: hover, which is easy to do by folding :focus-within into the hover block, would be the exact cross-signal slip this article warns about, letting a pointer-capability query decide whether keyboard feedback shows. The reveal pattern is the separate, discouraged case. If an action genuinely must stay hidden until interaction, the minimum bar is to pair :hover with :focus-within and bring it back with opacity rather than display, so the element stays in the tab order and keyboard focus surfaces it. Even then, visible by default is the safer call, which is why this build takes it.
The pointer features are a separate question, and it’s easy to misread them. hover and pointer describe only the primary input mechanism, while any-hover and any-pointer describe every pointing device available, so hover: hover does not rule out touch. On a touchscreen laptop with a mouse, the primary pointer is fine and hover is hover, yet any-pointer matches both fine and coarse. So for “is this a touch-capable context,” you read any-pointer: coarse, and you use it only to size touch targets up, never as a fix for keyboard reachability, which it says nothing about.
index.ts
/* index.ts, inside static styles */
@media ( any-pointer: coarse ) {
.save {
padding: 12px 18px;
min-height: 44px;
}
}The fifth demo is the one to try with the keyboard. The broken hover-only card sits next to the reachable one. Park your mouse and tap Tab through the broken card, and you watch the focus ring jump straight past Save, because hiding it with display: none pulled it from the tab order. Do the same on the reachable card and focus lands on a Save button that was there the whole time. What a senior flags here is that an essential action can’t live only behind hover, and hover: hover doesn’t rule out touch.
import { LitElement, html, css } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { ReachabilityVariant } from './types';
/**
* A card whose Save action is either trapped behind hover or reachable by default, for teaching the hover-reachability rule.
*
* The `'hover-only'` variant hides Save with `display: none` until `:hover`, which also drops it from the tab order, so a touch or keyboard user never sees or reaches it.
* The `'reachable'` variant keeps Save visible and focusable and lets hover only add emphasis, the version that ships.
* Tab into either card to feel the difference: the broken one never lands focus on Save, the reachable one does.
*
* @element hover-only-card
* @attr variant - `'hover-only'` for the broken card, `'reachable'` for the fixed one.
*/
@customElement( 'hover-only-card' )
export class HoverOnlyCard extends LitElement {
/**
* Which reachability variant to render.
*
* Defaults to `'hover-only'`, the broken case, so an unconfigured card shows the failure the demo is about.
*/
@property( { type: String, reflect: true } )
variant: ReachabilityVariant = 'hover-only';
/**
* The card heading shown above the action.
*/
@property( { type: String } )
heading = '';
/**
* Styles for the card, scoped to its shadow root.
*
* The two variants share a base and diverge only in how Save is revealed, so the contrast is the affordance and nothing else.
*/
static styles = css`
:host {
display: block;
color: var( --ink, #17171a );
font-family: var( --font-sans, system-ui, sans-serif );
}
.card {
display: grid;
gap: var( --space-3, 12px );
padding: var( --space-4, 16px );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-lg, 14px );
}
.card:focus-within {
outline: 2px solid var( --accent, #2257e6 );
outline-offset: 4px;
}
.heading {
margin: 0;
font-size: var( --fs-16, 16px );
}
.save {
justify-self: start;
padding: 8px 14px;
font: inherit;
font-size: var( --fs-13, 13px );
color: var( --ink, #17171a );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
cursor: pointer;
}
/* The broken card: Save is removed from the layout, and from the tab order, until a hover that touch and keyboard never produce. */
:host( [variant='hover-only'] ) .save {
display: none;
}
:host( [variant='hover-only'] ) .card:hover .save {
display: inline-flex;
}
/* The reachable card: Save stays present and focusable, and hover only adds emphasis. */
:host( [variant='reachable'] ) .save {
display: inline-flex;
opacity: 1;
transition: background-color 120ms ease;
}
/* The focus tint is feedback that must reach keyboard users regardless of pointer capability, so it is never gated by hover, mirroring the production card. */
:host( [variant='reachable'] ) .card:focus-within .save {
background-color: var( --tint, #eef2fe );
}
/* Hover only enhances an already-available action, on devices that actually hover. */
@media ( hover: hover ) {
:host( [variant='reachable'] ) .card:hover .save {
background-color: var( --tint, #eef2fe );
}
}
/* The lift is motion, so it exists only under no-preference, and it is not gated by hover, so keyboard focus gets it too. */
@media ( prefers-reduced-motion: no-preference ) {
:host( [variant='reachable'] ) .save {
transition: transform 120ms ease, background-color 120ms ease;
}
:host( [variant='reachable'] ) .card:focus-within .save {
transform: translateY( -1px );
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
:host( [variant='reachable'] ) .card:hover .save {
transform: translateY( -1px );
}
}
`;
/**
* Renders the card and its Save action in whichever reachability variant is set.
*/
render() {
return html`
<article class="card" aria-labelledby="heading">
<h3 class="heading" id="heading">${this.heading}</h3>
<button class="save" type="button">Save</button>
</article>
`;
}
}
declare global {
interface HTMLElementTagNameMap {
'hover-only-card': HoverOnlyCard;
}
}Step 7: The reduced-data trap, and a fallback the product owns
Everything so far has been a CSS preference that CSS honors. This last signal is the trap, and it’s where the rubric earns its keep. The idea reads like the others. There’s a media query for it, prefers-reduced-data, and you’d think you could serve a lighter hero behind it:
css
/* the idea, which ships nothing today */
@media (prefers-reduced-data: reduce) {
.cover {
/* swap to a lighter asset... except this never matches */
}
}Run it through the first rubric question, can the user actually set this signal today, and the answer is no. MDN’s page for the feature carries a banner reading “This feature is not supported by any user agent.” Chromium has a flag-gated experimental implementation, so “not supported by any user agent” is the accurate phrasing rather than “false everywhere,” but in practice this CSS query carries no policy in any shipping browser. There are two browser-level data signals that do exist, the Save-Data request header and navigator.connection.saveData, and both are Chromium-only, absent from Firefox and Safari. Chrome itself hasn’t shipped a data-saving toggle since Lite mode was removed in early 2022, though on Android the hint still follows the system Data Saver setting, so the practical path into it is OS-level and single-engine.
Browser data updated September 24, 2026
That table is the rendered proof. The usage figure it shows is the point of the whole step. A signal almost no one can produce can’t be the thing your product depends on. So when the product genuinely needs a data-saving path, you build one you own, in this order:
- an explicit in-product user setting, which is the source of truth and works across every engine because it’s yours,
- a server or client asset-selection policy driven by that setting (lighter codecs, smaller resolutions, deferred loads),
- the Chromium-only browser hints (
Save-Data,navigator.connection.saveData) consumed as an optional extra signal, never as the gate, - the CSS
prefers-reduced-dataquery as speculative progressive enhancement, only if it ever ships broadly.
The one piece of this that lives in the module is a small helper that reads that optional hint, exported beside the component; the card itself never calls it and just renders the heroVariant a product’s policy set. navigator.connection is the non-standard, Chromium-only Network Information API, and the standard TypeScript lib.dom.d.ts doesn’t declare it, so rather than reach for an as cast we teach the type through a Navigator augmentation in types.ts.
types.ts
// types.ts, the Navigator augmentation (this file is a module, so declare global is the augmentation form)
declare global {
interface Navigator {
/**
* The non-standard Network Information API, Chromium only, not Baseline, and absent in Firefox and Safari.
*
* Declared here because the standard `lib.dom.d.ts` does not ship it, so the read type-checks without an `as` cast.
* Optional, since it is `undefined` on every engine that does not implement it.
*/
connection?: {
/**
* True when the user has asked the browser for reduced data usage, on the engines that expose it.
*/
saveData?: boolean;
};
}
}index.ts
// index.ts, the one production read of a browser data hint
/**
* Reads the optional, Chromium-only Save-Data hint, one input a product may fold into its own asset policy.
*
* It decides nothing on its own: a product leads with its user setting and asset policy, and may consult this hint as an optional signal rather than the gate.
* Optional chaining is required, since `connection` is `undefined` in Firefox and Safari.
* Defaults to `false` wherever the hint is absent, so nothing withholds the full hero on a guess.
*
* @returns True when the browser reports a Save-Data preference, otherwise false.
*/
export function readSaveDataHint(): boolean {
return navigator.connection?.saveData ?? false;
}The verdict is the same one the table carried. The CSS query carries no policy, and Save-Data is Chromium-only, so ship a product-owned data-saving path with an explicit user setting. The component only exposes the seam that path drives, the heroVariant it renders; this demo stands that seam in with a CSS swap rather than transferring a lighter file, which is the piece a real asset policy would own.
Step 8: The senior review pass, then assemble
The pieces are all written, so this last step is the one a senior actually gets paid for. Before the component ships, you run the review eye back over it, and the review is the classification recap. Every signal the build touched gets named with its tier, the place its fallback lives, and the proof that the base card survives the query going missing. That pass is what turns a working component into one you’d approve, and it’s the habit this whole article was built to grow.
Walk the five signals the way you would on a pull request. prefers-reduced-motion is a requirement, honored in the @media (prefers-reduced-motion: reduce) block, and the focus ring and Save tint sit outside the animation, so cutting motion never cuts feedback. prefers-contrast: more is a requirement, honored in its own block that strengthens the border, the separation, and the focus ring alongside the text. forced-colors is a requirement, honored by adopting the system keywords so the card integrates with the user’s theme instead of fighting it. The hover and pointer features are a reachability requirement, met by keeping Save visible and focusable by default while hover and any-pointer: coarse only enhance it. prefers-reduced-data is the unsupported one, and its fallback lives in the product itself, in the heroVariant the card renders from the product’s own asset policy, with readSaveDataHint() available as an optional hint and never the gate. Run that list and the shape holds across all five, with CSS owning every preference response and the one non-CSS choice, reduced data, owned by the product rather than resolved in the card. A component that survives the recap is one you can approve.
The substance is the CSS you just wrote, and you have seen all of it in the slices above. What is left is packaging: the same card assembled into the small typed Lit component this series ships, so you can lift the whole thing at once instead of stitching the slices together. Its API is five properties, heading, summary, articleId, readHref, and heroVariant, plus one card-save event, and the only one tied to the reduced-data seam is heroVariant, which the product sets from its own asset policy and a consumer can seed from readSaveDataHint() as an optional input, never from a browser query the card reads itself. None of the judgment lived in the framework, though, so the same @media blocks work pasted straight into a stylesheet if you ship this without a component at all.
What carries production is the real @media queries, the @media ( width >= 40rem ) width query reflowing the layout and the prefers-reduced-motion, prefers-contrast, and forced-colors queries carrying the three accessibility responses, all sitting on the container-type: inline-size and @container ( width <= 30rem ) floor from Step 2 that keeps the reflow from crushing the card in a narrow container.
Notice what is not in this file: no wide attribute, no sim-* hooks, no demo scaffolding to strip out before you ship. Every preference response here is a real @media query that runs on its own, so the card you lift is the card that ships. The demos drive it with a small demo-adaptive-card subclass that adds those attribute hooks, since a sandboxed iframe cannot set a real OS preference or resize the real viewport, and none of that reaches this production file.
The public types go in types.ts:
types.ts
// types.ts
/**
* Which hero variant the card should render, the seam a product's asset policy drives.
*
* The card only receives this value; the product decides it from its own setting and asset policy, and the Chromium-only Save-Data hint is one optional input to that decision.
* `'light'` renders the lighter hero, which this demo stands in for by swapping a gradient rather than transferring a smaller file, the transfer a real asset policy would make.
*/
export type HeroVariant = 'full' | 'light';
/**
* The classification a media signal lands in once the four-question rubric has run.
*
* A requirement ships in CSS and the base stays correct without it.
* An enhancement layers on a base that is already complete and no-ops where it is absent.
* An unsupported signal drives no product behavior, so the product owns a real fallback instead.
*/
export type SignalTier = 'requirement' | 'enhancement' | 'unsupported';
declare global {
interface Navigator {
/**
* The non-standard Network Information API, Chromium only, not Baseline, and absent in Firefox and Safari.
*
* Declared here because the standard `lib.dom.d.ts` does not ship it, so the read type-checks without an `as` cast.
* Optional, since it is `undefined` on every engine that does not implement it.
*/
connection?: {
/**
* True when the user has asked the browser for reduced data usage, on the engines that expose it.
*/
saveData?: boolean;
};
}
}And the assembled index.ts, every CSS preference honored in CSS, the one data read in TypeScript:
index.ts
// index.ts
import { LitElement, html, css, nothing, type CSSResultGroup } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HeroVariant } from './types';
/**
* Reads the optional, Chromium-only Save-Data hint, one input a product may fold into its own asset policy.
*
* It decides nothing on its own: a product leads with its user setting and asset policy, and may consult this hint as an optional signal rather than the gate.
* The `navigator` existence check keeps the read safe in a server render, where no browser globals exist.
* Optional chaining is required, since `connection` is `undefined` in Firefox and Safari.
* Defaults to `false` wherever the hint is absent, so nothing withholds the full hero on a guess.
*
* @returns True when the browser reports a Save-Data preference, otherwise false.
*/
export function readSaveDataHint(): boolean {
if ( typeof navigator === 'undefined' ) {
return false;
}
return navigator.connection?.saveData ?? false;
}
/**
* An article card that reads the person and the device, going past the viewport width.
*
* It honors `prefers-reduced-motion`, `prefers-contrast: more`, and `forced-colors` in pure CSS, so no preference depends on JavaScript.
* The Save action stays visible and focusable by default, and hover only enhances its presentation, so touch and keyboard users always keep it.
* The one signal CSS can't carry today is reduced data, so the card takes a product-set `heroVariant` rather than reading a browser query, and a consumer may seed that value from the optional Save-Data hint.
*
* This is the production component a reader copies as is: every preference adaptation is a real `@media` query with no demo scaffolding to strip out.
* The demos drive it with a `demo-adaptive-card` subclass that adds attribute hooks, so a sandboxed iframe can force a branch without touching this file.
*
* @element adaptive-card
* @slot summary - The card's summary text; it falls back to the `summary` attribute when empty.
* @fires card-save - Fired when the Save button is pressed, carrying the article `id` so a page can react.
* @cssprop [--adaptive-card-accent=var(--accent)] - The accent used for focus, links, and the Save tint.
*/
@customElement( 'adaptive-card' )
export class AdaptiveCard extends LitElement {
/**
* The article title shown as the card's heading and its accessible name.
*
* When it is empty, the heading element and the `aria-labelledby` wiring are both omitted, so the card never exposes an empty heading as its accessible name.
*/
@property( { type: String } )
heading = '';
/**
* The article summary shown under the heading.
*
* A slotted `summary` overrides this, so a consumer can pass inline markup like emphasis or a link, which the slot renders inside the summary paragraph, while a plain string still works.
*/
@property( { type: String } )
summary = '';
/**
* A stable identifier for the article, echoed back in the `card-save` event.
*
* It carries no preference data, so it stays a plain product field.
* The `attribute` is set explicitly because Lit lowercases the default observed attribute, so `article-id` would otherwise never reach this property.
*/
@property( { type: String, attribute: 'article-id' } )
articleId = '';
/**
* The href the read link points at, a plain product field like the article id.
*
* It reflects so the host keeps the property visible as an attribute, for devtools inspection and attribute selectors; the anchor itself reads the property either way.
* It defaults to empty, and the read link renders only when a consumer sets it, so a copied card never ships a link to a route that does not exist.
*/
@property( { type: String, attribute: 'read-href', reflect: true } )
readHref = '';
/**
* Which hero variant to render, the seam a product's own asset policy drives.
*
* The card only receives this value; the product decides it from its user setting and asset policy, and a consumer may seed it from {@link readSaveDataHint} as an optional input.
* `'light'` swaps the animated hero for a flat static one, a pure-CSS stand-in that changes presentation rather than transferring a smaller file, the transfer the real asset policy would make.
* Defaults to `'full'`, so the card never withholds the rich hero without an explicit decision.
*/
@property( { type: String, attribute: 'hero-variant' } )
heroVariant: HeroVariant = 'full';
/**
* Styles for the card, scoped to its shadow root.
*
* Every preference adaptation lives here as a real `@media` query, with nothing to delete before shipping.
* The state feedback (the focus ring, the Save tint) is declared outside the hero animation, so the reduced-motion branch removes motion without removing feedback.
*/
static styles: CSSResultGroup = css`
/* The host is the layout container, so the two-column reflow can key off the card's own width and stay robust in a narrow slot. */
:host {
display: block;
container-type: inline-size;
color: var( --ink, #17171a );
font-family: var( --font-sans, system-ui, sans-serif );
}
.card {
display: grid;
gap: var( --space-4, 16px );
max-width: 34rem;
padding: var( --space-4, 16px );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-lg, 14px );
}
/* The width reflow: the cover takes a flexible column that caps at 11rem, and the body takes the rest. */
/* Both tracks are minmax( 0, ... ) so neither can overflow its share, and align-items: start keeps each column at its own content height. */
@media ( width >= 40rem ) {
.card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
align-items: start;
}
}
/* A production robustness floor that keeps the card one-column whenever the card box itself is under 30rem, so a viewport width breakpoint never forces two columns inside a narrow container. */
/* Without this floor the body would collapse to about 10rem the instant the card went two-column and crush the heading. */
@container ( width <= 30rem ) {
.card {
grid-template-columns: minmax( 0, 1fr );
}
}
/* The body groups the heading, summary, and footer, so the two-column layout can place the cover beside the whole text block. */
/* The min-width of 0 lets the body shrink and wrap inside its track instead of pushing the cover or overflowing the card. */
.body {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var( --space-3, 12px );
min-width: 0;
align-content: start;
}
/* The footer pins to the bottom of the body, so in the wide layout the Save action lines up with the foot of the cover. */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var( --space-3, 12px );
align-self: end;
}
/* The focus cue is declared on its own, outside the hero animation, so removing motion never removes feedback. */
.card:focus-within {
outline: 2px solid var( --adaptive-card-accent, var( --accent, #2257e6 ) );
outline-offset: 4px;
}
/* The cover is a deep gradient field with a bright orb that settles into place, a clear motion the reader sees on arrival. */
/* The cover sits at the start of its row and takes its column width, so its 4 / 3 box is derived from the width and never inflates past the column to overlap the body. */
.cover {
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
align-self: start;
aspect-ratio: 4 / 3;
border-radius: var( --radius-md, 11px );
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
/* The orb, the moving part: it drifts in and settles over a finite run, so the motion has a clear end and never loops forever. */
/* A finite settle keeps the hero clear of WCAG 2.2.2, which governs motion that starts on its own and runs past five seconds. */
.cover::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 56%;
aspect-ratio: 1;
margin: -28% 0 0 -28%;
border-radius: 50%;
background: radial-gradient( circle at 30% 30%, #ffffff, rgb( 255 255 255 / 0 ) 70% );
opacity: 0.85;
animation: settle 2.4s ease-out both;
}
/* The lighter hero the reduced-data product path selects, a flat fill that stands in for the lighter real asset a product would serve. */
.cover.light {
background: var( --tint, #eef2fe );
}
.cover.light::after {
content: none;
}
@keyframes settle {
0% {
transform: translate( -55%, -35% ) scale( 0.7 );
opacity: 0;
}
55% {
transform: translate( 30%, 20% ) scale( 1.08 );
opacity: 0.9;
}
100% {
transform: translate( 0%, 0% ) scale( 1 );
opacity: 0.85;
}
}
.heading {
margin: 0;
font-size: var( --fs-18, 18px );
line-height: 1.25;
}
.summary {
margin: 0;
color: var( --ink-soft, #56565c );
font-size: var( --fs-14, 14px );
line-height: 1.6;
}
.read {
color: var( --adaptive-card-accent, var( --accent, #2257e6 ) );
font-size: var( --fs-13, 13px );
text-decoration: none;
}
/* Save is visible and focusable by default, so touch and keyboard users always reach it. */
/* The color tint can transition because a color change is not the motion a reduced-motion user is guarding against. */
.save {
opacity: 1;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font: inherit;
font-size: var( --fs-13, 13px );
color: var( --ink, #17171a );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
cursor: pointer;
transition: background-color 120ms ease;
}
/* The focus tint is feedback that must reach keyboard users regardless of pointer capability, so it is never gated by hover. */
.card:focus-within .save {
background-color: var( --tint, #eef2fe );
}
/* Hover only enhances an already-available action, on devices that actually hover. */
@media ( hover: hover ) {
.card:hover .save {
background-color: var( --tint, #eef2fe );
}
}
/* The cosmetic lift is motion, so it drops out under a reduced-motion request, but it is not gated by hover, so keyboard focus gets it too. */
@media ( prefers-reduced-motion: no-preference ) {
.save {
transition: transform 120ms ease, background-color 120ms ease;
}
.card:focus-within .save {
transform: translateY( -1px );
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.card:hover .save {
transform: translateY( -1px );
}
}
/* Touch context: size the target up, the only thing any-pointer: coarse decides here. */
@media ( any-pointer: coarse ) {
.save {
padding: 12px 18px;
min-height: 44px;
}
}
/* Requirement: cut the optional hero motion, keep the focus and Save feedback above. */
@media ( prefers-reduced-motion: reduce ) {
.cover::after {
animation: none;
}
}
/* Requirement: strengthen the whole hierarchy across border, text, separation, and focus. */
/* This handles the more branch only. */
@media ( prefers-contrast: more ) {
.card {
border-color: var( --ink, #17171a );
border-width: 2px;
}
.cover {
border: 2px solid var( --ink, #17171a );
}
.summary {
color: var( --ink, #17171a );
}
.read {
text-decoration: underline;
text-decoration-thickness: 2px;
}
.save {
color: var( --ink, #17171a );
border-color: var( --ink, #17171a );
border-width: 2px;
}
.card:focus-within {
outline-width: 3px;
}
}
/* System mode: adopt the system palette and integrate, never fight it. */
@media ( forced-colors: active ) {
.card {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
.cover {
background: Canvas;
border: 1px solid CanvasText;
}
.cover::after {
animation: none;
background: none;
}
.summary {
color: CanvasText;
}
.read {
color: LinkText;
}
.save {
color: ButtonText;
background: ButtonFace;
border-color: ButtonText;
}
.card:focus-within {
outline-color: CanvasText;
}
}
`;
/**
* Renders the card: a semantic `article`, a heading, the summary, the read link when an href is set, and a Save button visible by default.
*
* The heading, summary, and footer are grouped in a `.body` element, so the two-column wide layout places the cover beside the whole text block.
* The hero gets the `light` class when `heroVariant` is `'light'`, the only place the product's asset decision touches the view.
* The heading and the read link each render only when their property is set, so a card missing one never ships an empty heading as its accessible name or a dead anchor.
*/
render() {
return html`
<article class="card" aria-labelledby=${this.heading ? 'heading' : nothing}>
<div class="cover ${this.heroVariant === 'light' ? 'light' : ''}" role="presentation"></div>
<div class="body">
${this.heading ? html`<h3 class="heading" id="heading">${this.heading}</h3>` : null}
<p class="summary"><slot name="summary">${this.summary}</slot></p>
<div class="footer">
${this.readHref ? html`<a class="read" href=${this.readHref}>Read article</a>` : null}
<button class="save" type="button" @click=${this.handleSave}>Save</button>
</div>
</div>
</article>
`;
}
/**
* Dispatches the `card-save` event with the article id when Save is pressed.
*
* It is `composed` so the event crosses the shadow boundary, and `bubbles` so a listener on the host receives it.
*/
private handleSave = (): void => {
this.dispatchEvent(
new CustomEvent<string>( 'card-save', {
detail: this.articleId,
bubbles: true,
composed: true,
} ),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'adaptive-card': AdaptiveCard;
}
}The final demo puts the finished card beside the senior review pass itself. The checklist marks every signal the build weighed with its tier: the four requirements honored in CSS, prefers-reduced-transparency as the lone enhancement, and prefers-reduced-data as the one unsupported signal the product owns instead, so all three tiers sit in one place. The enhancement row is the one worth pausing on, since the same signal is a requirement in a UI built on glassy translucent layers. Here the no is scoped to this card, where no core task depends on transparency, so the response only layers onto an already-readable base, which is exactly what makes it an enhancement rather than a requirement.
The controls stack those responses on the live card. Three toggles mirror the responses of the real @media branches, reduced motion, contrast, and forced colors, and the fourth drives the reduced-data hero variant, the product setting no browser query gates. Flip them however you like, read each response against the review comment the table opened with, then lift the assembled component and try every branch in your own code.
import { LitElement, html, css, nothing, type CSSResultGroup } from 'lit';
import { customElement, property } from 'lit/decorators.js';
import type { HeroVariant } from './types';
/**
* Reads the optional, Chromium-only Save-Data hint, one input a product may fold into its own asset policy.
*
* It decides nothing on its own: a product leads with its user setting and asset policy, and may consult this hint as an optional signal rather than the gate.
* The `navigator` existence check keeps the read safe in a server render, where no browser globals exist.
* Optional chaining is required, since `connection` is `undefined` in Firefox and Safari.
* Defaults to `false` wherever the hint is absent, so nothing withholds the full hero on a guess.
*
* @returns True when the browser reports a Save-Data preference, otherwise false.
*/
export function readSaveDataHint(): boolean {
if ( typeof navigator === 'undefined' ) {
return false;
}
return navigator.connection?.saveData ?? false;
}
/**
* An article card that reads the person and the device, going past the viewport width.
*
* It honors `prefers-reduced-motion`, `prefers-contrast: more`, and `forced-colors` in pure CSS, so no preference depends on JavaScript.
* The Save action stays visible and focusable by default, and hover only enhances its presentation, so touch and keyboard users always keep it.
* The one signal CSS can't carry today is reduced data, so the card takes a product-set `heroVariant` rather than reading a browser query, and a consumer may seed that value from the optional Save-Data hint.
*
* This is the production component a reader copies as is: every preference adaptation is a real `@media` query with no demo scaffolding to strip out.
* The demos drive it with a `demo-adaptive-card` subclass that adds attribute hooks, so a sandboxed iframe can force a branch without touching this file.
*
* @element adaptive-card
* @slot summary - The card's summary text; it falls back to the `summary` attribute when empty.
* @fires card-save - Fired when the Save button is pressed, carrying the article `id` so a page can react.
* @cssprop [--adaptive-card-accent=var(--accent)] - The accent used for focus, links, and the Save tint.
*/
@customElement( 'adaptive-card' )
export class AdaptiveCard extends LitElement {
/**
* The article title shown as the card's heading and its accessible name.
*
* When it is empty, the heading element and the `aria-labelledby` wiring are both omitted, so the card never exposes an empty heading as its accessible name.
*/
@property( { type: String } )
heading = '';
/**
* The article summary shown under the heading.
*
* A slotted `summary` overrides this, so a consumer can pass inline markup like emphasis or a link, which the slot renders inside the summary paragraph, while a plain string still works.
*/
@property( { type: String } )
summary = '';
/**
* A stable identifier for the article, echoed back in the `card-save` event.
*
* It carries no preference data, so it stays a plain product field.
* The `attribute` is set explicitly because Lit lowercases the default observed attribute, so `article-id` would otherwise never reach this property.
*/
@property( { type: String, attribute: 'article-id' } )
articleId = '';
/**
* The href the read link points at, a plain product field like the article id.
*
* It reflects so the host keeps the property visible as an attribute, for devtools inspection and attribute selectors; the anchor itself reads the property either way.
* It defaults to empty, and the read link renders only when a consumer sets it, so a copied card never ships a link to a route that does not exist.
*/
@property( { type: String, attribute: 'read-href', reflect: true } )
readHref = '';
/**
* Which hero variant to render, the seam a product's own asset policy drives.
*
* The card only receives this value; the product decides it from its user setting and asset policy, and a consumer may seed it from {@link readSaveDataHint} as an optional input.
* `'light'` swaps the animated hero for a flat static one, a pure-CSS stand-in that changes presentation rather than transferring a smaller file, the transfer the real asset policy would make.
* Defaults to `'full'`, so the card never withholds the rich hero without an explicit decision.
*/
@property( { type: String, attribute: 'hero-variant' } )
heroVariant: HeroVariant = 'full';
/**
* Styles for the card, scoped to its shadow root.
*
* Every preference adaptation lives here as a real `@media` query, with nothing to delete before shipping.
* The state feedback (the focus ring, the Save tint) is declared outside the hero animation, so the reduced-motion branch removes motion without removing feedback.
*/
static styles: CSSResultGroup = css`
/* The host is the layout container, so the two-column reflow can key off the card's own width and stay robust in a narrow slot. */
:host {
display: block;
container-type: inline-size;
color: var( --ink, #17171a );
font-family: var( --font-sans, system-ui, sans-serif );
}
.card {
display: grid;
gap: var( --space-4, 16px );
max-width: 34rem;
padding: var( --space-4, 16px );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-lg, 14px );
}
/* The width reflow: the cover takes a flexible column that caps at 11rem, and the body takes the rest. */
/* Both tracks are minmax( 0, ... ) so neither can overflow its share, and align-items: start keeps each column at its own content height. */
@media ( width >= 40rem ) {
.card {
grid-template-columns: minmax( 0, 11rem ) minmax( 0, 1fr );
align-items: start;
}
}
/* A production robustness floor that keeps the card one-column whenever the card box itself is under 30rem, so a viewport width breakpoint never forces two columns inside a narrow container. */
/* Without this floor the body would collapse to about 10rem the instant the card went two-column and crush the heading. */
@container ( width <= 30rem ) {
.card {
grid-template-columns: minmax( 0, 1fr );
}
}
/* The body groups the heading, summary, and footer, so the two-column layout can place the cover beside the whole text block. */
/* The min-width of 0 lets the body shrink and wrap inside its track instead of pushing the cover or overflowing the card. */
.body {
display: grid;
grid-template-rows: auto auto 1fr;
gap: var( --space-3, 12px );
min-width: 0;
align-content: start;
}
/* The footer pins to the bottom of the body, so in the wide layout the Save action lines up with the foot of the cover. */
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: var( --space-3, 12px );
align-self: end;
}
/* The focus cue is declared on its own, outside the hero animation, so removing motion never removes feedback. */
.card:focus-within {
outline: 2px solid var( --adaptive-card-accent, var( --accent, #2257e6 ) );
outline-offset: 4px;
}
/* The cover is a deep gradient field with a bright orb that settles into place, a clear motion the reader sees on arrival. */
/* The cover sits at the start of its row and takes its column width, so its 4 / 3 box is derived from the width and never inflates past the column to overlap the body. */
.cover {
position: relative;
overflow: hidden;
width: 100%;
min-width: 0;
align-self: start;
aspect-ratio: 4 / 3;
border-radius: var( --radius-md, 11px );
background: linear-gradient( 135deg, var( --accent, #2257e6 ), var( --accent-2, #5b8bff ) );
}
/* The orb, the moving part: it drifts in and settles over a finite run, so the motion has a clear end and never loops forever. */
/* A finite settle keeps the hero clear of WCAG 2.2.2, which governs motion that starts on its own and runs past five seconds. */
.cover::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 56%;
aspect-ratio: 1;
margin: -28% 0 0 -28%;
border-radius: 50%;
background: radial-gradient( circle at 30% 30%, #ffffff, rgb( 255 255 255 / 0 ) 70% );
opacity: 0.85;
animation: settle 2.4s ease-out both;
}
/* The lighter hero the reduced-data product path selects, a flat fill that stands in for the lighter real asset a product would serve. */
.cover.light {
background: var( --tint, #eef2fe );
}
.cover.light::after {
content: none;
}
@keyframes settle {
0% {
transform: translate( -55%, -35% ) scale( 0.7 );
opacity: 0;
}
55% {
transform: translate( 30%, 20% ) scale( 1.08 );
opacity: 0.9;
}
100% {
transform: translate( 0%, 0% ) scale( 1 );
opacity: 0.85;
}
}
.heading {
margin: 0;
font-size: var( --fs-18, 18px );
line-height: 1.25;
}
.summary {
margin: 0;
color: var( --ink-soft, #56565c );
font-size: var( --fs-14, 14px );
line-height: 1.6;
}
.read {
color: var( --adaptive-card-accent, var( --accent, #2257e6 ) );
font-size: var( --fs-13, 13px );
text-decoration: none;
}
/* Save is visible and focusable by default, so touch and keyboard users always reach it. */
/* The color tint can transition because a color change is not the motion a reduced-motion user is guarding against. */
.save {
opacity: 1;
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
font: inherit;
font-size: var( --fs-13, 13px );
color: var( --ink, #17171a );
background: var( --surface, #ffffff );
border: 1px solid var( --line, #e7e6e2 );
border-radius: var( --radius-pill, 999px );
cursor: pointer;
transition: background-color 120ms ease;
}
/* The focus tint is feedback that must reach keyboard users regardless of pointer capability, so it is never gated by hover. */
.card:focus-within .save {
background-color: var( --tint, #eef2fe );
}
/* Hover only enhances an already-available action, on devices that actually hover. */
@media ( hover: hover ) {
.card:hover .save {
background-color: var( --tint, #eef2fe );
}
}
/* The cosmetic lift is motion, so it drops out under a reduced-motion request, but it is not gated by hover, so keyboard focus gets it too. */
@media ( prefers-reduced-motion: no-preference ) {
.save {
transition: transform 120ms ease, background-color 120ms ease;
}
.card:focus-within .save {
transform: translateY( -1px );
}
}
@media ( hover: hover ) and ( prefers-reduced-motion: no-preference ) {
.card:hover .save {
transform: translateY( -1px );
}
}
/* Touch context: size the target up, the only thing any-pointer: coarse decides here. */
@media ( any-pointer: coarse ) {
.save {
padding: 12px 18px;
min-height: 44px;
}
}
/* Requirement: cut the optional hero motion, keep the focus and Save feedback above. */
@media ( prefers-reduced-motion: reduce ) {
.cover::after {
animation: none;
}
}
/* Requirement: strengthen the whole hierarchy across border, text, separation, and focus. */
/* This handles the more branch only. */
@media ( prefers-contrast: more ) {
.card {
border-color: var( --ink, #17171a );
border-width: 2px;
}
.cover {
border: 2px solid var( --ink, #17171a );
}
.summary {
color: var( --ink, #17171a );
}
.read {
text-decoration: underline;
text-decoration-thickness: 2px;
}
.save {
color: var( --ink, #17171a );
border-color: var( --ink, #17171a );
border-width: 2px;
}
.card:focus-within {
outline-width: 3px;
}
}
/* System mode: adopt the system palette and integrate, never fight it. */
@media ( forced-colors: active ) {
.card {
background: Canvas;
color: CanvasText;
border-color: CanvasText;
}
.cover {
background: Canvas;
border: 1px solid CanvasText;
}
.cover::after {
animation: none;
background: none;
}
.summary {
color: CanvasText;
}
.read {
color: LinkText;
}
.save {
color: ButtonText;
background: ButtonFace;
border-color: ButtonText;
}
.card:focus-within {
outline-color: CanvasText;
}
}
`;
/**
* Renders the card: a semantic `article`, a heading, the summary, the read link when an href is set, and a Save button visible by default.
*
* The heading, summary, and footer are grouped in a `.body` element, so the two-column wide layout places the cover beside the whole text block.
* The hero gets the `light` class when `heroVariant` is `'light'`, the only place the product's asset decision touches the view.
* The heading and the read link each render only when their property is set, so a card missing one never ships an empty heading as its accessible name or a dead anchor.
*/
render() {
return html`
<article class="card" aria-labelledby=${this.heading ? 'heading' : nothing}>
<div class="cover ${this.heroVariant === 'light' ? 'light' : ''}" role="presentation"></div>
<div class="body">
${this.heading ? html`<h3 class="heading" id="heading">${this.heading}</h3>` : null}
<p class="summary"><slot name="summary">${this.summary}</slot></p>
<div class="footer">
${this.readHref ? html`<a class="read" href=${this.readHref}>Read article</a>` : null}
<button class="save" type="button" @click=${this.handleSave}>Save</button>
</div>
</div>
</article>
`;
}
/**
* Dispatches the `card-save` event with the article id when Save is pressed.
*
* It is `composed` so the event crosses the shadow boundary, and `bubbles` so a listener on the host receives it.
*/
private handleSave = (): void => {
this.dispatchEvent(
new CustomEvent<string>( 'card-save', {
detail: this.articleId,
bubbles: true,
composed: true,
} ),
);
};
}
declare global {
interface HTMLElementTagNameMap {
'adaptive-card': AdaptiveCard;
}
}A few ways this goes wrong
- Reading only the viewport. A width breakpoint is a real adaptation, but it’s one signal out of several the browser is already reporting. Treating “responsive” as done once the layout reflows leaves motion, contrast, forced colors, and input on the table.
- Removing motion and removing the feedback with it. A blanket
transition: noneinside the reduced-motion branch can strip the focus ring and the state change the user actually relies on. Declare the feedback outside the animation so the reduced path keeps it. - Giving forced colors a clever custom look. Detecting forced-colors mode and reasserting your brand fights the user’s chosen palette. Adopt the system color keywords and integrate, and remember Apple’s OS settings don’t activate it, so test the real OS path on Windows.
- Hiding an essential action behind hover. Revealing Save only on
:hover, especially withdisplay: none, drops it from the tab order and loses it for touch and keyboard users. Keep it visible and focusable by default and let hover only add emphasis. - Treating
prefers-reduced-datalike the other preferences. It reads like a CSS preference, but no user agent supports it, so shipping behavior behind the query alone ships nothing. Lead with a product-owned setting and an asset policy, and treat the browser hints as optional.
What separates a senior from a junior here
- Classifying the signal before styling it. A junior asks whether a media feature is supported and styles behind it. A senior reads it as an input, asks which tier it lands in, requirement, enhancement, or unsupported, and lets that answer decide the response. The four-question rubric is that habit written down.
- Owning the fallback for a signal that isn’t real. A junior trusts the spec and ships the
prefers-reduced-dataquery. A senior checks whether a user can actually set it, finds that almost no one can, and builds the data-saving path the product owns instead of leaning on a query that does nothing.
Signals we didn’t build
The build covered five signals, but the rubric carries everything. The rest of the map runs a line or two each, so you can see the boundary of the article and the shape of the work past it.
prefers-color-schemeis the dark-mode signal you almost certainly already ship. Run it through the rubric and it usually lands as an enhancement, since a readable light-only card still works without it, though a product with a dark-first brand can elevate it to a requirement; either way you already know it, so the article just names it and moves on.prefers-reduced-transparencyis the tidy example of the enhancement tier. It’s real, but enabled by default only in Chromium today, with Firefox carrying it behind a flag and Safari without it, and the card stays readable whether or not it ever matches. That’s exactly the signal the final demo’s review checklist marks as the enhancement tier.dynamic-rangeandcolor-gamutare display-capability enhancements. Capability isn’t consent, and broad query support isn’t the same as a device actually reportinghigh, so they stay enhancement-only and out of the build.update,scripting,video-dynamic-range, andinverted-colorsare further signals worth knowing exist. Read each one through the same rubric when you reach for it.
These features sit across two spec levels. The prefers-* family and forced-colors come from Media Queries Level 5, still a Working Draft, while hover, pointer, and the any-* pair come from Media Queries Level 4. Whatever the spec revision, the durable move is to attribute the level you actually cite and re-check the live status before you publish, because the support picture is the part that moves.
Build your own
You’ve poked at the finished card above, where the switches stand in for preferences a sandboxed demo can’t set for you. Now build one yourself, and give it the test the demos can’t, against real preferences on your own machine. Turn on a real OS reduced-motion or contrast setting and confirm the whole card responds the way it would for an actual user, past the layout reflow. Tab through it with your hands off the mouse and make sure every action is reachable. If you have a Windows machine, switch on a Contrast theme and watch your card integrate. Treat all of that as the optional real-world pass on your own component, the genuine check the in-demo switches were only ever a stand-in for. Then take one signal the build skipped, run it through the four questions, and decide its tier before you write a rule. That last part, classifying the signal before styling it, is the eye this was meant to build.
Want more of these in your Google results?