Delan Azabani

Meet the CSS highlight pseudos

 2842 words 15 min  home igalia

A year and a half ago, I was asked to help upstream a Chromium patch allowing authors to recolor spelling and grammar errors in CSS. At the time, I didn’t realise that this was part of a far more ambitious effort to reimagine spelling errors, grammar errors, text selections, and more as a coherent system that didn’t yet exist as such in any browser. That system is known as the highlight pseudos, and this post will focus on the design of said system and its consequences for authors.

This is the third part of a series (part one, part two) about Igalia’s work towards making the CSS highlight pseudos a reality.

Contents

What are they?

CSS has four highlight pseudos and an open set of author-defined custom highlight pseudos. They have their roots in ::selection, which was a rudimentary and non-standard, but widely supported, way of styling text and images selected by the user.

The built-in highlights are ::selection for user-selected content, ::target-text for linking to text fragments, ::spelling-error for misspelled words, and ::grammar-error for text with grammar errors, while the custom highlights are known as ::highlight(x) where x is the author-defined highlight name.

Can I use them?

::selection has long been supported by all of the major browsers, and ::target-text shipped in Chromium 89. But for most of that time, no browser had yet implemented the more robust highlight pseudo system in the CSS pseudo spec.

::highlight() and the custom highlight API shipped in Chromium 105, thanks to the work by members1 of the Microsoft Edge team. They are also available in Safari 14.1 (including iOS 14.5) as an experimental feature (Highlight API). You can enable that feature in the Develop menu, or for iOS, under Settings > Safari > Advanced.

Chromium 105 also implements the vast majority of the new highlight pseudo system. This includes highlight overlay painting, which was enabled for all highlight pseudos, and highlight inheritance, which was enabled for ::highlight() only.

Chromium 108 includes ::spelling-error and ::grammar-error as an experimental feature, together with the new ‘text-decoration-line’ values ‘spelling-error’ and ‘grammar-error’. Chromium 111 enables highlight inheritance for ::selection and ::target-text as an experimental feature, in addition to ::highlight() and the spelling and grammar pseudos (which always use highlight inheritance). You can enable these features at

chrome://flags/#enable-experimental-web-platform-features

Click the table below to see if your browser supports these features.

yoursChromiumSafariFirefox
Custom highlights
noyes
10514.1*?
• ::highlight()
noyes
10514.1*?
• CSSOM API
noyes
10514.1* (ab)?
::spelling-error
noyes
108*??
Highlight overlay painting
noyes
105??
Highlight inheritance (::selection)
noyes
111*??
Highlight inheritance (::highlight)
noyes
105??

How do I use them?

While you can write rules for highlight pseudos that target all elements, as was commonly done for pre-standard ::selection, selecting specific elements can be more powerful, allowing descendants to cleanly override highlight styles.

the fox jumps over the dog
(the quick fox, mind you)
<style>
    :root::selection {
        color: white;
        background-color: black;
    }
    aside::selection {
        background-color: darkred;
    }
</style>
<body>
    <p>the fox jumps over the dog
    <aside>
        (the <sup>quick</sup> fox, mind you)
    </aside>
</body>

Previously the same code would yield…

the fox jumps over the dog
(the quick fox, mind you)

(in older browsers)

Notice how none of the text is white on black, because there are always other elements (body, p, aside, sup) between the root and the text.

…unless you also selected the descendants of :root and aside:

:root::selection,
:root *::selection
/* (or just ::selection) */ {
    color: white;
    background-color: black;
}
aside::selection,
aside *::selection {
    background-color: green;
}

Note that a bare ::selection rule still means *::selection, and like any universal rule, it can interfere with inheritance when mixed with non-universal highlight rules.

the fox jumps over the dog
(the quick fox, mind you)
<style>
    ::selection {
        color: white;
        background-color: black;
    }
    aside::selection {
        background-color: darkred;
    }
</style>
<body>
    <p>the fox jumps over the dog
    <aside>
        (the <sup>quick</sup> fox, mind you)
    </aside>
</body>

sup::selection would have inherited ‘darkred’ from aside::selection, but the universal ::selection rule matches it directly, so it becomes black.

::selection is primarily controlled by user input, though pages can both read and write the active ranges via the Selection API with getSelection().

::target-text is activated by navigating to a URL ending in a fragment directive, which has its own syntax embedded in the #fragment. For example:

::spelling-error and ::grammar-error are controlled by the user’s spell checker, which is only used where the user can input text, such as with textarea or contenteditable, subject to the spellcheck attribute (which also affects grammar checking). For privacy reasons, pages can’t read the active ranges of these highlights, despite being visible to the user.

::highlight() is controlled via the Highlight API with CSS.highlights. CSS.highlights is a maplike object, which means the interface is the same as a Map of strings (highlight names) to Highlight objects. Highlight objects, in turn, are setlike objects, which you can use like a Set of Range or StaticRange objects.

Hello, world!
<style>
    ::highlight(foo) { background: yellow; }
</style>
<script>
    const foo = new Highlight;
    CSS.highlights.set("foo", foo); // maplike

    const range = new Range;
    range.setStart(document.body.firstChild, 0);
    range.setEnd(document.body.firstChild, 5);
    foo.add(range); // setlike
