JavaScript Detect Dark Mode
Use matchMedia() to detect a dark color preference in JavaScript, respond to changes, and choose when CSS alone is enough.

Use the browser’s window.matchMedia() API to check whether the prefers-color-scheme: dark media query matches:
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
// Run dark-specific application logic.
} else {
// The dark preference does not match.
}
That is a one-time check. If the page should respond when the preference changes while it is open, keep the returned MediaQueryList and listen for its change event. If the only goal is to change colors, CSS can handle the adaptation without JavaScript.
1. Detect the current dark preference
matchMedia() evaluates a CSS media query in the current page context and returns a MediaQueryList. Its matches property is a boolean: it is true when the query currently matches and false when it does not.
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
console.log(darkQuery.matches);
In browser code, the concise expression is:
const isDarkPreference = window.matchMedia("(prefers-color-scheme: dark)").matches;
A true result means the effective preference for this page context matches dark. A false result means the dark query does not match. Do not interpret false as proof that the person explicitly chose light mode: the media feature’s light value also applies when there is no active preference.
Use the result for behavior that actually needs JavaScript—for example, choosing a chart theme, selecting an appropriate editor palette, or deciding which theme to initialize in a canvas. If the decision is purely about page styling, use CSS instead.
2. Keep the page synchronized with preference changes
The operating-system or user-agent preference can change while a page remains open. Subscribe to the same MediaQueryList if the application must react to that change:
const darkQuery = window.matchMedia("(prefers-color-scheme: dark)");
function applyColorScheme(isDark) {
document.documentElement.dataset.theme = isDark ? "dark" : "light";
}
// Apply the current value immediately so the page starts in the right state.
applyColorScheme(darkQuery.matches);
// Apply later changes while this document is open.
darkQuery.addEventListener("change", (event) => {
applyColorScheme(event.matches);
});
Checking .matches before registering the listener matters: the change event reports future changes; it does not replace initial setup. The event’s matches property supplies the new boolean value, so the callback does not need to re-evaluate the query.
If this code runs in a component that can be removed, unregister the listener during cleanup. That avoids keeping an unnecessary callback attached after the component is gone.
function watchSystemTheme(onChange) {
const query = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = (event) => onChange(event.matches);
onChange(query.matches);
query.addEventListener("change", handleChange);
return () => query.removeEventListener("change", handleChange);
}
const stopWatching = watchSystemTheme((isDark) => {
document.documentElement.dataset.theme = isDark ? "dark" : "light";
});
// Call stopWatching() when the owning component or page controller is disposed.
For a one-time branch, skip the listener entirely. Add it only when live synchronization is part of the behavior.
3. Prefer CSS when only the appearance changes
A CSS media query is the smallest solution when dark mode only changes visual styles. It responds to the same preference without a JavaScript listener or a script-driven class update.

:root {
color-scheme: light dark;
--page-bg: #ffffff;
--page-fg: #202124;
--card-bg: #f4f5f7;
}
@media (prefers-color-scheme: dark) {
:root {
--page-bg: #181a1b;
--page-fg: #f1f3f4;
--card-bg: #25282a;
}
}
body {
color: var(--page-fg);
background: var(--page-bg);
}
.card {
background: var(--card-bg);
}
Keep shared layout and component rules outside the media query. Put only the values that change—usually colors, borders, shadows, and image treatments—inside it. CSS is also a useful fallback for visual behavior if JavaScript loads late or fails.
You can put the query directly in a stylesheet or in a style block. The browser applies the matching rule based on the effective color preference for the page.
4. Declare supported schemes for browser-controlled UI
If the document supports both schemes, declare that early in the document head:
<meta name="color-scheme" content="light dark">
This declaration tells the browser which schemes the document supports and gives an order of preference. Browser-controlled elements can then use a supported scheme. It does not supply the website’s own color palette: define page colors in CSS or another styling system.
The CSS color-scheme property can also declare supported schemes at the root:
:root {
color-scheme: light dark;
}
Choose the schemes the interface really supports. Declaring dark support while leaving controls or surfaces unreadable can make the browser UI and page content feel inconsistent.
5. Pick the right implementation
| Need | Use | Why |
|---|---|---|
| Change CSS colors based on the current preference | @media (prefers-color-scheme: dark) |
Styling stays in CSS and reacts without application code. |
| Choose a JavaScript-driven theme at page start | matchMedia(...).matches |
It gives a synchronous boolean for the current context. |
| React to a preference change after page start | MediaQueryList change event |
The event carries the new match status. |
| Make browser-controlled elements aware of supported schemes | color-scheme metadata or CSS property |
It declares supported schemes, but does not create page colors. |
A useful rule is: CSS for appearance, JavaScript for application behavior, and color-scheme when browser-provided UI should know which schemes the page supports.
6. Handle user-selected themes deliberately
Many sites offer an explicit light, dark, or system setting. Treat that setting as application state. The system query answers what the current page context prefers; it does not know whether the person has selected an override inside your app.
For a simple implementation, keep the selected mode separately and derive the effective theme:
const systemDarkQuery = window.matchMedia("(prefers-color-scheme: dark)");
let selectedMode = "system"; // "system", "light", or "dark"
function renderTheme() {
const isDark = selectedMode === "dark" ||
(selectedMode === "system" && systemDarkQuery.matches);
document.documentElement.dataset.theme = isDark ? "dark" : "light";
}
renderTheme();
systemDarkQuery.addEventListener("change", () => {
if (selectedMode === "system") renderTheme();
});
function setThemeMode(mode) {
selectedMode = mode;
renderTheme();
}
// Example control actions:
// setThemeMode("light");
// setThemeMode("dark");
// setThemeMode("system");
In a real application, connect setThemeMode() to the setting control and persist the selected mode if the product requires it. When the choice is light or dark, ignore system changes; when it is system, follow them. The preference query should not silently override a clear in-app selection.
To avoid an initial flash of the wrong theme, apply the chosen mode as early as the page architecture permits. A CSS-first design is often simpler because matching styles can apply without waiting for JavaScript.
7. Context, compatibility, and edge cases
The media feature is about the effective preference for the page’s context. It is commonly informed by an operating-system or user-agent setting, but it is not a guaranteed reading of one universal device-wide switch. Embedded SVG and iframe contexts can use the embedding page’s color scheme, so a nested document may not behave like a top-level tab.

