Engineering7 min read

Why our PDF canvas had to become theme-immune

Dark mode was bleeding into the PDF preview. The fix required rewriting how Tailwind dark variants propagate through a nested editor surface.

You're building a visual PDF editor. The app has dark mode. But the document on the canvas is always going to be printed on white paper. What does dark mode do to that canvas?

The obvious answer: nothing. The canvas should stay white no matter what.

The not-obvious answer is why that's much harder to implement than it sounds, and why we had to re-think how Tailwind's dark: variant traverses a component tree.

The bug

The PDFx Builder canvas looks like this:

┌──────────────────────────────────────────────────────┐
│  Dark app chrome (sidebar, top bar, inspector)       │
│  ┌────────────────────────────────────────────────┐  │
│  │                                                │  │
│  │    White A4 page                               │  │
│  │    ┌────────────────────────────────────┐      │  │
│  │    │  Badge component (has dark:bg-...) │      │  │
│  │    └────────────────────────────────────┘      │  │
│  │                                                │  │
│  └────────────────────────────────────────────────┘  │
└──────────────────────────────────────────────────────┘

The A4 page is forced to white with a scoped CSS rule:

.canvas-page-light {
  --background: oklch(1 0 0);
  --foreground: oklch(0.135 0.008 258);
  --card: oklch(1 0 0);
  /* ...all tokens reset to light values */
}

That fixed the background and any element using CSS variables. But our Badge component had this:

// Before: a badge colored by its variant
const BADGE_COLORS = {
  success: "bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-400",
  warning: "bg-amber-100 text-amber-700 dark:bg-amber-950 dark:text-amber-400",
  // ...
};

Tailwind's dark:bg-green-950 is not driven by CSS variables. It's a class that only applies when the element is a descendant of .dark. Our app shell had <html class="dark"> at the top. So even inside .canvas-page-light, Tailwind still saw an ancestor .dark and activated the dark colors on the badge.

The canvas page was white, but the badges inside it were rendering in dark-950 on dark-950. Invisible.

Why CSS variables weren't enough

Our initial fix had been to reset every CSS variable inside the scoped class. That works for any component whose colors come from CSS variables. But dark:bg-green-950 doesn't read a variable. It writes a literal background-color: oklch(...) and Tailwind's compiler puts a .dark selector in front of it:

/* What Tailwind generates */
.dark .dark\:bg-green-950 {
  background-color: var(--color-green-950);
}

That .dark selector is the ancestor check. Resetting variables doesn't help because no variable is involved.

Tailwind's @custom-variant

Tailwind v4 lets you redefine what dark: means. In a fresh install, the default is:

@custom-variant dark (&:is(.dark *));

Read it as: "a dark: utility applies when the element is a descendant of any .dark."

Our fix was to exclude descendants of the canvas:

/* After */
@custom-variant dark (&:is(.dark *):not(.canvas-page-light *));

Now dark: utilities apply when the element is (a) inside .dark and (b) not inside .canvas-page-light. The badge's dark:bg-green-950 never fires inside the canvas, because the canvas is always outside Tailwind's dark-mode tree.

Why this works

The real insight is that our "canvas" is conceptually a different platform from the app. It's a simulation of a piece of white paper. Dark mode is a property of the app. The canvas is a window into another place. So dark: should stop at the window.

The custom-variant mechanism lets us draw that boundary in a single line of CSS. We don't have to audit every third-party or internal component for dark: usage. We don't have to override with !important. The variant itself knows where to stop.

What else this unlocks

Once the canvas is truly theme-immune, we can do things that would otherwise be painful:

  • PDF export fidelity. The rendered canvas looks exactly like the PDF, because both are now light-mode forever. The match is 1:1.
  • Shared components. We reuse our Badge in both app chrome (respects dark mode) and in the canvas (doesn't). One component, two behaviors, zero conditional code.
  • User-editable themes. A user can set a "brand" theme on their PDF, and nothing about app chrome leaks in.

The general principle

When you have a nested UI that simulates a different context (a canvas, a preview iframe, a print layout), the dark-mode boundary is part of that simulation. Don't fight it with !important or forked components. Redefine the variant to stop at the boundary.

Tailwind v4's @custom-variant makes this a one-line change. It's underused. If you run a visual editor, a print preview, or anything that has to feel like "a different surface within the app," put this in your back pocket.

One line of CSS

Here's the complete fix:

/* Before */
@custom-variant dark (&:is(.dark *));
 
/* After */
@custom-variant dark (&:is(.dark *):not(.canvas-page-light *));

That's it. The PDF preview has been theme-immune ever since.

Build one of these yourself.

Start free at pdfxbuilder.com. Three templates, no credit card.

Open PDFx Builder