My name is Vladimir, and I develop mobile front-end for Yandex Mail. Our apps have had a dark theme for a while, but it was incomplete: only the interface and plain emails were dark. Messages with custom formatting remained light and stood out against the dark interface, hurting our users’ eyes at night.

Today I'll tell you how we fixed this problem. You will learn about two simple techniques that didn't work for us and the method that finally did the trick — adaptive page recoloring. I'll also share some ideas about adapting images to a dark theme. To be fair, darkening pages with custom CSS is a rather peculiar task, but I believe some of you may find our experience helpful.

Simple methods

Before settling on our magic color-bending technique, we tried out two really basic options: applying either additional dark styling or a CSS filter to elements. Neither option worked for us, but they might be more suitable in other cases (simple is cool, right?).

Overriding styles

This is a dead-simple option, which logically extends the app's own dark CSS theme. You just put extra dark styles onto an email container (or, in general, onto a container for the user-generated content to darken):

.message--dark { background-color: black; color: white; }

However, any styles on the elements inside the email will override our root style. And no, !important doesn't help here. The idea can be taken a step further by preventing inheritance:

.message--dark * { background-color: black !important; color: white !important; border-color: #333 !important; }

In this case, you can't do without !important , since the selector itself isn't very specific. This also happens to override most inline styles (the ones with inline !important will still find their way in, and there's not much you can do about it).

Our style eagerly paints everything the same color, thus introducing another problem. There's a chance the designer wanted to say something with the way they arranged the colors (you know, priorities, pairings, all that designer stuff), and we just took their idea and threw it out the window. Not a nice thing to do.

If you don't respect designers as much as I do and decide to go with this method, don't forget to handle the not-so-obvious things:

box-shadow . You won't, however, be able to only override the color. Either get rid of the shadows altogether or make peace with the light ones.

. You won't, however, be able to only override the color. Either get rid of the shadows altogether or make peace with the light ones. The colors of semantic elements, such as links or inputs.

Inline SVG. Use fill instead of background , and stroke instead of color , but you can never be sure, it may well be the other way around.

From a technical point of view, this is a solid method: it takes three lines of code (OK, thirty for a production-ready version with all the edge cases taken care of), it's compatible with all the browsers in the world, it works on dynamic pages out-of-the-box, and the way CSS is attached to the document is completely irrelevant. There's also a nice bonus: you can easily tweak the colors in the style to match the main colors of the application (for example, make the background #bbbbb8 instead of black).

By the way, this is how we used to darken emails before. If an email had any styles of its own, we’d just give up and leave it light just in case.

Using a CSS filter

This is a smart and elegant method. You can darken a page using a CSS filter:

.message--dark { filter: invert(100) hue-rotate(180deg); /* hue-rotate возвращает тона обратно */ }

The images become creepy, but we can easily fix that:

.message-dark img { filter: invert(100) hue-rotate(180deg); }

However, this doesn't solve the issue of content images attached via the background property (sure, it’s handy for adjusting the aspect ratio, but what about the semantics?). OK, let's assume we can find all these elements, explicitly mark them, and change their colors back, too.

The good thing about this method is that it preserves the original brightness and contrast balance. On the other hand, it's full of problems that outweigh the advantages:

Dark pages become bright. You can't control the final colors. What filter do you apply to the background so that it matches your brand #bbbbb8 ? That's a real head scratcher. Double inversion makes the images faded. The performance is sluggish, especially on mobile devices — makes sense, instead of simply rendering the page, the browser runs Fourier transforms on every draw.

This method would work for emails with neutral-colored text, but I've never met anyone with an inbox full of such content. On the other hand, filters are the only way to darken elements without accessing their contents — frames, web components, or images.

Adaptive dark theme

And now it's time for magic! Based on the drawbacks of the first two approaches, we made a checklist of what we should take care of:

Make the background dark, the text light, and the borders somewhere in between. Detect dark pages and preserve their colors. Maintain the original balance of brightness and contrast. Give control over the resulting colors. Leave the hues unchanged.

So, you need to change the colors of the styles to make the background dark. Why not just do that, literally? Take all the styles, extract the color-related rules ( color , background , border , box-shadow , and their subproperties), and replace them with their "darkened" versions: darken the background and the borders (but not as much as the background), lighten the text, etc.

This method has an incredible advantage that will warm any developer's heart. You can configure color conversion rules for each property — yes, using code! With a bit of imagination, you can fit the colors to any external theme, perform any color correction you wish (for example, do a light theme instead of a dark one, or a theme of any color you like), and even add a little bit of contextuality — for example, treat wide and narrow borders differently.

The disadvantages are what you'd expect from an "everything-in-js" approach. We run scripts, mess up style encapsulation, and parse CSS with regex. The latter, however, isn't as humiliating as with HTML, because CSS grammar is regular (at least at the level we're working with).

Here's our dark plan:

Normalize the legacy style properties ( bgcolor and others) and move them into style="..." . Find all the inline styles. Find all the color-related rules in each style ( background-color , color , box-shadow , and others). Take the colors from the color-related properties and match them with the right converter (darken the background, lighten the text). Call the converter. Put the converted properties back into the CSS.

The wrapper code — normalization, locating styles, parsing — is quite trivial. Let's see how exactly our magic converter works.

HSL conversions

Darkening a color isn't as simple as it sounds, especially if you want to preserve the hue (for example, turn blue into dark blue, not orange). You can do this in ordinary RGB, but it's problematic. If you're into algorithmic design, you know that in RGB, even gradients are broken. Meanwhile, working with colors in HSL is a real treat: instead of Red, Green and Blue, which you have no idea what to do with, you have the following three channels:

Hue — the thing we're looking to preserve.

Saturation, which is not very important to us right now.

Lightness, which we will be changing.

You can think of it as a cylinder. We need to turn this cylinder upside down. Color correction does something like this: (h, s, l) => [h, s, 1-l] .

Colors that are already OK

Sometimes we get lucky and the custom design of the email (or a part of it) is already dark. This means we don't need to change anything — the designer probably did a better job at picking the colors than our algorithm could ever dream of. In HSL, you only need to consider the L — Lightness. If its value is higher or lower (for the text and the background, respectively) than the threshold (which you can set), just leave it alone.

Dynamic circus

Even though we did not need any of this (many thanks, sanitizer, you saved my day here), I'll list the extra features an adaptive theme needs to darken entire pages and not just goofy static emails from the '90s. Fair warning: this task is not for everyone.

Dynamic inline styles

Changing inline styles is the simplest case that messes up our dark pages. It is common, but there's a simple fix: add MutationObserver and fix inline styles as soon as the changes occur.

External styles

Working with styles referenced by a <link> element from inside the page can be agonizing because of the asynchronicity and the @import statements. And CORS doesn't make things better. It looks like this problem can be solved quite elegantly with a web worker (proxying all *.css ).

Dynamic styles

To make things worse, scripts can add, remove, or rearrange <style> and <link> elements as they wish, and even change the rules in <style> . Here you'll also need to use MutationObserver , but every change will incur more processing.

CSS variables

Things get really wild when CSS variables come into play. You can't darken the variables: even if you guess that a variable contains a color based on its format (although I would advise against this), you still don't know what its role is — background, text, border, or all at once? Moreover, the values of CSS variables are inherited, so you need to track not only the styles, but the elements they are applied to, and this quickly escalates out of control.

Once the CSS variables make it to the mainstream, we’re in trouble. On the other hand, color() will be supported by then, allowing us to replace all our JS conversions with a color(var(--bg) lightness(-50%)) .

Summary

In our case, when the sanitizer leaves only inline styles, adaptive darkening at the CSS level works like a charm: it gives higher-quality darkening, doesn't disrupt the original structure, and is relatively simple and quick. Extending it to tackle dynamic pages is probably not worth the trouble, though. Fortunately, if you're working with user-generated content and you're not developing a browser, your sanitizer likely works the same way.

In practice, adaptive mode should be paired with style overrides, since standard elements like <input> or <a> often use the default light styles.

How to darken images

Image darkening is a separate issue that bothers me personally. It's challenging, and gives me an excuse to finally use the phrase "spectral analysis". There are several typical problems with images in a dark theme.

Some images are just too bright. These give us the same trouble as the bright emails we started our quest with. This issue often arises in ordinary photos, but it’s not exclusive to these. Since writing newsletter CSS isn't much fun, some guys like to skip corners by inserting complex layout as an image, which prevents darkening. When I see that, my inner perfectionist groans in frustration. These images should be dimmed, but not inverted, unless you want to scare your users.

Then there are dark images with real transparency. This is a typical problem with logos — they are designed with a light background in mind, and when we replace it with a dark background, the logo disappears into it. These images need to be inverted.

Somewhere in between are images that use a white background that's supposed to represent transparency. In the dark theme, they simply end up in a weird white triangle. In a perfect world, we could change the white background to a transparent one, but if you've ever used a magic wand tool, you know how hard it is to do that automatically.

Interestingly enough, some images carry no meaning whatsoever. These are usually tracking pixels or "format holders" in particularly bizzare layouts. You can safely make them invisible (say, with opacity: 0 ).

Analyzing what's inside

To figure out how to adjust an image for dark theme, we need to look inside and analyze its contents, preferably with a quick and simple method. Here's the first version of an algorithm that solves this.

First count the image's dark, light, and transparent pixels. As an obvious optimization, just a small subset of pixels can be considered. Next, determine the overall brightness of the image (light, dark, or medium) and whether there's any transparency. Invert dark images with transparency, dim opaque bright images, and leave the rest unchanged.

I was really happy about this discovery until I came across a charity newsletter featuring a photo of a lesson at an African school. The designer centered it by adding transparent pixels on the sides. We didn't want to get involved in a story about offensive image recognition, so we decided to leave image manipulation out of our first iteration.

In the future, we can prevent such problems by employing that heuristic I call "spectral analysis". That is, counting the number of different hues in the image and only inverting if there are aren't too many of them. You can use the same method to recognize bright sketchy images and invert them, too.

Conclusion

To design a full-fledged dark theme, we had to find a way to darken emails that contain custom formatting — and we did. First, we tried two simple, pure CSS solutions — overriding styles and using a CSS filter. Neither of them hit the mark: the first one was too hard on the original design, and the other simply didn't work well. In the end, we decided to use adaptive darkening. We take the styles apart, replace the colors, and put them back together. We're currently working on applying the dark theme to images. To do that, we need to analyze their contents and then only adjust some of them.

If you ever need to change the color of custom HTML snipets to fit it into a dark theme, keep three methods in mind:

Overriding styles. It's a no fuss, no muss method, plus you'll need it for your application anyway. The bad news is that it destroys all the original colors.

CSS filter. It's fun but leaves much to be desired. Reserve it for elements that aren't easy to access, like frames or web components.

Style rewriting. This method does a great job at darkening, but it's more complex.

Even if you never try any of this out, I hope you had a great time reading this article!