Dark mode is not as simple as inverting colors. Anyone who has tried using a naive dark mode extension knows the experience: images become negatives, logos turn into ghosts, maps become unreadable, and some websites end up with dark text on a dark background. Building a dark mode that actually works well across every website on the internet is one of the hardest UI engineering challenges you can tackle. This is the story of how we built Tensor's dark mode engine and the technical decisions that make it work.
The Naive Approach and Why It Fails
The simplest dark mode implementation is a CSS filter applied to the entire page:
html {
filter: invert(1) hue-rotate(180deg);
}
This inverts all colors and then rotates the hue by 180 degrees to restore approximate original hues. It works surprisingly well for simple text-heavy pages. But it breaks in spectacular ways on complex sites.
The core problem is that invert() does not distinguish between elements that should be inverted (backgrounds, text) and elements that should not (images, videos, logos, maps, charts). An inverted photograph looks alien. An inverted map is unusable. An inverted logo with carefully chosen brand colors becomes unrecognizable.
The second problem is that hue rotation is lossy. Colors do not map perfectly through an invert-rotate cycle. Reds become slightly different reds. Blues shift subtly. Brand colors lose their identity. For sites with carefully designed color schemes, the result looks off even if you cannot immediately pinpoint why.
Tensor's Smart Inversion Approach
Tensor's dark mode engine uses what we call Smart Inversion. Rather than applying a blanket filter to the entire page, we analyze the page's DOM, classify each element by type, and apply appropriate transformations selectively.
The classification system categorizes elements into several groups:
- Text elements — paragraphs, headings, spans, list items. These get full inversion.
- Background elements — divs, sections, main containers with background colors. These get full inversion.
- Media elements — images, videos, canvases, SVGs with raster content. These are excluded from inversion and instead receive a subtle brightness reduction.
- Interactive elements — buttons, inputs, selects, links. These get inversion with special handling for focus states and hover effects.
- Decorative elements — borders, shadows, gradients. These are handled individually based on their visual role.
- Embedded content — iframes, embeds, objects. These are excluded from inversion to avoid double-inverting content that may have its own dark mode.
The implementation uses a combination of CSS filters for broad strokes and targeted style overrides for precision adjustments:
/* Base inversion on the HTML element */
html[tensor-dark] {
filter: invert(0.9) hue-rotate(180deg);
background: #111 !important;
}
/* Restore media elements */
html[tensor-dark] img,
html[tensor-dark] video,
html[tensor-dark] canvas,
html[tensor-dark] [style*="background-image"],
html[tensor-dark] svg image {
filter: invert(1) hue-rotate(180deg);
/* Double inversion = original appearance */
}
/* Subtle brightness reduction for images */
html[tensor-dark] img,
html[tensor-dark] video {
filter: invert(1) hue-rotate(180deg) brightness(0.9);
}
/* Handle inline styles and computed backgrounds */
/* (Applied dynamically via JavaScript) */
The double inversion trick for media elements is the foundation of the approach. When the page is inverted, images are inverted along with everything else. By applying invert(1) again to images specifically, we undo the page-level inversion, restoring the image to its original appearance. The additional brightness(0.9) slightly dims images so they do not feel blindingly bright against the dark background.
The Hard Part: Dynamic Content and Inline Styles
CSS filters handle the static case well, but modern websites are dynamic. Elements are created and destroyed constantly. React components re-render. Infinite scroll adds new content. AJAX requests replace page sections. A dark mode engine that only processes the initial page load will miss all of this.
Tensor uses a MutationObserver to watch for DOM changes and apply dark mode transformations to new elements as they appear. The observer watches for added nodes, attribute changes (particularly style and class attributes), and subtree modifications. When a new element enters the DOM, the engine classifies it and applies the appropriate treatment.
Inline styles present a particular challenge. Many websites use inline background-color and color properties that override CSS filters. A div with style="background-color: white" will remain white even when the page is inverted, because the inline style takes precedence. Our engine detects these inline styles, parses the color values, inverts them programmatically, and applies the inverted colors as overrides with !important priority.
// Simplified inline style handler
function processInlineStyles(element) {
const computed = getComputedStyle(element);
const bgColor = computed.backgroundColor;
const textColor = computed.color;
if (isLightColor(bgColor)) {
element.style.setProperty(
'background-color',
invertColor(bgColor),
'important'
);
}
if (isDarkColor(textColor)) {
element.style.setProperty(
'color',
invertColor(textColor),
'important'
);
}
}
Per-Site Overrides
No automated dark mode engine is perfect for every website. Some sites have unique layouts, custom components, or unusual color usage that the engine mishandles. Rather than trying to achieve perfection algorithmically (an impossible goal given the diversity of the web), we built a per-site override system.
Tensor maintains a database of site-specific CSS overrides for popular websites. When you visit Gmail, for example, Tensor loads a Gmail-specific override that handles Google's custom components, icons, and color tokens correctly. These overrides are maintained by our team and contributed by the community.
Users can also create their own overrides for any site. The dark mode settings panel includes a custom CSS editor where you can write site-specific rules that are applied alongside the engine's automatic transformations. If the engine mishandles a specific element on a site you visit frequently, you can fix it with a few lines of CSS and save the override for future visits.
Brightness, Contrast, and Sepia Controls
Dark mode is not one-size-fits-all. Some users prefer a deep black background, while others prefer dark gray. Some want high contrast between text and background, while others prefer softer contrast for extended reading. Some users with visual sensitivities benefit from a slight sepia tint that reduces blue light.
Tensor's dark mode panel exposes three sliders that let you fine-tune the output:
- Brightness (50% to 100%) — Controls the overall brightness of the dark theme. Lower values produce deeper blacks; higher values produce softer dark grays. Default is 90%.
- Contrast (80% to 120%) — Controls the contrast ratio between text and background. Higher values make text pop more sharply; lower values reduce eye strain in low-light environments. Default is 100%.
- Sepia (0% to 50%) — Applies a warm sepia tint that reduces the harshness of pure white text on dark backgrounds. Particularly useful for nighttime reading. Default is 0%.
These controls are implemented as additional CSS filters stacked on top of the inversion:
html[tensor-dark] {
filter:
invert(var(--tensor-inversion, 0.9))
hue-rotate(180deg)
brightness(var(--tensor-brightness, 0.9))
contrast(var(--tensor-contrast, 1.0))
sepia(var(--tensor-sepia, 0));
}
Each parameter is stored per-site, so your preferred brightness for reading Wikipedia can differ from your preferred brightness for GitHub.
Performance Considerations
CSS filters trigger compositing on the GPU, which means they are generally performant. However, applying filters to every element individually would be expensive. Our approach minimizes performance impact by applying the primary filter to the root html element (one filter for the entire page) and only applying individual filters to elements that need special treatment (media, embeds).
The MutationObserver processing is debounced to avoid performance spikes during heavy DOM manipulation. When a page loads a batch of new content (like an infinite scroll page loading 50 new items), the observer batches the mutations and processes them in a single requestAnimationFrame callback, keeping the main thread responsive.
In our benchmarks, Tensor's dark mode engine adds less than 3 milliseconds to page load time on average and has no measurable impact on scroll performance or interaction latency. Memory overhead is approximately 200 KB for the engine code and override database.
Respecting Native Dark Mode
An increasing number of websites implement their own dark mode, either through CSS prefers-color-scheme media queries or through manual theme toggles. Tensor detects both cases and avoids double-darkening sites that already have a dark theme active.
For sites that use prefers-color-scheme, Tensor can optionally set the media query to dark via the Chrome DevTools Protocol, triggering the site's native dark mode without any filter manipulation. This produces the best possible result because the site's own designers chose the dark mode colors.
For sites with manual theme toggles (like GitHub's theme selector), Tensor maintains a list of known toggle selectors and can automatically activate the site's native dark mode. The engine then disables its own filter-based dark mode for that site, deferring entirely to the native implementation.
The Road Ahead
We are exploring several improvements to the dark mode engine. ML-based element classification would use a lightweight neural network to classify elements more accurately than our current heuristic-based approach. Color palette generation would extract a site's color scheme and generate a harmonious dark palette rather than relying on mathematical inversion. And scheduled dark mode would automatically enable and disable dark mode based on time of day or ambient light, integrating with your operating system's night mode settings.
Dark mode seems like a simple feature. But building one that works universally, performs well, and looks good required solving dozens of edge cases across thousands of websites. It is one of those features where the better it works, the less you notice it — and that is exactly the goal.