::highlight() with a text-shadow safety net

작성자

카테고리:

← 피드로
DEV Community · Leo · 2026-08-07 개발(SW)

Leo

You have a paragraph and you want to paint a glow across four words in the middle of it. Not a whole sentence, not a semantic phrase — a range. The old move is to wrap those words in a <span> at build time and style the span. That works until the range is dynamic. Search results. A user selection you want to persist. A live comment thread with per-user tint. As soon as the range moves, the span has to move with it, and the DOM is doing work that has nothing to do with meaning.

The CSS Custom Highlight API is the way out. Sunkanmi Fafowora’s walkthrough on Piccalilli pairs it with a text-shadow fallback so the effect degrades cleanly on engines that have not shipped it yet.

Where ::highlight() sits in the family

The web already has a small pile of highlight pseudo-elements. ::selection for the user’s current selection. ::target-text for a URL text fragment. ::search-text for in-page find matches. ::spelling-error and ::grammar-error for the browser’s own annotations. Each one styles a range the browser decides for you.

::highlight() is the one you get to name. The parenthesised argument is an identifier you pick — the article uses hl-gold and hl-ice — and you register that identifier from JavaScript before any pixels change.

The wiring

Three moving parts on the JS side. A Highlight instance, a Range describing the text, and a slot in the CSS.highlights registry that ties the two together.

const goldHL = new Highlight();
CSS.highlights.set("hl-gold", goldHL);

Enter fullscreen mode Exit fullscreen mode

The Range is the standard DOM range you already know. setStart and setEnd on the node and offset you want, then hand the range to the highlight instance:

goldHL.add(rangeObject);

Enter fullscreen mode Exit fullscreen mode

A highlight can hold many ranges. That is the whole point: one named channel, many spans of text, no markup churn.

On the CSS side you style that identifier the same way you’d style any other highlight pseudo-element:

::highlight(hl-gold) {
  color: oklch(88% 0.16 75);
  text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
}

Enter fullscreen mode Exit fullscreen mode

No selector on a wrapper element. The pseudo-element paints over the live text where the range says it should.

The fallback layer

The API is inert without JavaScript, and inert on engines that have not shipped it. The Piccalilli article’s answer is to keep a real <mark> element in the HTML for the fallback path, styled with the same colour and text-shadow you want the API to produce.

mark.gold {
  color: oklch(88% 0.16 75);
  text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
}

Enter fullscreen mode Exit fullscreen mode

That is the baseline. Everyone gets a marked phrase. It is real HTML with real semantics. Text-shadow is doing the visual lift — the same declaration the pseudo-element will reuse — so the effect reads consistently whether it came from a <mark> or from a JavaScript-driven range.

Gating the upgrade with @supports

The upgrade is a single @supports selector(...) check. If the browser understands ::highlight() as a selector, opt in.

@supports selector(::highlight(h1-gold)) {
  ::highlight(hl-gold) {
    color: oklch(88% 0.16 75);
    text-shadow: 0 0 40px oklch(65% 0.22 75 / 0.4);
  }
}

Enter fullscreen mode Exit fullscreen mode

selector() is the interesting bit here. @supports normally tests a property-value pair, but the selector form asks whether the engine parses a specific selector at all. A pseudo-element the parser does not recognise is the honest signal to fall back — no user-agent sniff, no version table.

What this unlocks

Anything that needed a dynamic wrapper element around live text. Highlighting a search term across a rendered article. Tinting each participant’s contributions in a transcript. Colouring the diff on a text field the user is editing. All of it becomes: keep one JS registry, hand it ranges, style the name.

What is still missing is the tail. Not every engine ships the API yet, and the article’s whole framing is that you should not wait — ship the <mark> today, gate the ::highlight() rule on @supports selector(::highlight(...)), and the upgrade lights up as engines catch up.

What I would try next: take one place in your app where you are already wrapping matched text in a <span class="hit">, keep the semantic wrapper for the fallback, and add a highlight registration on top. The wrapper stops being the styling hook and starts being the safety net.

원문에서 계속 ↗

코멘트

답글 남기기

이메일 주소는 공개되지 않습니다. 필수 필드는 *로 표시됩니다