-
-
Notifications
You must be signed in to change notification settings - Fork 3
Animation
Every mark draws itself on. Instead of fading or popping into place, the band grows from its own tip and drags across the text like a real pen stroke. This page covers the animation option group: direction, when the stroke starts, whether it repeats, timing, and how reduced-motion is honoured.
The draw-on is the only entrance animation. There is no exit animation through this group. (Live selection has its own fade-out on clear, gated by fadeOnClear; see Selection-Highlighting.)
All animation settings live under the animation key of Options-Reference. The defaults produce a 420 ms left-to-right sweep that fires immediately on mount, with a 90 ms stagger between wrapped lines.
Suppressed automatically under prefers-reduced-motion: reduce (see Reduced motion below).
| Field | Type | Default | Values / range | Meaning |
|---|---|---|---|---|
draw |
boolean |
true |
— | Enable the draw-on swipe. false shows the full mark instantly. |
duration |
number |
420 |
ms, positive | Duration of a single band's draw-on. Non-positive falls back to the default. |
easing |
EasingValue |
"ease-out" |
"linear", "ease", "ease-in", "ease-out", "ease-in-out", or any valid CSS easing string |
Easing for the sweep. |
direction |
AnimationDirection |
"left-to-right" |
"left-to-right", "right-to-left", "center-out"
|
Sweep direction across each band. |
stagger |
number |
90 |
ms | Delay between successive lines / marks, so a wrapped mark reads as one continuous pen travel. |
trigger |
AnimationTrigger |
"immediate" |
"immediate", "in-view"
|
When the stroke begins. "in-view" arms an IntersectionObserver. |
threshold |
number |
0.2 |
0-1
|
IntersectionObserver threshold (only used for "in-view"). |
rootMargin |
string |
"0px" |
CSS margin string |
IntersectionObserver root margin (only used for "in-view"). |
repeat |
boolean |
false |
— | Re-animate every time the mark re-enters view, vs one-shot (only meaningful with "in-view"). |
AnimationDirection, AnimationTrigger, and EasingValue are exported from @highlighters/core and re-exported from each framework binding. See the Options-Reference for the full HighlightOptions shape.
Pass nothing and you get the full draw-on: a 420 ms ease-out left-to-right sweep, firing on mount, staggered 90 ms per wrapped line.
import { highlight } from "@highlighters/core";
// Default entrance: draws on immediately, left to right.
highlight(".intro");To turn the entrance off entirely and have the mark appear fully painted:
highlight(".intro", { animation: { draw: false } });direction sets which way the stroke sweeps across each band.
// Pen travels left to right (default).
highlight("h1", { animation: { direction: "left-to-right" } });
// Pen travels right to left.
highlight("h1", { animation: { direction: "right-to-left" } });
// Stroke opens from the middle outward.
highlight("h1", { animation: { direction: "center-out" } });A multi-line (wrapped) mark animates one band per line, in reading order, offset by stagger. The direction applies to each band individually.
trigger decides when the stroke starts.
-
"immediate"(default): the draw-on runs as soon as the mark mounts. -
"in-view": the mark is parked closed (invisible) and anIntersectionObserverwatches it. The stroke begins the first time the mark intersects the viewport, usingthresholdandrootMarginto tune when "in view" counts.
// Draw on as soon as the element scrolls into view.
highlight(".callout", {
animation: {
trigger: "in-view",
threshold: 0.4, // at least 40% visible
rootMargin: "0px 0px -10% 0px",
},
});threshold and rootMargin map straight onto the IntersectionObserver options. They have no effect when trigger is "immediate".
If IntersectionObserver is unavailable, the mark falls back to drawing immediately.
By default an "in-view" mark draws once: after it has played, the observer disconnects and it stays settled even if you scroll it out and back.
Set repeat: true to re-run the stroke every time the mark re-enters view. A mark already on screen is restarted when it re-intersects, so scrolling it away and back replays the entrance.
// Replays the draw-on on every re-entry into the viewport.
highlight(".stat", {
animation: { trigger: "in-view", repeat: true },
});repeat only matters with trigger: "in-view". With "immediate" the stroke plays once on mount; to replay it on demand, call handle.show() (see Replaying on demand).
duration is the time for a single band to draw on, in milliseconds. easing accepts the CSS keywords or any valid CSS easing string, including cubic-bezier(...).
// Slow, gentle ease-in-out.
highlight("p", {
animation: { duration: 900, easing: "ease-in-out" },
});
// Custom cubic-bezier.
highlight("p", {
animation: { duration: 600, easing: "cubic-bezier(0.16, 1, 0.3, 1)" },
});A non-positive duration is rejected and falls back to the 420 default. Unrecognised easing strings fall back to ease-out.
stagger is separate from duration: it is the per-line delay before each successive band starts. On a single-line mark stagger has no visible effect; on a wrapped mark it spaces the lines so the pen reads as travelling down the page. Use group() to extend the same staggered choreography across several independent marks.
import { group, highlight } from "@highlighters/core";
const g = group([
highlight(".step-1", { animation: { trigger: "in-view" } }),
highlight(".step-2", { animation: { trigger: "in-view" } }),
highlight(".step-3", { animation: { trigger: "in-view" } }),
]);
// Reveals members in array order so their draw-on staggers like a pen down the page.
g.show();See API-Reference for group() and GroupHandle.
The draw-on is suppressed automatically when the user has prefers-reduced-motion: reduce set. The mark appears fully painted, instantly, with no sweep, no fade, and no observer-driven replay. You do not need to special-case it; it is read from matchMedia at render time and is safe to call outside a DOM (it reports no reduced-motion preference when there is no window).
Reduced motion also factors into renderer tier selection: under renderer: "auto" it is one of the conditions that degrades the SVG tier to the CSS tier. See How-It-Works and Performance for tier degrade rules.
If you want to honour reduced motion but keep a subtle change, your options are to leave draw: true (the library does the right thing) or set draw: false unconditionally. There is no separate "reduced" timing track.
The entrance plays once on mount. To re-run it later, hide and show the handle:
const h = highlight(".badge");
// ...later
h.hide();
h.show(); // an explicit re-show replays the draw-on entranceshow() replays the draw-on; hide() pools the mark without tearing down geometry, so the next show() is cheap. A reflow (resize, font load, content change) never re-animates: it retargets an in-flight stroke onto corrected geometry, or re-shows the settled mark, but it does not restart the entrance. See MarkHandle in the API-Reference.
The animation group is passed through options exactly as in vanilla. No framework-specific animation API exists; these are the same fields documented above.
import { Highlight, useHighlight } from "@highlighters/react";
import { useRef } from "react";
// Component
function Callout() {
return (
<Highlight
options={{
animation: { trigger: "in-view", direction: "center-out", duration: 600 },
}}
>
drawn when it scrolls into view
</Highlight>
);
}
// Hook
function Badge() {
const ref = useRef<HTMLSpanElement>(null);
useHighlight(ref, {
animation: { duration: 300, easing: "ease-in" },
});
return <span ref={ref}>fast entrance</span>;
}Option changes are pushed through handle.update() without re-seeding geometry, so tweaking timing on a live mark does not restart the stroke. To replay deliberately, drive the underlying handle's show().
<script setup lang="ts">
import { Highlight } from "@highlighters/vue";
const anim = {
animation: { trigger: "in-view", repeat: true, stagger: 120 },
};
</script>
<template>
<Highlight :options="anim">replays on every re-entry</Highlight>
</template>// Composable
import { useHighlight } from "@highlighters/vue";
import { ref } from "vue";
const el = ref<HTMLElement | null>(null);
useHighlight(el, { animation: { direction: "right-to-left" } });<script lang="ts">
import { highlight } from "@highlighters/svelte";
</script>
<p use:highlight={{ animation: { trigger: "in-view", duration: 700, easing: "ease-out" } }}>
drawn on view
</p>The Svelte action's update(next) pushes new options through the live handle and preserves geometry, matching the React/Vue behaviour: changing timing does not restart the entrance.
The mark draws itself on by growing its own geometry rather than animating a CSS property. Each frame, the band's clip-path is rebuilt truncated to an advancing front, so the leading edge is always the mark's own tip cap and the drawn prefix is byte-identical frame to frame. There is no width-stretch and no opacity cross-fade of the whole band (only a tiny fixed onset fade softens each touchdown).
Because the draw is deterministic geometry, a reflow mid-stroke retargets onto corrected geometry and keeps drawing instead of flashing to full and restarting. Cancelling a mark mid-draw restores its full clip, so an interrupted stroke never strands a truncated band. Lines find their band by stable seed, never by index, so several marks sharing one overlay container never animate each other's bands.
For the per-frame browser cost of moving and re-clipping marks, see Performance.
See Options-Reference for the complete animation field list in context, API-Reference for MarkHandle.show() / group(), Selection-Highlighting for the live-selection fade-on-clear, Accessibility-and-Reflow for reduced-motion and reflow behaviour, and Recipes for ready-made entrance patterns.
Getting Started
The Mark
- Mark-Types-and-Shapes
- Tips-and-Edges
- Ink-and-Optics
- Color-and-Palettes
- Snapping-and-Overshoot
- Animation
Targeting
Reference
Production