Handling font rendering differences across browsers

This page is part of the Cross-Browser Matrix section, which covers keeping the same component visually stable across Chromium, WebKit, and Firefox. The specific problem here is narrower: a component that has not changed at all produces different screenshots on different engines and on different machines, purely because text is rasterized differently.

You capture a baseline of a <Button> on your Mac, push, and the Linux CI job reports a diff on every pixel that touches a letter. Nothing in the component changed. The DOM is identical, the CSS is identical, the layout box is identical. Only the glyphs are drawn with slightly different edges.

Problem statement

Font rendering is the single largest source of false positives in cross-browser visual testing. A screenshot assertion compares two bitmaps pixel by pixel, but the bitmap for a run of text is produced by a rasterizer that depends on the operating system, the installed font files, the antialiasing mode, and — for subpixel rendering — even the physical subpixel layout the engine assumes.

Three things vary independently and each one moves glyph pixels:

  • The font files themselves. macOS ships Helvetica Neue and San Francisco; a stock Ubuntu CI runner ships neither and silently falls back to DejaVu Sans or Liberation Sans. The letters have different shapes, so the diff is total.
  • Hinting and antialiasing. Even with identical font files, FreeType on Linux, CoreText on macOS, and DirectWrite on Windows apply different hinting and edge smoothing. The same glyph lands on the same pixel grid but its anti-aliased edges have different grey values.
  • Subpixel (RGB) antialiasing. When enabled, edges are tinted red/blue to exploit LCD subpixels. This tinting is GPU- and driver-dependent and never reproduces byte-for-byte between a local machine and a headless CI container.

The result: the component is correct, but the test is red. Worse, the noise is spread thinly across every glyph edge, so it is impossible to distinguish from a genuine one-word copy change without solving the rendering problem itself.

Root cause

The cross-browser matrix exists because each browser engine owns its own text rasterizer, and that rasterizer reads from the host operating system’s font stack. When you generate a baseline in one environment and compare it in another, you are not comparing your component — you are comparing two font-rendering pipelines. The pixel diff engine has no way to know that a 3-value change in the grey ramp along the edge of a letter is “the same” text; it only sees changed pixels. Until the font stack and antialiasing mode are held constant, the comparison is measuring the environment, not the component.

Why font rasters diverge across engines and how pinning fixes it Top row: a single Button component fans out to three engines — Chromium, WebKit, Firefox — each on a different OS font stack, producing three different glyph rasters and red diffs. Bottom: routing all engines through one pinned Docker font stack with grayscale antialiasing produces one deterministic raster per engine. <Button /> one component Unpinned: each engine reads the host font stack Chromium macOS · CoreText subpixel AA WebKit Linux CI · FreeType fallback font Firefox Linux CI · FreeType own hinting three different rasters → false-positive diffs Pinned: every engine reads one Docker font stack pinned fontconfig + grayscale AA deterministic per-engine baseline reproducible

Minimal reproduction

A single Playwright screenshot test against one component, run first on a Mac and then in a stock Ubuntu CI runner, reproduces the whole problem:

// button.spec.ts — a component-level screenshot with no font pinning
import { test, expect } from '@playwright/experimental-ct-react';
import { Button } from './Button';

test('primary button matches baseline', async ({ mount }) => {
  const component = await mount(<Button variant="primary">Save changes</Button>);
  await expect(component).toHaveScreenshot('button-primary.png');
});

Generate the baseline locally, commit it, then run the same test in CI:

# locally (macOS): creates button-primary-chromium.png
npx playwright test button.spec.ts --update-snapshots

# in Linux CI, against the committed macOS baseline
npx playwright test button.spec.ts

CI fails with a diff concentrated entirely on the text:

  1) button.spec.ts:5:1 › primary button matches baseline
     Error: Screenshot comparison failed:
       2183 pixels (ratio 0.04) are different.
       Expected: button-primary-chromium.png
       Received: button-primary-chromium-actual.png

