::nth-letter() and ::nth-word() pseudo-elements
AbstractRange with an inline style property
color animations
Many Web experiences apply visual effects to text. AI chat interfaces and online presentation tools have made text entry and exit effects especially prominent, while read-along experiences and editing tools use changing highlights to convey playback or analysis state.
Existing Web primitives can be inefficient for text-level animation effects because they require backing elements. For a per-word animation with a lot of text, hundreds of extra elements might be created for no reason other than to support such an effect.
This explainer proposes three extensions to highlight pseudo-element styling
and the CSS Custom Highlight API, which together allow authors to apply independently timed,
compositor-driven animations to arbitrary text ranges without
introducing additional elements solely to achieve the effect.
The per-range styling capability lets authors express non-semantic variations,
such as staggered animation delays, among ranges within one Highlight.
Animations, transitions, and opacity can apply to highlight pseudo-elements,
not just custom highlights.
The most common approach for building text-based effects is to segment the text using
a mechanism such as Intl.Segmenter or String.prototype.split(), wrap each
resulting character, word, or other unit in an element such as a <span>, and
apply the effect to each wrapper. This technique introduces several problems:
Libraries such as GSAP SplitText and Splitting.js package this pattern and handle many of its authoring details. Because the resulting representation still uses an element for each text unit, it retains similar platform-level costs and tradeoffs.
Libraries such as Pretext provide an alternative for measuring text and laying it out on a canvas. Canvas-based rendering avoids some of the drawbacks of DOM-based solutions but introduces its own drawbacks: the author becomes responsible for supporting text interactions such as selection and providing an accessible representation.
The Custom Highlight API already lets authors identify arbitrary text ranges without changing the document structure. However, highlight styling only supports a limited set of properties and does not allow for animation effects or per-range variants like an inline style on an element. Consequently, the API cannot currently replace element wrappers for these effects.
Highlight, without
requiring a separate highlight name and CSS rule for every variation.We propose three enhancements to highlight styling and the Custom Highlight API:
range-index() function.Animations and transitions apply to highlight pseudo-elements except
::search-text, ::spelling-error, and ::grammar-error; see the
Accessibility, Internationalization, Privacy, and Security Considerations
section for the reason behind this limitation. Opacity applies to all
highlight pseudo-elements. Per-range declarations and/or range-index()
apply only to custom highlights.
We propose allowing the CSS animation and transition properties to apply to supported highlight pseudo-elements. The examples below use custom highlights:
@keyframes emphasize {
from { background-color: transparent; }
to { background-color: gold; }
}
::highlight(new-annotation) {
animation: emphasize 300ms ease-out;
transition: background-color 150ms;
}
Animations and transitions can affect properties that are valid for highlight
pseudo-elements, including the proposed opacity support. Properties that
do not apply to highlights are ignored in keyframes and transition lists.
Animation and transition properties declared on an originating
element apply only to that element; they do not configure effects on its
highlight pseudo-elements. Highlight effects are instead configured through
the highlight cascade, including matching highlight pseudo-element rules,
per-range declarations for custom highlights, and inheritance from corresponding
ancestor highlight pseudo-elements. Custom properties inherited from the
originating element can affect the resolved effect properties.
Each originating element draws its portion of a highlighted range using the corresponding highlight pseudo-element style, and that portion acts as one animation or transition target. This follows the existing highlight styling model. A range which crosses several elements can therefore produce effects with different property values, timing parameters, or both.
For custom highlights, the target is identified by the
(Highlight registry entry, range, originating element) tuple.
Line boxes and paint fragments do not create additional animation targets. If one originating element draws a range in several fragments, those fragments share one animation timeline.
The registry entry is part of the identity because the same Highlight can be
registered under more than one name:
CSS.highlights.set("search-result", results);
CSS.highlights.set("active-result", results);
The two names can match different ::highlight() rules, so they produce
independent animation and transition effects. Removing and re-adding a range
creates a new membership and therefore new animation targets for its
originating elements.
Moving a range boundary can create new effect targets if an element boundary is crossed, but it does not initiate new effects. This is consistent with the behavior that occurs when adding a new text node to an element.
An originating element is eligible for a highlight animation when all of the following are true:
Changing a live Range's boundary points does not restart animations.
Instead, content newly added to the Range synchronizes state with any
animations that were already present. For example:
<p>This is a <b>paragraph</b> with <b>bold</b> words.</p>
<style>
@keyframes pulse {
from { background: transparent; }
to { background: yellow; }
}
::highlight(pulse) {
animation: pulse 1s infinite alternate;
}
</style>
<script>
// Start the pulse animation on the first word of the paragraph.
const paragraph = document.querySelector("p");
const firstWord = paragraph.firstChild.textContent.split(" ")[0];
const range = new Range();
range.setStart(paragraph.firstChild, 0);
range.setEnd(paragraph.firstChild, firstWord.length);
CSS.highlights.set("pulse", new Highlight(range));
// Later: Extend the pulse animation to the entire paragraph.
range.selectNodeContents(paragraph);
// The entire paragraph, including the <b> descendants,
// would now run the pulse animation on the same timeline.
</script>
Changes to animation declarations follow the existing CSS Animations update rules. Line boxes and internal paint fragments are not separately exposed to authors.
Transitions run when the computed style for one of these per-originating-element targets changes, whether the change comes from a highlight pseudo-element rule, a per-range declaration for a custom highlight, highlight inheritance, or an originating element's custom properties.
As with animations and static highlight styling, different originating elements can resolve different property values and timing parameters for the same range. Their transitions run independently. Changing the boundary points of a range does not interrupt transitions for originating elements which remain covered, nor does it start transitions for content that is newly covered or no-longer-covered.
Custom highlight membership changes also participate in transitions. The
not-highlighted transition endpoint uses the originating foreground values, a
transparent background, no highlight-introduced shadow or decoration, and the
initial value of other applicable properties. This makes adding and removing a
range behave similarly to gaining or losing :hover.
When a range is added to a registered Highlight, its before-change style is
the not-highlighted transition endpoint and its after-change style is the
newly computed highlight style. @starting-style can override the starting
values when an effect needs a different entry treatment.
Removing a range from a Highlight deletes it immediately from the set but
triggers an exit transition to the not-highlighted state.
Highlight.clear() starts exit transitions for each member, just as deleting
members individually would.
If a range is re-added while its exit transition is running, the existing CSS transition reversal rules apply. A range which becomes invalid or no longer belongs to the applicable document cancels its animations and transitions without retaining stale paint.
Deleting a registry entry with CSS.highlights.delete() or replacing
its value with a different Highlight using CSS.highlights.set() cancels all
effects associated with the old registry entry, including any in-progress
transitions.
Highlight becomes an EventTarget. For custom highlights, animation and
transition events are dispatched at the Highlight, with the affected
AbstractRange exposed on the event. Because the same range can produce an effect for more than one
originating element, the event also exposes the originating element.
event.pseudoElement identifies the applicable registry name, such as
::highlight(search-result).
Conceptually, the events extend the existing event interfaces as follows:
interface HighlightAnimationEvent : AnimationEvent {
readonly attribute AbstractRange range;
readonly attribute Element originatingElement;
};
interface HighlightTransitionEvent : TransitionEvent {
readonly attribute AbstractRange range;
readonly attribute Element originatingElement;
};
Events are generated independently for each (Highlight registry entry, range, originating element) target, following the existing CSS Animations and CSS
Transitions event rules. The same range can therefore appear in multiple
events, including events with different elapsed times. Line boxes and paint
fragments do not generate additional events. An author can use the range and
originating element to identify which effect completed without estimating
completion using a timer.
Custom highlight effects participate in the Web Animations API. They are
returned by document.getAnimations() and by getAnimations() on their originating
element, following the same rules as effects on other pseudo-elements.
The returned objects are CSSAnimation or CSSTransition objects, as
appropriate.
KeyframeEffect.target is the originating element and
KeyframeEffect.pseudoElement identifies the registry entry, such as
::highlight(search-result).
Because multiple ranges can share both values, KeyframeEffect additionally
exposes the affected range for highlight effects:
partial interface KeyframeEffect {
readonly attribute AbstractRange? highlightRange;
};
The value is null for effects which do not target a custom highlight range.
Together, target, pseudoElement, and highlightRange identify the
(Highlight registry entry, range, originating element) target.
Highlight also exposes the effects created for its memberships:
partial interface Highlight {
sequence<Animation> getAnimations(
optional HighlightGetAnimationsOptions options = {});
};
dictionary HighlightGetAnimationsOptions {
AbstractRange? range = null;
Element? originatingElement = null;
CSSOMString? pseudoElement = null;
};
Omitted or null members do not filter the results. The method continues to
return retained exit transitions after their range has been deleted from the
Highlight, until those transitions finish or are canceled. This gives
authors a way to inspect and control each tuple using the normal Animation
methods. In particular, calling cancel() on a retained exit transition
immediately discards its retained target, style, and paint state. Calling
cancel() also follows the existing Web Animations and CSS event rules,
including dispatching the corresponding cancellation event.
A staggered text fade requires each successive word to start its animation slightly later than the preceding word. This is technically achievable with custom highlights today, but in ways that are poorly suited to the scenario. Existing techniques are discussed in the "Alternatives Considered" section.
Conceptually, a given highlight used to implement a text entry animation represents a single semantic; the individual stagger offsets are implementation details of that semantic. Thus, it makes sense to introduce the staggers as variations applied to each range included in the highlight. We propose two different options for doing so. These options are not mutually exclusive; either or both could be implemented depending on adjacent use cases.
Staggering animation effects that work on a per-element basis can take advantage of each element's inline style to achieve this effect. As one option for non-semantic variations within a highlight, we propose a capability that aims for flexibility similar to element inline styles.
The proposed API is:
partial interface Highlight {
CSSStyleDeclaration? getRangeStyle(AbstractRange range);
};
getRangeStyle(range) returns a mutable declaration block for the membership
of range in that Highlight. It returns null if the exact range object is
not currently a member. The style belongs to the (Highlight, range)
relationship, rather than to the range alone:
const range = new StaticRange(/* ... */);
const searchResults = new Highlight(range);
const diagnostics = new Highlight(range);
searchResults.getRangeStyle(range).backgroundColor = "yellow";
diagnostics.getRangeStyle(range).textDecorationColor = "red";
The same range can therefore participate in different highlights without sharing presentation state.
Repeated calls for a current membership return the same
CSSStyleDeclaration. Changing the boundary points of a live Range preserves
the declaration. Deleting the range or clearing the Highlight detaches it:
the declaration remains inspectable, but further changes have no effect.
Re-adding the range creates a fresh declaration. This lifecycle follows the
identity of entries in Highlight's existing setlike<AbstractRange>.
The declaration accepts custom properties and properties that are valid for highlight pseudo-elements, including animation, transition, and opacity properties added by this proposal.
For example, a per-range style can supply data for minor variants while leaving the general nature of an effect in a shared rule:
highlight.getRangeStyle(range).setProperty("--confidence", confidence);
::highlight(analysis) {
color: color-mix(in srgb, gray, green calc(var(--confidence) * 100%));
}
Per-range declarations participate in the author cascade after the shared
::highlight() rule, analogous to inline declarations. Invalid properties
are ignored in the same way as they are in a ::highlight() rule. Normal
importance rules still apply, so a !important declaration in a stylesheet can
override a non-!important per-range declaration. The resulting style is
resolved separately for each originating element crossed by the range, since
inheritance, custom properties, and currentColor can differ across those
elements. This includes animation and transition parameters.
If the same Highlight is registered under multiple names, its per-range
declarations are shared, while each name contributes its own
::highlight() rules. Authors who need independent per-name range data can
use separate Highlight objects.
Overlapping ranges with different per-range styles behave like overlapping
ranges belonging to different named custom highlights. Each range's
declaration is first combined with the shared ::highlight() rule to produce
an effective style for that range. Those effective styles are then painted as
separate highlight layers; they are not cascaded or merged into one style for
the overlap.
For example:
const first = new StaticRange(/* ... */);
const second = new StaticRange(/* ... */);
const highlight = new Highlight(first, second);
highlight.getRangeStyle(first).backgroundColor = "yellow";
highlight.getRangeStyle(second).color = "blue";
Where the ranges overlap, the yellow background contributed by first remains
visible, while second, as the higher layer, supplies the winning blue
foreground. This is the same result the author would get by placing first
and second in separately named custom highlights with the corresponding
styles.
The existing custom-highlight stacking order continues to order different
registry entries. Within one Highlight, range set insertion order supplies
the additional ordering: later ranges paint above earlier ranges. Deleting and
re-adding a range moves it to the end of the set and therefore makes it the
highest range layer in that Highlight.
Ranges without distinct per-range styles retain the existing behavior for
members of one Highlight, including treating overlapping ranges as their
union. The additional layers are needed only where per-range styling makes an
observable difference.
range-index() functionAuthors can achieve styling variances among a set of sibling elements using the
sibling-index() and sibling-count() functions. As another option for
non-semantic variations within a highlight, we propose a range-index() function
which works similarly for ranges within a custom highlight.
A Highlight object is defined to be setlike<AbstractRange>, which means the
Ranges within a highlight are kept as an ordered set. When used in a property
applied to a highlight, range-index() resolves to the one-based index of the Range
within that ordered set. The value is the same for every originating element covered by the Range.
In other contexts, range-index() resolves to the guaranteed-invalid value.
A new range added to the highlight gets the next available index. When a range is
deleted, subsequent ranges have their indices reduced by 1, and any styles
dependent on range-index() are recalculated. Such changes follow typical CSS
update rules and may trigger new transitions or update animations accordingly.
Styles that differ because of range-index() use the same overlapping-range
layering rules described under Option 1.
With this function, an author could declare a staggered fade highlight as follows:
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
::highlight(response-words) {
--stagger-interval: 6ms;
animation: fade-in 600ms ease-in-out both;
animation-delay: calc(var(--stagger-interval) * (range-index() - 1));
}
To take advantage of compositor support in modern engines, staggering animation
effects that work on a per-element basis use the opacity property, rather than
animating color from transparent to the desired value. Using opacity for
these animations also lets authors share keyframes with non-text elements, such
as images, in a single staggered-entry effect that encompasses both.
Trading away compositor support would curtail or even outweigh the performance
benefits of avoiding DOM manipulation in a highlight-based animation. We therefore
propose allowing opacity to apply to all highlight pseudo-elements, including
built-in highlights such as ::selection as well as ::highlight().
Unlike opacity on nested elements, opacity values from overlapping highlights do not compound. Instead, one value wins as described below.
Each highlight resolves opacity from its corresponding highlight pseudo-element
style. For custom highlights, each
(Highlight registry entry, range, originating element) target also incorporates
any per-range style. The resulting value applies to the portion of the
highlight painted by that originating element.
When highlight layers overlap within an originating element, their opacity values do not multiply. Instead, the highest active layer that contributes an opacity value determines the winning opacity. If no active highlight contributes opacity, the resulting highlight opacity value is 1.
For color, a single winning text color is determined by finding the topmost
highlight that has a value for color other than currentColor. For opacity
we have no such sentinel value. To fill that gap, we introduce auto as a
valid value for opacity and add the following to the UA stylesheet:
/* See https://drafts.csswg.org/css-pseudo-4/#highlight-cascade for why :root */
:root::selection,
:root::search-text,
:root::target-text,
:root::spelling-error,
:root::grammar-error,
:root::highlight(*) {
opacity: auto;
}
In all contexts other than a highlight pseudo-element, the value of auto
computes to 1. In a highlight pseudo-element, auto indicates that the highlight
does not contribute an opacity value.
In the context of a highlight, a transition or animation between auto and a
numeric value is handled as a discrete flip.
For example, where two highlights overlap:
| Lower highlight | Upper highlight | Resulting opacity |
|---|---|---|
opacity: .4 |
opacity: .7 |
.7 |
opacity: .4 |
Opacity omitted (auto)* |
.4 |
opacity: .4 |
opacity: 1 |
1 |
opacity: .4 |
opacity: 0 |
0 |
* assuming no numeric value is inherited from an ancestor highlight
The winning opacity applies once to the combined ink for the affected region: foreground text or replaced content, highlight backgrounds and shadows, and originating and highlight decorations. It does not independently fade each highlight's contributions.
For example, an opaque blue glyph over an opaque yellow highlight background at winning opacity .5 is painted as blue over yellow and then faded as a unit. The yellow background does not show through the blue glyph as it would if the foreground and background were faded separately.
The existing foreground painting behavior remains unchanged: the topmost active highlight overlay redraws the text, while lower highlight foregrounds and the originating text are not separately painted. Consequently, a winning opacity of 0 makes the combined ink transparent; it does not reveal another highlight's foreground or the originating text beneath it.
Highlight opacity does not replace or override opacity on the originating element(s). Opacity effects on originating element(s) continue to apply as usual. The highlighted content resolves its own opacity and is rendered as a unit, but remains subject to opacity on the originating element(s) and ancestors.
For example:
span {
opacity: .25;
}
span::highlight(example) {
opacity: .75;
}
The entire span is faded to opacity .25, and on top of that the highlighted range is faded to .75, for a net effective opacity of .25 x .75 = .1875.
The following example uses Intl.Segmenter to create one range per word,
assigns each range a different animation delay using the per-range style option,
and animates the highlight's opacity.
<style>
@keyframes fade-in {
from { opacity: 0; }
to { opacity: 1; }
}
::highlight(response-words) {
animation: fade-in 600ms ease-in-out both;
}
@media (prefers-reduced-motion: reduce) {
::highlight(response-words) {
animation: none;
opacity: 1;
}
}
</style>
<p id="response">Custom highlights can animate text without extra spans.</p>
<script>
const node = document.querySelector("#response").firstChild;
const segmenter = new Intl.Segmenter(undefined, {granularity: "word"});
// Trailing punctuation joins the preceding word, so it fades in with it.
function splitWords(text) {
const words = [];
for (const {index, segment, isWordLike} of segmenter.segment(text)) {
const last = words.at(-1);
if (isWordLike) {
words.push({index, segment});
} else if (last && segment.trim() &&
index == last.index + last.segment.length) {
last.segment += segment;
}
}
return words;
}
const words = splitWords(node.data);
const ranges = words.map(({index, segment}) => new StaticRange({
startContainer: node,
startOffset: index,
endContainer: node,
endOffset: index + segment.length,
}));
const highlight = new Highlight(...ranges);
const staggerInterval = 6 /* milliseconds */;
for (const [index, range] of ranges.entries()) {
highlight.getRangeStyle(range).animationDelay = `${index * staggerInterval}ms`;
}
// Register only after setting the delays, so all animations start together.
CSS.highlights.set("response-words", highlight);
</script>
The shared ::highlight() rule defines the animation, the per-range
declarations stagger its timing, and using opacity makes the animation
eligible for compositor acceleration.
To append more text dynamically, the application can add ranges to the
already registered Highlight. This continuation of the example receives
complete-word batches, each with its own stagger starting at zero:
function appendResponseText(text) {
const newNode = document.createTextNode(text);
node.parentNode.append(newNode);
const newWords = splitWords(text);
for (const [index, {index: offset, segment}] of newWords.entries()) {
const range = new StaticRange({
startContainer: newNode,
startOffset: offset,
endContainer: newNode,
endOffset: offset + segment.length,
});
highlight.add(range);
highlight.getRangeStyle(range).animationDelay = `${index * staggerInterval}ms`;
}
}
// Later, when another batch of text arrives:
appendResponseText(" Newly appended words fade in as they arrive.");
Each batch is appended as a new text node, leaving the existing StaticRange
boundaries unchanged. The new ranges and their per-range delays are set
synchronously, before the next style update, so their animations start with
the intended stagger. Adding these ranges does not restart animations for
existing ranges, even though all the words have the same originating <p>.
Previously revealed words remain visible through animation-fill-mode: both,
while each new range gets its own animation timeline. The same
prefers-reduced-motion rule also applies to the newly added ranges.
range-index()The same solution could be implemented using range-index() by adding
animation-delay: calc(6ms * (range-index() - 1)) to the ::highlight(response-words)
rule and removing the calls to getRangeStyle().
However, in appendResponseText(), new ranges would continue the stagger
according to their position in the existing highlight rather than restarting
at zero. Restarting each batch at zero would require waiting for the previous
batch to finish. The function could then either remove existing ranges from the
highlight, or register a new highlight containing the new ranges under the same name.
<style>
@keyframes shimmer {
0%,
100% {
color: #999;
}
50% {
color: #333;
}
}
::highlight(loading-shimmer) {
color: #999;
animation: shimmer 1.5s ease-in-out infinite both;
}
@media (prefers-reduced-motion: reduce) {
::highlight(loading-shimmer) {
animation: none;
color: #333;
}
}
</style>
<p id="loading-status">Loading...</p>
<script>
const node = document.querySelector("#loading-status").firstChild;
const segmenter = new Intl.Segmenter(undefined, {granularity: "grapheme"});
const ranges = Array.from(
segmenter.segment(node.data),
({index, segment}) => new StaticRange({
startContainer: node,
startOffset: index,
endContainer: node,
endOffset: index + segment.length,
}),
);
const highlight = new Highlight(...ranges);
const shimmerInterval = 100 /* milliseconds */;
for (const [index, range] of ranges.entries()) {
highlight.getRangeStyle(range).animationDelay =
`${index * shimmerInterval}ms`;
}
CSS.highlights.set("loading-shimmer", highlight);
</script>
The shared ::highlight() rule defines the looping color animation, while the
per-range declarations offset each grapheme cluster by 100 milliseconds.
Intl.Segmenter keeps user-perceived characters such as combining sequences
together without introducing wrapper elements. Registering the Highlight
after assigning the delays starts all of the animations from the same style
update while preserving their stagger.
Given a set of word ranges, an application can keep
the current word in one Highlight:
::highlight(spoken-word) {
background-color: gold;
transition: background-color 120ms ease-out;
}
const spokenWord = new Highlight();
CSS.highlights.set("spoken-word", spokenWord);
function setSpokenWord(range) {
spokenWord.clear();
spokenWord.add(range);
}
The speech or media layer calls setSpokenWord() as playback advances. When
the range changes, the old word transitions to the not-highlighted state while
the new word transitions into the highlighted state. No timer is needed to
drive intermediate colors, and neither word needs a wrapper element.
The same pattern can use two Highlight objects to track the current word and
current sentence independently.
::nth-letter() and ::nth-word() pseudo-elementsA previous investigation contemplated introducing ::nth-letter()
and ::nth-word() pseudo-element selectors. These selectors have a
long history
of being requested additions to CSS but come with their own
complicating factors.
::nth-word() rules. Words can cross element
boundaries, so these pseudo-elements cannot be made tree-abiding, which rules
out the use of CSS counters.Another previous proposal contemplated four new CSS properties:
transition-text-interval: <time [0s,∞]>#
transition-text-unit: [ none | character | word | line ]#
animation-text-interval: <time [0s,∞]>#
animation-text-unit: [ none | character | word | line ]#
::nth-letter() and ::nth-word() about baking
in specific text-splitting algorithms apply to this approach as well.Instead of assigning per-range delays, authors could run a JavaScript loop. Once every n
milliseconds, where n is the desired stagger interval, the next range would have the desired
highlight (including an animation effect) applied.
Instead of assigning per-range delays, authors could specify each successive delay in its
own ::highlight() rule.
n text units requires maintaining n
unique ::highlight() rules. There are two ways to do this, neither of which is good:
AbstractRange with an inline style propertyAn alternative API would put the declaration directly on every range:
partial interface AbstractRange {
[SameObject, PutForwards=cssText]
readonly attribute CSSStyleDeclaration style;
};
range.style API.Range changes boundary
points.AbstractRange, rather than Range, also covers
StaticRange and future range types.Highlight objects, an intrinsic style would
force both uses to share declarations. Membership-owned styles let the same
range represent, for example, a yellow search result and a red diagnostic.HighlightRegistry
containing every Highlight which contains the range. This creates reverse
rendering dependencies on a general DOM geometry object.AbstractRange subtype, even when it is never
used for highlighting.An alternate model for opacity would collect the contributions from each highlight with opacity < 1 into an atomic paint group. Each group would be painted as a unit, with the groups stacked in custom-highlight order rather than interleaving their backgrounds, shadows, decorations, and foreground. Only the group belonging to the highlight that supplies the winning foreground would include foreground text.
Another alternate model for opacity retains the existing phase-major paint order: all highlight backgrounds, followed by shadows, underlines, overlines, the winning foreground, and line-throughs. Each highlight's opacity would apply independently to its contributions within those phases.
color: currentcolor claim the text foreground
without changing the color, but this also produces counterintuitive results: If the originating
element had text with a shadow, the foreground text would be faded, but the shadow would still be
painted at full opacity, because the latter was not part of the highlight contribution.color animationsopacity, requiring
separate keyframes for text and non-text units.color is less efficient than
compositing opacity. The former requires a native paint worklet in the
engine, whereas the latter can be offloaded entirely to the graphics API.As with any animation effect, user preferences expressed via prefers-reduced-motion
should be respected.
As with any opacity effect, user contrast preferences expressed through
prefers-contrast or Forced Colors Mode should be respected.
These extensions affect painting and do not alter the DOM or accessibility tree. Range boundaries and animation timing do not add semantics or cause text to be announced. Authors remain responsible for ensuring that information conveyed by a highlight is also available to assistive technology when needed. Compared with presentation-only element wrappers, highlights preserve the accessible representation and native selection behavior of the underlying content.
In contrast to other solutions explored in this space, this approach leaves the decision
of how to segment text in the author's hands. For word-by-word animations or similar effects,
authors are encouraged to use a standards-backed splitting algorithm such as the one
exposed by Intl.Segmenter.
The proposal builds on the existing Custom Highlight API and CSS animation
models. It does not expose document content or ranges that the calling script
could not already access. For custom highlights, the new events and Web
Animations objects describe only ranges already supplied to a Highlight,
their originating elements, and the effects applied to them.
CSS Pseudo-Elements Level 4 requires user agents to mitigate
direct style reads and timing attacks on spelling and grammar error highlights.
The TAG review of ::search-text
raises similar concerns about exposing active find-in-page matches. Accordingly,
these three pseudo-elements are excluded from the proposed animations,
transitions, and their script-visible events and Web Animations API exposure.
Opacity remains available; like existing author styling such as transparent
text color, it does not add script-visible access to active match state.
See also: Security and Privacy Questionnaire
Intl.SegmenterMany thanks for valuable feedback and advice from: