JavaScript matchMedia Table

PieceWhat it doesField note
matchMedia('(max-width: 700px)')Ask CSS a questionSame syntax as @media - one source of truth for breakpoints
mql.matchesThe boolean answerEvaluate at runtime - JS branching that agrees with the stylesheet
mql.addEventListener('change', fn)React to crossingFires on BOTH directions - check e.matches inside
prefers-color-scheme: darkSystem theme queryThe dark-mode detection primitive - pair with a user override
prefers-reduced-motionMotion sensitivityJS animations gate on this - accessibility, not decoration
JS media query vs CSS classLayout vs logicCSS handles appearance; matchMedia only when LOGIC must branch
mql vs resize listenerThresholds vs pixelsresize fires per pixel; mql fires per breakpoint crossing
old mql.disconnect()Remove change listenersSame teardown discipline as event listeners
Reference: the MDN matchMedia reference. matchMedia lets JavaScript ask CSS questions with the SAME syntax as your stylesheet - one breakpoint definition serving both appearance and logic, so the mobile menu collapse in CSS and the JS behavior switch can never disagree. Bottom line: it fires per BREAKPOINT CROSSING (not per resized pixel like a resize listener), which is the efficient granularity; prefers-color-scheme is the dark-mode detection primitive (pair it with a manual override stored in localStorage), and prefers-reduced-motion is the accessibility gate for any JS-driven animation. Related tools: media queries table (the CSS side of the same syntax), custom properties table (theme tokens the query switches), and localStorage table (persisting the user's manual theme choice).

matchMedia lets JavaScript ask CSS questions in CSS's own syntax: matchMedia('(max-width: 700px)').matches is a boolean that agrees with your stylesheet by construction - one breakpoint definition serving both appearance and behavior, so the mobile collapse in CSS and the JS logic switch can never disagree.

Bottom line: MediaQueryList fires change events per BREAKPOINT CROSSING, not per resized pixel - the efficient granularity a resize listener lacks (it fires dozens of times per drag). Inside the handler, check e.matches, because the event fires in both directions.

The honest part: matchMedia is for LOGIC that must branch, not for appearance - the moment CSS can express it (hiding, stacking, reflowing), CSS media queries belong there instead. The JS side earns its keep for behavior: pausing a carousel, switching a chart library, requesting different data.

How to use

  1. Gate logic on the same breakpoints as the CSS: const mq = matchMedia('(max-width: 700px)') - reuse the exact query strings, or define both from one constant.
  2. Detect and persist dark mode: matchMedia('(prefers-color-scheme: dark)').matches picks the default, a stored localStorage choice overrides it, and the change listener follows live switches.
  3. Respect motion settings: if (!matchMedia('(prefers-reduced-motion: reduce)').matches) runAnimation() - the accessibility gate every JS animation owes.

Frequently asked questions

Why is matchMedia better than a resize listener for responsive behavior?

Granularity and agreement. A resize listener fires continuously during a drag - dozens of events needing debouncing - while a MediaQueryList change event fires exactly when a THRESHOLD is crossed: once entering mobile, once leaving. That is the granularity responsive logic actually wants. The subtler win is agreement: the query string is identical to the CSS @media condition, so the JS branch and the stylesheet rule flip at the same pixel - a resize listener with a hardcoded 700 makes a second source of truth that drifts from the CSS the first time someone retunes a breakpoint.

How do I build a theme system on prefers-color-scheme?

Three layers. The CSS layer carries both palettes under prefers-color-scheme media queries (or light-dark() values). The JS detection layer reads matchMedia('(prefers-color-scheme: dark)').matches to pick the DEFAULT and, critically, listens for change so following the OS switch live works. The override layer is user choice: a stored value in localStorage wins over the system default, which is the feature users actually expect (the OS says dark, the site toggle says light, light wins). Without the stored layer, a site that follows the system gets it right until one user wants the opposite - then has no recourse.

What does prefers-reduced-motion ask of JavaScript animations?

The same gate CSS animations get. The setting signals vestibular sensitivity - parallax, auto-playing carousels, large slides and zooms can cause real discomfort. In CSS, wrap decorative animation in a media query; in JS, gate programmatic animation on !matchMedia('(prefers-reduced-motion: reduce)').matches. The nuance: reduced motion does not mean no motion - opacity fades and color transitions generally remain acceptable while movement-across-the-screen and scale jumps should stop. Also listen for change: users flip the setting in response to what they see, mid-session.

Does the matchMedia change event fire in both directions?

Yes - one event, both crossings: entering and leaving the queried state both fire, with e.matches telling you which direction. Write the handler as a pure function of matches (if (e.matches) enableMobile() else enableDesktop()) rather than assuming entry, and idempotency comes free - a handler that only handles 'entered' leaves stale state behind on exit. The same pattern covers prefers-color-scheme flips and reduced-motion toggles. Cleanup uses removeEventListener on the SAME MediaQueryList object (or the signal option) - the mql is an object with identity, not a string.

Related tools