Open the diff image and every changed pixel sits on the edge of a letter in “Save changes”. The button box, the padding, and the background colour are untouched. The component is fine; the font raster is not.

Step-by-step fix

1. Pin the font stack in a Docker image

The root fix is to make every screenshot — baseline and comparison, local and CI — come out of the same font environment. Build on Playwright’s official image (which pins the browser binaries) and install exactly the fonts you render with, plus a locked fontconfig.

# Dockerfile.visual — one font environment for every screenshot
FROM mcr.microsoft.com/playwright:v1.48.0-jammy

# Install only the fonts the app actually uses, plus fontconfig
RUN apt-get update && apt-get install -y --no-install-recommends \
      fontconfig=2.13.1-4.2ubuntu5 \
      fonts-liberation2 \
    && rm -rf /var/lib/apt/lists/*

# Bundle the exact webfont files the app ships (do not rely on system fallbacks)
COPY fonts/Inter-Regular.ttf fonts/Inter-SemiBold.ttf /usr/share/fonts/truetype/inter/
RUN fc-cache -f && fc-list | grep -i inter

WORKDIR /work

What this does: pins the Playwright browser binaries, the fontconfig version, and the actual Inter font files into one image. Because the baseline and the CI comparison both run in a container built from this Dockerfile, the font files can never differ between them.

2. Force grayscale antialiasing and disable subpixel rendering

Even with identical fonts, subpixel (RGB) antialiasing produces coloured fringing that varies by GPU and driver. Force FreeType to grayscale antialiasing via a fontconfig rule, and reinforce it in CSS so the browser never opts into subpixel smoothing.

<!-- fonts/local.conf — copied to /etc/fonts/local.conf in the image -->
<?xml version="1.0"?>
<!DOCTYPE fontconfig SYSTEM "fonts.dtd">
<fontconfig>
  <match target="font">
    <edit name="rgba" mode="assign"><const>none</const></edit>       <!-- no subpixel RGB -->
    <edit name="antialias" mode="assign"><bool>true</bool></edit>    <!-- grayscale AA only -->
    <edit name="hinting" mode="assign"><bool>true</bool></edit>
    <edit name="hintstyle" mode="assign"><const>hintslight</const></edit>
    <edit name="lcdfilter" mode="assign"><const>lcddefault</const></edit>
  </match>
</fontconfig>
/* global test stylesheet — injected before every screenshot */
* {
  -webkit-font-smoothing: antialiased;   /* grayscale, not subpixel, on WebKit/Chromium */
  -moz-osx-font-smoothing: grayscale;
  text-rendering: geometricPrecision;    /* disable engine-specific optimizeLegibility heuristics */
}

Add the COPY for local.conf to the Dockerfile and re-run fc-cache -f. What this does: removes the two most driver-dependent variables — subpixel RGB tinting and aggressive hinting — so the only remaining differences are the deterministic parts of each engine’s rasterizer.

3. Generate every baseline inside the container

Baselines produced on your laptop are worthless the moment they are compared in CI. Produce them in the same image, and mount the repo so the updated PNGs land back in the working tree.

# Build once, then generate baselines in the pinned environment
docker build -f Dockerfile.visual -t fc-visual .

docker run --rm -v "$PWD":/work fc-visual \
  npx playwright test button.spec.ts --update-snapshots

What this does: the reference images are rasterized by the same fonts and antialiasing settings the CI job uses. The -v "$PWD":/work bind mount writes the regenerated *.png baselines straight back into your checkout so you can commit them.

4. Store per-engine baselines with a small tolerance

Chromium, WebKit, and Firefox will still rasterize slightly differently from each other even inside one container — that is expected, and it is why you keep one baseline per engine rather than one shared image. Define one Playwright project per browser and apply a small tolerance threshold to absorb the last few unavoidable antialiasing pixels.

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