MDN describes prefers-color-scheme as widely available across browsers since January 2020, window.matchMedia() since July 2015, and the MediaQueryList change event since September 2020. Those are broad compatibility summaries, not a promise about every webview, embedded browser, or unusually old runtime. Check the actual environments your application supports.
- No active preference: the dark query can be false. Treat that as “dark does not match,” not necessarily “the user chose light.”
- Embedded page: the effective scheme can be inherited from the embedding context. Validate the actual embed you support.
- One-time check: a stored boolean becomes stale if the preference changes. Subscribe to
changeonly if the page must stay synchronized. - Visual-only requirement: JavaScript adds state and lifecycle work. Prefer CSS media queries.
- Browser controls: page CSS alone may not declare the schemes browser UI should use; set
color-schemewhere appropriate.
The related Sec-CH-Prefers-Color-Scheme client hint and User Preferences API are unnecessary for ordinary client-side theme detection. The standard media query is the direct option for this task.
8. Troubleshoot common problems
| Symptom | Likely cause | Fix |
|---|---|---|
matches is false even though the page looks dark |
The query reports the effective preference, not the colors currently used by the site. The app may have an explicit dark override, or no active system preference may be expressed. | Keep app-selected mode separate from system preference. Log the query result and inspect the theme state independently. |
| The page has the correct theme on load but does not change later | The code performs only the initial .matches check. |
Attach a change listener to the same MediaQueryList and update the theme from event.matches. |
| Controls use a different scheme from the page | The document has not declared which schemes it supports, or its own palette does not follow the preference. | Declare color-scheme for supported browser UI schemes and define page colors in CSS. |
| An embedded page reports an unexpected result | The effective preference can depend on the embedding context, including for SVG and iframe content. | Test the embedded context itself and avoid assuming the query always exposes a top-level device setting. |
| Theme listeners behave after a component is removed | A listener was attached without a matching cleanup. | Retain the callback reference and call removeEventListener("change", callback) when the component is disposed. |
| Old embedded browser lacks the expected behavior | Broad browser availability does not cover every webview or legacy runtime. | Test target runtimes and provide a sensible default theme if the feature is unavailable in a supported legacy environment. |
For JavaScript that runs outside a browser, such as a server-side rendering process, window may not exist. Do not evaluate window.matchMedia() during server execution. Initialize the browser-specific check in client-side code and render a safe default or CSS-driven theme until then.
9. Reliability, performance, and testing
matchMedia() is a browser API designed to evaluate a media query, so checking it once is a small, local operation. The main reliability risks are state design and lifecycle mistakes: rendering from a stale initial value, letting system updates override an explicit app choice, or retaining listeners after their owner is gone.
Use CSS for color changes where possible. It avoids an extra JavaScript theme update and lets the browser apply the matching rules. If a chart, canvas, or other JavaScript-driven component needs the effective theme, read the initial value and subscribe only for as long as that component needs updates.
There is no external service or API request involved in this detection, so there is no network cost. For visual QA, check both preference states and the transition between them in the browsers and embedded contexts your app supports. Confirm text contrast, controls, focus indicators, borders, images, and any user override. Avoid treating one screenshot of a dark-looking page as proof that preference detection worked—the page might be using a fixed or app-selected theme.
10. Or skip the browser setup
If the task is to capture how a site looks in dark mode, ScreenshotNeo can request that rendering through its screenshot API. Its URL parameter conventions include dark_mode=true; the browser-side approach above remains useful when implementing the theme in your own page. See the ScreenshotNeo API docs for request options.
curl -G "https://api.screenshotneo.com/v1/shot" \
-d access_key=YOUR_API_KEY \
--data-urlencode url=https://stripe.com \
-d dark_mode=true \
-o shot.webp
ScreenshotNeo is a website screenshot API and MCP server from Yorker Media. Cookie and consent banners, newsletter popups, and chat widgets are removed before the shot; bot checks, blank pages, failed loads, timeouts, and cache hits cost nothing. AI agents can use its MCP server tools to take screenshots. The free plan includes 1,000 screenshots a month with no card, and paid plans start at $5 for 3,000.
Sign up free for 1,000 screenshots a month, no card required.
FAQ
Does false mean the user prefers light mode?
No. It means the dark query does not match. The light value also applies when there is no active preference.
Can I use this in CSS without JavaScript?
Yes. Use @media (prefers-color-scheme: dark) for styling and declare supported schemes with color-scheme if browser-controlled UI should adapt.
Will the query always reveal the computer’s system setting?
It reports the effective preference for the current page context. Embedded documents can reflect the embedding page’s scheme.
Do I need a listener?
Only if JavaScript-driven behavior must update after the initial page check. CSS adapts directly for visual changes.