</script>
<body>Hello, world!</body>

You can use getComputedStyle() to query resolved highlight styles under a particular element. Regardless of which parts (if any) are highlighted, the styles returned are as if the given highlight is active and all other highlights are inactive.

<style>
    ::selection { background: #00FF00; }
    ::highlight(foo) { background: #FF00FF; }
</style>
<script>
    getSelection().removeAllRanges();
    getSelection().selectAllChildren(document.body);

    const style = getComputedStyle(document.body, "::highlight(foo)");
    console.log(style.backgroundColor);
</script>
<body>Hello, world!</body>

This code always prints “rgb(255, 0, 255)”, even though only ::selection is active.

How do they work?

Highlight pseudos are defined as pseudo-elements, but they actually have very little in common with other pseudo-elements like ::before and ::first-line.

Unlike other pseudos, they generate highlight overlays, not boxes, and these overlays are like layers over the original content. Where text is highlighted, a highlight overlay can add backgrounds and text shadows, while the text proper and any other decorations are “lifted” to the very top.

quikc brown fox
quikc brown fox
quikc brown fox
quikc brown fox
quikc brown fox
quikc brown fox

You can think of highlight pseudos as innermost pseudo-elements that always exist at the bottom of any tree of elements and other pseudos, but unlike other pseudos, they don’t inherit their styles from that element tree.

Instead each highlight pseudo forms its own inheritance tree, parallel to the element tree. This means body::selection inherits from html::selection, not from ‘body’ itself.


At this point, you can probably see that the highlight pseudos are quite different from the rest of CSS, but there are also several special cases and rules needed to make them a coherent system.

For the typical appearance of spelling and grammar errors, highlight pseudos need to be able to add their own decorations, and they need to be able to leave the underlying foreground color unchanged. Highlight inheritance happens separately from the element tree, so we need some way to refer to the underlying foreground color.

That escape hatch is to set ‘color’ itself to ‘currentColor’, which is the default if nothing in the highlight tree sets ‘color’.

quick → quikc
quick → quikc
:root::spelling-error {
    /* color: currentColor; */
    text-decoration: red wavy underline;
}

To make highlight inheritance actually useful for ‘text-decoration’ and ‘background-color’, all properties are inherited in highlight styles, even those that are not usually inherited.

quick fox
<style>
    aside::selection {
        background-color: yellow;
    }
</style>
<aside>
    <sup>quick</sup> fox
</aside>

This would conflict with the usual rules3 for decorating boxes, because descendants would get two decorations, one propagated and one inherited. We resolved this by making decorations added by highlights not propagate to any descendants.

quick fox
quick fox
quick fox
quikc fxo
<style>
    .blue {
        text-decoration: blue underline;
    }
    :root::spelling-error {
        text-decoration: red wavy underline;
    }
</style>
<div class="blue">
    <sup>quick</sup> fox
</div>
<div contenteditable spellcheck lang="en">
    <sup>quikc</sup> fxo
</div>

The blue decoration propagates to the sup element from the decorating box, so there should be a single line at the normal baseline. On the other hand, the spelling decoration is inherited by sup::spelling-error, so there should be separate lines for “quikc” and “fxo” at their respective baselines.

Unstyled highlight pseudos generally don’t change the appearance of the original content, so the default ‘color’ and ‘background-color’ in highlights are ‘currentColor’ and ‘transparent’ respectively, the latter being the property’s initial value. But two highlight pseudos, ::selection and ::target-text, have UA default foreground and background colors.

For compatibility with ::selection in older browsers, the UA default ‘color’ and ‘background-color’ (e.g. white on blue) is only used if neither were set by the author. This rule is known as paired cascade, and for consistency it also applies to ::target-text.

default on default plus more text
+
p { color: rebeccapurple; }
::selection { background: yellow; }
=currentColor on yellow plus more text

It’s common for selected text to almost invert the original text colors, turning black on white into white on blue, for example. To guarantee that the original decorations remain as legible as the text when highlighted, which is especially important for decorations with semantic meaning (e.g. line-through), originating decorations are recolored to the highlight ‘color’. This doesn’t apply to decorations added by highlights though, because that would break the typical appearance of spelling and grammar errors.

do not buy bread
do not buy bread
<style>
    del {
        text-decoration: darkred line-through;
    }
    ::selection {
        color: white;
        background: darkblue;
    }
</style>
<div>
    do <del>not</del> buy bread
</div>

This line-through decoration becomes white like the rest of the text when selected, even though it was explicitly set to ‘darkred’ in the original content.

The default style rules for highlight pseudos might look something like this. Notice the new ‘spelling-error’ and ‘grammar-error’ decorations, which authors can use to imitate native spelling and grammar errors.

:root::selection { background-color: Highlight; color: HighlightText; }
:root::target-text { background-color: Mark; color: MarkText; }
:root::spelling-error { text-decoration: spelling-error; }
:root::grammar-error { text-decoration: grammar-error; }

This doesn’t completely describe ::selection and ::target-text, due to paired cascade.


The way the highlight pseudos have been designed naturally leads to some limitations.

Gotchas

Removing decorations and shadows

Older browsers with ::selection tend to treat it purely as a way to change the original content’s styles, including text shadows and other decorations. Some tutorial content has even been written to that effect:

One of the most helpful uses for ::selection is turning off a text-shadow during selection. A text-shadow can clash with the selection’s background color and make the text difficult to read. Set text-shadow: none; to make text clear and easy to read during selection.

Under the spec, highlight pseudos can no longer remove or really change the original content’s decorations and shadows. Setting these properties in highlight pseudos to values other than ‘none’ adds decorations and shadows to the overlays when they are active.

del {
    text-decoration: line-through;
    text-shadow: 2px 2px red;
}
::highlight(undelete) {
    text-decoration: none;
    text-shadow: none;
}

This code means that ::highlight(undelete) adds no decorations or shadows, not that it removes the line-through and red shadow when del is highlighted.

While the new :has() selector might appear to offer a solution to this problem, pseudo-element selectors are not allowed in :has(), at least not yet.

del:has(::highlight(undelete)) {
    text-decoration: none;
    text-shadow: none;
}

This code does not work.

Removing shadows that might clash with highlight backgrounds (as suggested in the tutorial above) will no longer be as necessary anyway, since highlight backgrounds now paint on top of the original text shadows.

Faultlore
Faultlore
Faultlore
Faultlore

Faultlore
Faultlore
Faultlore
Faultlore
Faultlore
Faultlore

If you still want to ensure those shadows don’t clash with highlights in older browsers, you can set ‘text-shadow’ to ‘none’, which is harmless in newer browsers.

::selection { text-shadow: none; }

This rule might be helpful for older browsers, but note that like any universal rule, it can interfere with inheritance of ‘text-shadow’ when combined with more specific rules.

As for line decorations, if you’re really determined, you can work around this limitation by using ‘-webkit-text-fill-color’, a standard property (believe it or not) that controls the foreground fill color of text4.

::highlight(undelete) {
    color: transparent;
    -webkit-text-fill-color: CanvasText;
}

This hack hides any original decorations (in visual media), because those decorations are recolored to the highlight ‘color’, but it might change the text color too.

Fun fact: because of ‘-webkit-text-fill-color’ and its stroke-related siblings, it isn’t always possible for highlight pseudos to avoid changing the foreground colors of text, at least not without out-of-band knowledge of what those colors are.

the quick fox
the quikc fox
p { color: blue; }
em {
    -webkit-text-fill-color: yellow;
    -webkit-text-stroke: 1px green;
}
:root::spelling-error {
    /* default styles */
    color: currentColor;
    -webkit-text-fill-color: currentColor;
    -webkit-text-stroke-color: 0 currentColor;
    text-decoration: spelling-error;
}
em::spelling-error {
    /* styles needed to preserve text colors */
    -webkit-text-fill-color: yellow;
    -webkit-text-stroke: 1px green;
}

When a word in em is misspelled, it will become blue like the rest of p, unless the fill and stroke properties are set in ::spelling-error accordingly.

Accessing global constants

Highlight pseudos also don’t automatically have access to custom properties set in the element tree, which can make things tricky if you have a design system that exposes a color palette via custom properties on :root.

:root {
    --primary: #420420;
    --secondary: #C0FFEE;
    --accent: #663399;
}
::selection {
    background: var(--accent);
    color: var(--secondary);
}

This code does not work.

You can work around this by adding selectors for the necessary highlight pseudos to the rule defining the constants, or if the necessary highlight pseudos are unknown, by rewriting each constant as a custom @property rule.

:root, :root::selection {
    --primary: #420420;
    --secondary: #C0FFEE;
    --accent: #663399;
}
@property --primary {
    initial-value: #420420;
    syntax: "*"; inherits: false;
}
@property --secondary {
    initial-value: #C0FFEE;
    syntax: "*"; inherits: false;
}
@property --accent {
    initial-value: #663399;
    syntax: "*"; inherits: false;
}

Spec issues

While the design of the highlight pseudos has mostly settled, there are still some unresolved issues to watch out for.

What now?

The highlight pseudos are a radical departure from older browsers with ::selection, and have some significant differences with CSS as we know it. Now that we have some experimental support, we want your help to play around with these features and help us make them as useful and ergonomic as possible before they’re set in stone.

Special thanks to Rego, Brian, Eric (Igalia), Florian, fantasai (CSSWG), Emilio (Mozilla), and Dan for their work in shaping the highlight pseudos (and this post). We would also like to thank Bloomberg for sponsoring this work.


  1. Dan, Fernando, Sanket, Luis, Bo, and anyone else I missed. 

  2. See this demo for more details.  2

  3. CSSWG discussion also found that decorating box semantics are undesirable for decorations added by highlights anyway. 

  4. This is actually the case everywhere the WHATWG compat spec applies, at all times. If you think about it, the only reason why setting ‘color’ to ‘red’ makes your text red is because ‘-webkit-text-fill-color’ defaults to ‘currentColor’.