|

Emulating Dark Mode, Reduced Motion, and Print in Playwright

Your app ships a slick dark theme, an accessible reduced-motion mode, and a clean print stylesheet — but does anyone actually test them? Most teams never do, because manually toggling OS-level settings is tedious and unrepeatable. In this guide you’ll learn how to Playwright emulate dark mode reduced motion and print media using a single built-in API, so these states become first-class, automated test cases in your TypeScript suite.

🎭 Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.

Contents

Why media emulation matters

Modern UIs branch their rendering on CSS media features. A user with the OS set to dark mode triggers @media (prefers-color-scheme: dark). Someone who enabled “Reduce motion” in their accessibility settings triggers @media (prefers-reduced-motion: reduce). And when a page is sent to a printer or saved as PDF, the browser switches to the print media type and applies @media print rules. Each of these is a real, shippable surface that can break independently of your default light-mode-on-screen experience.

The problem is that these states depend on the operating system or the rendering context, not on anything you can click in the page. You cannot reliably flip your CI machine’s OS theme mid-test. Playwright solves this with page.emulateMedia(), which overrides the values the browser reports to CSS — entirely at the browser level, no OS changes required, and fully isolated per page or per context.

The core API: page.emulateMedia()

Everything in this article is built on one method. page.emulateMedia() accepts an options object with the keys colorScheme, reducedMotion, forcedColors, contrast, and media. Any key you omit is left untouched; passing null for a key resets it to the system default. Here is the full surface in one place.

OptionAccepted valuesCSS media feature affected
colorScheme'light', 'dark', 'no-preference', nullprefers-color-scheme
reducedMotion'reduce', 'no-preference', nullprefers-reduced-motion
forcedColors'active', 'none', nullforced-colors
contrast'no-preference', 'more', nullprefers-contrast
media'screen', 'print', nullmedia type (@media print)

A quick first example. The snippet below loads a page, switches it into dark mode, and asserts that a CSS variable resolved to the dark value. Note how we read the computed style from inside the browser with page.evaluate().

import { test, expect } from '@playwright/test';

test('switches to the dark palette', async ({ page }) => {
  await page.goto('https://example.com');

  // Tell the page the user prefers a dark UI.
  await page.emulateMedia({ colorScheme: 'dark' });

  const bg = await page.evaluate(() =>
    getComputedStyle(document.body).backgroundColor
  );

  // A dark theme should not render a white body.
  expect(bg).not.toBe('rgb(255, 255, 255)');
});

Emulating dark mode the right way

The cleanest signal that your dark theme works is a matched-media check. Rather than hard-coding color strings, ask the browser whether the dark media query is active. This is robust against theme tweaks and reads almost like the CSS itself. You can also verify the matching media query directly with page.evaluate() and window.matchMedia.

import { test, expect } from '@playwright/test';

test.describe('color scheme', () => {
  test('honours dark preference', async ({ page }) => {
    await page.goto('https://example.com');
    await page.emulateMedia({ colorScheme: 'dark' });

    const isDark = await page.evaluate(
      () => window.matchMedia('(prefers-color-scheme: dark)').matches
    );
    expect(isDark).toBe(true);
  });

  test('honours light preference', async ({ page }) => {
    await page.goto('https://example.com');
    await page.emulateMedia({ colorScheme: 'light' });

    const isLight = await page.evaluate(
      () => window.matchMedia('(prefers-color-scheme: light)').matches
    );
    expect(isLight).toBe(true);
  });
});

If you want every test in a file to run in dark mode without repeating the call, set the color scheme at the context level. In Playwright Test, you do this with test.use() and the colorScheme fixture, which forwards straight into browser.newContext(). This is the most efficient approach because the preference is applied before the very first navigation, so even initial paint is rendered dark.

import { test, expect } from '@playwright/test';

// Every test in this block runs with a dark color scheme.
test.use({ colorScheme: 'dark' });

test('navbar uses the dark surface token', async ({ page }) => {
  await page.goto('https://example.com');
  const nav = page.locator('header');
  await expect(nav).toHaveCSS('background-color', 'rgb(17, 24, 39)');
});

One subtlety worth knowing: test.use({ colorScheme }) is applied when the context is created, whereas page.emulateMedia({ colorScheme }) can be called at any point during the test, including between assertions. Use the fixture for whole-suite defaults and the method when you need to flip the theme mid-test, for example to verify a theme-toggle button.

Testing reduced motion and stable visual snapshots

Reduced motion is an accessibility setting that asks the UI to drop or shorten animations. Beyond the accessibility win, emulating reducedMotion: 'reduce' is one of the most practical tricks for stabilising visual regression tests — animations are a leading cause of flaky pixel diffs, and a well-built app will pause them when reduced motion is requested.

import { test, expect } from '@playwright/test';

test('disables the hero carousel animation', async ({ page }) => {
  await page.emulateMedia({ reducedMotion: 'reduce' });
  await page.goto('https://example.com');

  const reduced = await page.evaluate(
    () => window.matchMedia('(prefers-reduced-motion: reduce)').matches
  );
  expect(reduced).toBe(true);

  // With motion reduced, a snapshot is far less likely to flake.
  await expect(page).toHaveScreenshot('home-reduced-motion.png');
});

It is worth being precise about what each tool does. Playwright’s toHaveScreenshot() already disables CSS animations and transitions by default through its built-in animations: 'disabled' option, which is separate from media emulation. The two are complementary: toHaveScreenshot freezes animations at capture time for the screenshot, while emulateMedia({ reducedMotion: 'reduce' }) changes how your application’s own code and stylesheets behave. If your app gates a JavaScript-driven animation behind matchMedia('(prefers-reduced-motion)'), only the media emulation will exercise that branch.