export default defineConfig({
  expect: {
    toHaveScreenshot: {
      // absorb residual sub-pixel AA noise, not layout or copy changes
      maxDiffPixelRatio: 0.002,
      // treat near-identical grey values along edges as equal
      threshold: 0.15,
    },
  },
  // one project per engine → Playwright suffixes baselines -chromium / -webkit / -firefox
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
    { name: 'webkit', use: { ...devices['Desktop Safari'] } },
    { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
  ],
});

What this does: threshold sets the per-pixel colour-distance tolerance (how different one pixel’s grey value may be before it counts as changed), while maxDiffPixelRatio caps how many such pixels are allowed across the whole image. Together they swallow the residual half-pixel edge noise without swallowing a real one-word text change, which lights up far more than 0.2% of the image. Playwright’s per-project naming gives you button-primary-chromium.png, -webkit.png, and -firefox.png automatically.

Verification

Prove the baseline is deterministic before you trust it: run the suite twice back-to-back in the container and confirm zero diff pixels between runs.

docker run --rm -v "$PWD":/work fc-visual \
  npx playwright test button.spec.ts --reporter=line

Expected output when font rendering is pinned correctly:

Running 3 tests using 3 workers
  ✓  [chromium] › button.spec.ts:5:1 › primary button matches baseline (612ms)
  ✓  [webkit]   › button.spec.ts:5:1 › primary button matches baseline (704ms)
  ✓  [firefox]  › button.spec.ts:5:1 › primary button matches baseline (689ms)

  3 passed (4.1s)

To confirm the fix actually removed the noise (rather than the threshold hiding it), temporarily set maxDiffPixelRatio: 0 and re-run the same test twice in the container. Two identical runs should still pass with zero diff pixels — that is the signal that the environment, not the tolerance, is doing the work:

  ✓  [chromium] › primary button matches baseline (598ms)   0 diff pixels

If you instead see a non-zero diff between two identical runs, the environment is still leaking a variable — usually a font that slipped through to a system fallback. Re-check fc-list output inside the image.

Edge cases and caveats

  • Emoji and icon fonts render from colour bitmap tables that are not pinned by installing a TTF. A 🎉 or a Material Symbols glyph is drawn from an embedded colour bitmap (CBDT/COLR) whose rasterization differs across engines regardless of hinting settings. Mask emoji and icon regions with Playwright’s mask option, or replace them with inline SVG in test builds.
  • Web fonts loaded over the network can screenshot mid-swap. If the component uses font-display: swap, a screenshot taken before the web font loads captures the fallback face, producing intermittent diffs. Await document.fonts.ready before the assertion, and prefer bundling the font file into the image over fetching it at runtime.
  • CI runner image updates silently change the font stack. Upgrading the base Playwright image or the Ubuntu release can bump fontconfig or FreeType and shift every glyph by a pixel. Pin the image by digest, not by floating tag, and regenerate baselines deliberately as part of the upgrade — never let an unrelated base-image bump rewrite your references.

FAQ

Why do my screenshots differ between local macOS and Linux CI?

macOS and Linux ship different font files, different fontconfig defaults, and different antialiasing engines. macOS applies its own subpixel smoothing while a Linux CI runner uses FreeType with grayscale hinting. Even with the same CSS font-family, the glyph rasters differ by several pixels per edge. Generating baselines inside the same Docker image the CI job uses removes the platform as a variable.

Should I keep one baseline per browser or a single shared baseline?

Keep one baseline per engine. Chromium, WebKit, and Firefox rasterize the same font with different hinting and antialiasing pipelines, so a single shared baseline will always show diffs on at least two of the three engines. Playwright names screenshots by project automatically, so per-engine baselines are the default when you define one project per browser.

Can I just raise the diff threshold instead of pinning fonts?

A high threshold hides real regressions. Antialiasing noise is spread thinly along every glyph edge, so masking it requires a threshold large enough to also mask a one-line text change or a colour shift. Pin the fonts and disable subpixel antialiasing first, then apply only a small maxDiffPixelRatio to absorb the last few unavoidable pixels.