Dark mode is easy until the important part of the page is not HTML.

Google Sheets and parts of Google Docs render the editing surface into canvas-backed layers. A normal page-color stylesheet can darken the toolbar while leaving the grid bright. A page-wide CSS filter reaches the grid, but it also reaches every photo, video frame and icon inside it.

That creates a worse problem: a photo can become a negative, and a careless color-rewriting approach can change what the editor thinks the document's colors are.

I built DarkWave to solve this problem for my own daily work. The stable v1.4 release is free, makes no network requests, and supports Google Sheets, Docs, Slides and ordinary websites. The interesting part is not the toggle. It is keeping the visual change separate from the document's data.

Why the obvious approaches fail

There are three common approaches to browser dark mode:

  1. Rewrite color and background-color on every element.
  2. Apply an inversion filter to the page root.
  3. Re-invert media with a second filter.

The first approach changes computed styles. That matters in an editor: the editor can read those styles while it serializes a cell or paragraph. A visual preference should not become saved document formatting.

The second approach reaches canvas-rendered surfaces, but it also inverts photos and video. A red image does not become a darker red image; it becomes a different image.

The third approach helps ordinary <img> and <video> elements, but it cannot repair pixels that an editor already painted into its own canvas. The correction has to happen at the editor's presentation boundary.

Treat the page and the presentation surface separately

The useful split is:

  • HTML and authored CSS: theme the page's surfaces and text.
  • Media elements: preserve their pixels.
  • Editor canvases: let the editor keep drawing, then compensate only for the page-level presentation effect.

For Google editor pages, DarkWave installs a small, scoped canvas adapter. It observes the drawing operation that matters: drawImage(). When the editor paints a real photo into a connected presentation canvas, the adapter applies the inverse presentation transform to that image draw. The page can then use its dark treatment without turning the photo into a negative.

This is deliberately narrow. Canvas-to-canvas copies, glyph buffers, tiny UI sprites and detached scratch surfaces are not treated as photographs. A small glyph is part of the editor's UI; a large image region is content. The adapter uses size and source checks instead of assuming that every canvas draw is a photo.

Repainting is part of the design

Editors redraw constantly. Turning dark mode on once is not enough:

  • the document may already contain photos when the extension starts;
  • a toggle may change the presentation transform;
  • Sheets may shrink a large source photo into a small in-cell image;
  • a deleted image must not reappear because an old draw record survived.

The adapter remembers enough information to repaint a still-visible photo when the presentation changes. Before restoring it, it checks that the region still contains the same image rather than blindly drawing an old snapshot back over the editor. This avoids the classic “ghost photo” bug after a canvas redraw.

The check also needs to tolerate resampling. Comparing a single pixel works for a large image and fails for a photo reduced to a small cell. Comparing several small blocks across the middle of the region is slower than a single pixel, but much more reliable for an editor surface.

Never use the editor's canvas as a scratchpad

Canvas readback is another trap. Repeatedly calling getImageData() on a GPU-backed editor canvas can produce browser warnings and force expensive readbacks. It can also interfere with the editor's own rendering path.

The safer pattern is:

  1. Copy the region to a private scratch canvas configured for readback.
  2. Read pixels from the scratch canvas.
  3. Leave the editor's context state unchanged.

The adapter snapshots and restores the drawing context around its work: filter, transform, clipping path, styles and other state that the editor expects to remain its own.

What the user should experience

The implementation details should disappear. A user opening a Sheet should see:

  • the whole grid become comfortable to read;
  • photos retain their real colors;
  • typing and saving continue normally;
  • an already-dark page remain untouched;
  • an unusual page remain easy to exclude with one site-level control.

That last control matters because no automatic theme can predict every site's intent. A good dark-mode extension needs an escape hatch that affects one site instead of forcing the user to disable the feature everywhere.

The broader lesson

Dark mode is a presentation problem, not a data-mutation problem.

The most robust implementation keeps those boundaries explicit:

  • style the page without changing the page's authored data;
  • identify presentation surfaces that use a different rendering path;
  • preserve media as media;
  • adapt only the drawing operation that creates the visual mismatch;
  • verify the result through real editor interactions, not just a static screenshot.

That is the difference between “the page looks dark in a demo” and a dark mode that can be left enabled during real work.

If you want to try the finished extension, DarkWave is available at tjmanoj.github.io/darkwave. I built it, so practical feedback from real Sheets and Docs workflows is especially useful.