You can combine reduced motion with a dark scheme in a single call, since the options are independent keys on the same object.

import { test, expect } from '@playwright/test';

test('dark mode plus reduced motion together', async ({ page }) => {
  await page.goto('https://example.com');

  await page.emulateMedia({
    colorScheme: 'dark',
    reducedMotion: 'reduce',
  });

  const state = await page.evaluate(() => ({
    dark: window.matchMedia('(prefers-color-scheme: dark)').matches,
    reduced: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
  }));

  expect(state).toEqual({ dark: true, reduced: true });
});

Emulating print media and capturing PDFs

Print stylesheets are notoriously under-tested because, on screen, the browser always reports the screen media type. To exercise your @media print rules, pass media: 'print' to emulateMedia(). After that, any element hidden or restyled for printing will reflect those rules, and you can assert against them directly.

import { test, expect } from '@playwright/test';

test('hides navigation when printing', async ({ page }) => {
  await page.goto('https://example.com');

  // Many sites use @media print { nav { display: none } }
  await page.emulateMedia({ media: 'print' });
  await expect(page.locator('nav')).toBeHidden();

  // Reset back to the on-screen rendering for later steps.
  await page.emulateMedia({ media: 'screen' });
  await expect(page.locator('nav')).toBeVisible();
});

To go a step further and produce an actual PDF, use page.pdf(). This is Chromium-only and runs only in headless mode. Importantly, page.pdf() renders using the print CSS media by default, so your print stylesheet is applied automatically — if you want a PDF that looks like the on-screen page instead, you would call page.emulateMedia({ media: 'screen' }) first.

import { test, expect } from '@playwright/test';

test('generates an invoice PDF with print styles', async ({ page }) => {
  await page.goto('https://example.com/invoice/42');

  // page.pdf() applies @media print automatically.
  const buffer = await page.pdf({
    format: 'A4',
    printBackground: true,
    margin: { top: '20mm', bottom: '20mm' },
  });

  // The buffer should be a real, non-empty PDF.
  expect(buffer.length).toBeGreaterThan(1000);
  expect(buffer.subarray(0, 4).toString()).toBe('%PDF');
});

Configuring emulation across the whole project

Calling emulateMedia() in every test gets repetitive. For suite-wide or project-wide defaults, set the relevant options in playwright.config.ts. The colorScheme, reducedMotion, forcedColors, and contrast keys are all valid context options. A common pattern is to define separate projects so the same specs run once in light and once in dark, giving you cross-theme coverage for free.

import { defineConfig, devices } from '@playwright/test';

export default defineConfig({
  projects: [
    {
      name: 'light',
      use: { ...devices['Desktop Chrome'], colorScheme: 'light' },
    },
    {
      name: 'dark',
      use: {
        ...devices['Desktop Chrome'],
        colorScheme: 'dark',
        reducedMotion: 'reduce',
      },
    },
  ],
});

With this configuration, running npx playwright test executes the entire suite twice, and npx playwright test --project=dark runs only the dark variant. Because the preferences live on the context, every page opened within a test inherits them — no per-test boilerplate required.

The table below summarises when to reach for each layer of configuration as you scale up the ability to Playwright emulate dark mode reduced motion and print states.

ScopeHow to set itBest for
Single assertion / mid-test toggleawait page.emulateMedia({ ... })Flipping state during a test, theme-toggle buttons
One spec filetest.use({ colorScheme, reducedMotion })A file dedicated to dark-mode behaviour
Whole project / CI matrixprojects[].use in configRunning every spec across light and dark

🚀 Level Up Your Playwright

From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.

Common pitfalls to avoid

  • Forgetting to reset: within a single test, emulateMedia changes persist until you override them. If a later step assumes screen rendering, reset with page.emulateMedia({ media: 'screen' }) or pass null to clear a key.
  • Print PDFs are Chromium and headless only: page.pdf() throws in WebKit and Firefox, and in headed Chromium. Gate those tests to the Chromium project.
  • Confusing emulation with capture options: toHaveScreenshot‘s animation disabling is not the same as reducedMotion emulation — the former affects the screenshot, the latter affects your app’s logic.
  • Asserting on raw colors: prefer matchMedia checks or CSS-token assertions over hard-coded RGB strings, which break the moment a designer nudges a shade.

Conclusion

Media emulation turns three commonly neglected surfaces — dark theme, reduced motion, and print — into routine, automated checks. With a single method, page.emulateMedia(), plus context fixtures and a light/dark project matrix, you can Playwright emulate dark mode reduced motion and print states deterministically in CI, catch theme regressions before users do, and stabilise your visual snapshots along the way. Start by adding a dark project to your config and converting one flaky animated test to reduced motion — the payoff is immediate.

FAQ

Does page.emulateMedia change my operating system settings?

No. It only overrides the values the browser reports to CSS media queries and to window.matchMedia for that page or context. Your OS theme, accessibility settings, and other browser windows are completely unaffected, which makes it safe to run in parallel and in CI.

What is the difference between reducedMotion emulation and toHaveScreenshot animation handling?

They solve different problems. toHaveScreenshot() disables CSS animations and transitions at capture time so the screenshot is stable. Emulating reducedMotion: 'reduce' changes what your application code and stylesheets do, exercising any logic gated behind prefers-reduced-motion. Use both for accessible apps with motion-driven UI.

Can I test print styles without generating a PDF?

Yes. Call page.emulateMedia({ media: 'print' }) and your @media print rules become active in the live DOM, so you can assert visibility and computed styles with normal locators and matchers. Generating a PDF with page.pdf() is only needed when you want to validate the actual exported document.

🎓 Master Playwright End to End

Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.

Similar Posts

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.