feat(skills): add marker-highlight skill for animated text highlighting (#190)

## Summary

- **New skill:** **`marker-highlight`** — integrates [MarkerHighlight.js](https://github.com/Robincodes-Sandbox/marker-highlight) into HyperFrames compositions. Canvas-based animated text highlighting with 5 drawing modes: marker pen, circle, burst, scribble, and sketchout.
- **Studio fix:** added missing `captionSync` to useEffect dependency array (oxlint exhaustive-deps)
- **Studio fix:** `loadOverrides` now checks `res.ok` before parsing, preventing 404 console noise on projects without captions

## Skill details

The skill documents the non-obvious GSAP integration pattern discovered during development:

1. **One highlighter per container** — the library clears ALL `.highlight` divs from the shared parent on init, so multiple instances on sibling marks conflict
2. **`data-color`** **\+** **`data-original-bgcolor`** — prevents the CSS background-color flash that occurs when the library reads and clears the mark's background
3. **Canvas pre-draw + clear + reanimate** — `animate: false` pre-draws statically, canvases are hidden, then cleared and shown with `reanimateMark()` at trigger time for clean animated reveals
4. **`onReverseComplete`** **for rewind** — hides highlight divs when the timeline seeks backward past the trigger point

## Test plan

- [ ] `npx hyperframes lint` passes on test-composition
- [ ] Studio preview shows marker highlight on "something" at 1s, circle on "love" at 2.2s
- [ ] Rewind past trigger points hides highlights
- [ ] No 404 console errors for caption-overrides.json on non-caption projects



[Screen Recording 2026-04-02 at 1.56.30 AM.mov <span class="graphite__hidden">(uploaded via Graphite)</span> <img class="graphite__hidden" src="https://app.graphite.com/user-attachments/thumbnails/53b03f4e-538e-477a-b738-7a033b99a84e.mov" />](https://app.graphite.com/user-attachments/video/53b03f4e-538e-477a-b738-7a033b99a84e.mov)



🤖 Generated with [Claude Code](https://claude.com/claude-code)
This commit is contained in:
Vance Ingalls
2026-04-02 13:48:24 -07:00
committed by GitHub
parent 5e2781b459
commit cb0b17062a
9 changed files with 863 additions and 46 deletions
+2
View File
@@ -10,6 +10,7 @@ This repo ships skills that are installed globally via `npx hyperframes skills`
| ------------------------ | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **hyperframes-compose** | `/hyperframes-compose` | Creating ANY HTML composition — videos, animations, title cards, overlays. Contains required HTML structure, `class="clip"` rules, GSAP timeline patterns, and rendering constraints. |
| **hyperframes-captions** | `/hyperframes-captions` | Any task involving text synced to audio: captions, subtitles, lyrics, lyric videos, karaoke. Also covers transcription strategy (whisper model selection, transcript format). |
| **marker-highlight** | `/marker-highlight` | Animated text highlighting — marker sweeps, hand-drawn circles, burst lines, scribble, sketchout. Use with captions for dynamic emphasis. |
### GSAP Skills (from [greensock/gsap-skills](https://github.com/greensock/gsap-skills))
@@ -34,6 +35,7 @@ The skills encode HyperFrames-specific patterns (e.g., required `class="clip"` o
- When creating a video from audio (music video, lyric video, audio visualizer with text) → invoke BOTH `/hyperframes-compose` AND `/hyperframes-captions`
- When writing GSAP animations → invoke `/gsap-core` and `/gsap-timeline` BEFORE writing any code
- When optimizing animation performance → invoke `/gsap-performance` BEFORE making changes
- When adding animated text emphasis (highlight sweeps, circles, bursts, scribbles) → invoke `/marker-highlight` BEFORE writing any code
- After creating or editing any `.html` composition → run `npx hyperframes lint` and `npx hyperframes validate` in parallel, fix all errors before opening the studio or considering the task complete. `lint` checks the HTML structure statically; `validate` loads the composition in headless Chrome and catches runtime JS errors, missing assets, and failed network requests. Always validate before `npx hyperframes preview`.
### Installing skills
@@ -42,6 +42,9 @@ export function applyCaptionOverrides(): void {
const gsap = (window as unknown as { gsap?: GsapStatic }).gsap;
if (!gsap) return;
// Only fetch overrides if the composition has caption groups
if (document.querySelectorAll(".caption-group").length === 0) return;
fetch("caption-overrides.json")
.then((r) => {
if (!r.ok) return null;
@@ -1,6 +1,7 @@
import { memo, useCallback } from "react";
import { memo, useCallback, useState } from "react";
import { useCaptionStore } from "../store";
import type { CaptionStyle } from "../types";
import { CaptionAnimationPanel } from "./CaptionAnimationPanel";
import { Section, Row, inputCls } from "./shared";
// ---------------------------------------------------------------------------
@@ -20,6 +21,8 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
const updateSelectedStyle = useCaptionStore((s) => s.updateSelectedStyle);
const updateGroupStyle = useCaptionStore((s) => s.updateGroupStyle);
const [activeTab, setActiveTab] = useState<"style" | "animation">("style");
// Resolve effective style for the first selected segment
const firstSegmentId = selectedSegmentIds.size > 0 ? [...selectedSegmentIds][0] : undefined;
const firstSegment = model?.segments.get(firstSegmentId ?? "");
@@ -184,54 +187,89 @@ export const CaptionPropertyPanel = memo(function CaptionPropertyPanel({
<div className="flex flex-col h-full min-h-0">
{/* Header */}
<div className="px-3 py-2 border-b border-neutral-800 flex-shrink-0">
<span className="text-2xs text-neutral-500">{countLabel}</span>
<div className="flex items-center justify-between mb-1.5">
<span className="text-2xs text-neutral-500">{countLabel}</span>
</div>
{/* Tab switcher */}
<div className="flex gap-1">
<button
type="button"
onClick={() => setActiveTab("style")}
className={[
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
activeTab === "style"
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
].join(" ")}
>
Style
</button>
<button
type="button"
onClick={() => setActiveTab("animation")}
className={[
"flex-1 py-0.5 rounded text-2xs font-medium transition-colors",
activeTab === "animation"
? "bg-studio-accent/20 text-studio-accent border border-studio-accent/50"
: "text-neutral-500 border border-neutral-800 hover:text-neutral-300 hover:border-neutral-600",
].join(" ")}
>
Animation
</button>
</div>
</div>
<div className="flex-1 overflow-y-auto px-3 py-2">
<Section label="Position">
<Row label="X">
<input
type="number"
value={x}
onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
className={inputCls}
/>
</Row>
<Row label="Y">
<input
type="number"
value={y}
onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
{/* Animation tab */}
{activeTab === "animation" && <CaptionAnimationPanel />}
<Section label="Transform">
<Row label="Scale">
<input
type="number"
value={scaleX}
step={0.1}
onChange={(e) =>
handleStyleChange({
scaleX: Number(e.target.value),
scaleY: Number(e.target.value),
})
}
className={inputCls}
/>
</Row>
<Row label="Rotation">
<input
type="number"
value={rotation}
onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
</div>
{/* Style tab — Transform only */}
{activeTab === "style" && (
<div className="flex-1 overflow-y-auto px-3 py-2">
<Section label="Position">
<Row label="X">
<input
type="number"
value={x}
onChange={(e) => handleStyleChange({ x: Number(e.target.value) })}
className={inputCls}
/>
</Row>
<Row label="Y">
<input
type="number"
value={y}
onChange={(e) => handleStyleChange({ y: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
<Section label="Transform">
<Row label="Scale">
<input
type="number"
value={scaleX}
step={0.1}
onChange={(e) =>
handleStyleChange({
scaleX: Number(e.target.value),
scaleY: Number(e.target.value),
})
}
className={inputCls}
/>
</Row>
<Row label="Rotation">
<input
type="number"
value={rotation}
onChange={(e) => handleStyleChange({ rotation: Number(e.target.value) })}
className={inputCls}
/>
</Row>
</Section>
</div>
)}
</div>
);
});
@@ -115,6 +115,7 @@ export function useCaptionSync(projectId: string | null) {
const res = await fetch(
`/api/projects/${pid}/files/${encodeURIComponent("caption-overrides.json")}`,
);
if (!res.ok) return;
const data = await res.json();
if (!data.content) return;
+3
View File
@@ -97,6 +97,7 @@ For each detected word, specify:
- Color override (specific hex value)
- Weight/style change (bolder, italic)
- Animation variant (overshoot entrance, glow pulse, scale pop)
- **Marker highlight mode** — for visual emphasis beyond color/scale, add a marker-style effect: highlight sweep behind the word, hand-drawn circle around it, burst lines radiating from it, or scribble underline beneath it. See the `/marker-highlight` skill for patterns and the energy-to-mode mapping table.
## Script-to-Style Mapping
@@ -198,6 +199,8 @@ Place this **before** `window.__timelines[id] = tl` so it runs at composition in
For dynamic animation techniques (karaoke, clip-path reveals, slam words, scatter exits, elastic entrances, 3D rotation, audio-reactive captions, pretext-based positioning and grouping), see [dynamic-techniques.md](./dynamic-techniques.md).
For animated text emphasis (highlight sweeps, hand-drawn circles, burst lines, scribble underlines, sketchout effects) that pairs with per-word styling, see the `/marker-highlight` skill.
For transcription commands, whisper models, external APIs, and troubleshooting, see [transcript-guide.md](./transcript-guide.md).
## Constraints
@@ -16,6 +16,8 @@ You are here because SKILL.md told you to read this file before writing animatio
**Emphasis words always break the pattern.** When a word is flagged as emphasis (emotional keyword, ALL CAPS, brand name), give it a stronger animation than surrounding words (larger scale, accent color, overshoot ease). This creates contrast.
**Marker highlight modes add a visual layer on top of karaoke.** For emphasis words that need more than color/scale, add a marker-style effect — highlight sweep, circle, burst, or scribble — using the `/marker-highlight` skill. Match mode to energy: burst for hype, circle for key terms, highlight for standard, scribble for subtle.
## Audio-Reactive Captions (Mandatory for Music)
**If the source audio is music (vocals over instrumentation, beats, any musical content), you MUST extract audio data and add audio-reactive animations.** This is not optional — music without audio reactivity looks disconnected. Even low-energy ballads get subtle bass pulse and treble glow.
@@ -74,6 +76,8 @@ python3 skills/gsap-effects/scripts/extract-audio-data.py audio.mp3 --fps 30 --b
Don't use the same highlight animation on every group — cycle through styles using the group index. Don't combine multiple competing animations on the same word at the same timestamp. Vary techniques across groups to match the content's pace changes.
**Marker highlight effects** (from the `/marker-highlight` skill) layer well with karaoke — use karaoke for the word-by-word reveal, then add a marker effect on emphasis words only. For example: karaoke highlights each word in white, but brand names get a yellow highlight sweep and stats get a red circle. Cycle marker modes across groups for visual variety (see the mode-to-energy mapping in the marker-highlight skill).
## Available Tools
These tools are available in the HyperFrames runtime. Use them when they solve a real problem — not every composition needs all of them.
+257
View File
@@ -0,0 +1,257 @@
---
name: marker-highlight
description: Use when highlighting text, circling words, or adding hand-drawn annotation effects in a composition. Marker pen, circle, burst, scribble, and sketchout modes via animated canvas overlays.
---
# Marker Highlight
Animated canvas-based text highlighting using [MarkerHighlight.js](https://github.com/Robincodes-Sandbox/marker-highlight). Wraps text in `<mark>` tags and renders animated highlight effects (marker pen, hand-drawn circle, burst rays, scribble, sketchout) on a canvas overlay without modifying the text DOM.
The library runs its own requestAnimationFrame animation loop — it is **not** GSAP-driven. Use GSAP `tl.call()` to trigger highlights at specific points in the timeline.
## Required Script
The library is an ES module. For HyperFrames, create a global-script version by downloading the minified file and replacing the `export` line:
```bash
curl -sL "https://cdn.jsdelivr.net/gh/Robincodes-Sandbox/marker-highlight@main/dist/marker-highlight.min.js" \
| sed 's/export{[^}]*};$/window.MarkerHighlighter=W;/' > marker-highlight.global.js
```
The `sed` command replaces the ES module `export` with a global assignment. The variable name `W` is the minifier's alias for MarkerHighlighter — if the library is rebuilt and the minifier picks a different name, check the last line of the minified file for the correct alias.
Then load it as a regular script:
```html
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="marker-highlight.global.js"></script>
```
## Color Setup
Set highlight colors via `data-color` on each `<mark>`, then copy them to `data-original-bgcolor` before constructing the highlighter. Never set `background-color` in CSS — it flashes before the library takes over.
```css
mark {
color: inherit;
background-color: transparent;
}
```
```html
<mark id="m1" data-color="rgba(255, 220, 50, 0.5)">highlighted</mark>
```
```js
// Copy colors before any MarkerHighlighter construction
document.querySelectorAll("mark[data-color]").forEach(function (m) {
m.setAttribute("data-original-bgcolor", m.getAttribute("data-color"));
});
```
## GSAP Integration Pattern
The library has three behaviors that matter for timeline control:
1. **`animate: false`** draws highlights statically on construction — canvas is pre-filled
2. **`reanimateMark()`** animates from scratch, but only works on a clean canvas
3. **Multiple MarkerHighlighter instances** on sibling elements conflict — the library clears ALL `.highlight` divs from the shared parent container on init
The correct pattern: use ONE `MarkerHighlighter` per container with `animate: false`, hide all canvases immediately, then clear + show + reanimate per mark at trigger time.
```js
// 1. Construct once — draws everything statically
var hl = new MarkerHighlighter(document.getElementById("text-container"), {
animate: false,
animationSpeed: 800,
padding: 0.3,
highlight: { amplitude: 0.3, wavelength: 5 },
});
// 2. Hide all canvases after static render
setTimeout(function () {
document.querySelectorAll(".highlight").forEach(function (div) {
div.style.opacity = "0";
});
}, 100);
// 3. Trigger individual marks from the timeline
function addHighlight(highlighter, markId, time) {
tl.to(
{},
{
duration: 0.001,
onStart: function () {
var mark = document.getElementById(markId);
var ref = mark.getAttribute("data-mark-ref");
if (!ref) return;
var divs = mark.parentElement.querySelectorAll('.highlight[data-mark-id="' + ref + '"]');
divs.forEach(function (div) {
var canvas = div.querySelector("canvas");
if (canvas) canvas.getContext("2d").clearRect(0, 0, canvas.width, canvas.height);
div.style.opacity = "1";
});
highlighter.reanimateMark(mark);
},
onReverseComplete: function () {
var mark = document.getElementById(markId);
var ref = mark.getAttribute("data-mark-ref");
if (!ref) return;
mark.parentElement
.querySelectorAll('.highlight[data-mark-id="' + ref + '"]')
.forEach(function (div) {
div.style.opacity = "0";
});
},
},
time,
);
}
addHighlight(hl, "m1", 1.0);
addHighlight(hl, "m2", 2.2);
```
The `onReverseComplete` hides the highlight when the timeline rewinds past the trigger point.
## Drawing Modes
Set via the `drawingMode` option or `data-drawing-mode` attribute per mark.
| Mode | Effect | Best for |
| ----------- | -------------------------------------------- | ---------------------------------------- |
| `highlight` | Wavy marker pen stroke behind text (default) | Emphasizing phrases, key terms |
| `circle` | Hand-drawn circle/ellipse around text | Calling out single words, annotations |
| `burst` | Radiating lines, curves, or cloud puffs | Excitement, emphasis, visual energy |
| `scribble` | Chaotic hand-drawn scribble over text | Crossing out, messy energy, redaction |
| `sketchout` | Rough rectangle outline around text | Boxed callouts, technical/blueprint feel |
```html
<mark data-drawing-mode="circle" data-color="rgba(229, 57, 53, 0.6)">critical</mark>
<mark
data-drawing-mode="burst"
data-burst='{"style":"cloud","count":20,"power":1.5}'
data-color="rgba(255, 220, 50, 0.5)"
>amazing</mark
>
```
## Configuration
### Global Options (constructor)
| Option | Type | Default | Description |
| ---------------- | ------ | ------------- | ------------------------------------------------------ |
| `animate` | bool | `true` | Set `false` to defer animation for GSAP control |
| `animationSpeed` | number | `5000` | Animation duration in ms |
| `drawingMode` | string | `"highlight"` | Default mode for all marks |
| `height` | number | `1` | Height relative to line height (0.15 = underline) |
| `offset` | number | `0` | Vertical shift (-1 = above, 1 = below text) |
| `padding` | number | `0` | Horizontal padding around text |
| `easing` | string | `"ease"` | `ease`, `linear`, `ease-in`, `ease-out`, `ease-in-out` |
| `skewX` | number | `0` | Horizontal slant |
| `multiLineDelay` | number | `0` | Delay between line segments (0-1 ratio of speed) |
### Per-Mode Options
**highlight** — `highlight` object or `data-highlight` attribute:
| Option | Default | Description |
| ------------ | ------- | ----------------------------------- |
| `amplitude` | `0.25` | Edge waviness (0 = flat, 1+ = wavy) |
| `wavelength` | `1` | Wave frequency |
| `roughEnds` | `5` | Irregularity at start/end |
| `jitter` | `0.1` | Randomness in the wave path |
**circle** — `circle` object or `data-circle` attribute:
| Option | Default | Description |
| ----------- | ------- | --------------------------------------------- |
| `curve` | `0.5` | Shape: 0 = square, 0.5 = rounded, 1 = ellipse |
| `wobble` | `0.3` | Hand-drawn irregularity |
| `loops` | `3` | Number of overlapping strokes |
| `thickness` | `5` | Line thickness |
**burst** — `burst` object or `data-burst` attribute:
| Option | Default | Description |
| ------------ | --------- | ------------------------- |
| `style` | `"lines"` | `lines`, `curve`, `cloud` |
| `count` | `10` | Number of rays/puffs |
| `power` | `1` | Ray length multiplier |
| `randomness` | `0.5` | Variation in placement |
### Per-Element Overrides
Any option can be set per `<mark>` via `data-*` attributes:
```html
<mark
data-drawing-mode="highlight"
data-animation-speed="800"
data-height="0.15"
data-offset="0.8"
data-padding="0"
data-highlight='{"amplitude": 0.2, "wavelength": 5, "roughEnds": 0}'
data-color="rgba(30, 136, 229, 0.5)"
>underlined text</mark
>
```
## Named Styles
Define reusable presets and apply them with `data-highlight-style`:
```js
MarkerHighlighter.defineStyle("underline", {
animationSpeed: 400,
height: 0.15,
offset: 0.8,
padding: 0,
highlight: { amplitude: 0.2, wavelength: 5, roughEnds: 0 },
});
MarkerHighlighter.defineStyle("redact", {
drawingMode: "scribble",
animationSpeed: 300,
height: 1.2,
});
```
```html
<mark data-highlight-style="underline" data-color="rgba(30, 136, 229, 0.5)">key term</mark>
<mark data-highlight-style="redact" data-color="rgba(0, 0, 0, 0.8)">classified</mark>
```
Define styles before constructing the `MarkerHighlighter` instance.
## Mode-to-Caption Energy Mapping
Match modes to caption energy levels detected by the `hyperframes-captions` skill:
| Caption energy | Recommended mode | Use for |
| -------------- | --------------------- | ------------------------------------- |
| High | `burst` + `highlight` | Product launches, hype videos |
| Medium-high | `circle` | Key stats, important terms |
| Medium | `highlight` | Standard emphasis, clean professional |
| Medium-low | `scribble` | Subtle emphasis, tutorials |
| Low | `sketchout` | Contrast with active text |
## Recipes and Full Example
See [references/examples.md](./references/examples.md) for underline, strikethrough, circled annotation recipes, and a complete composition example with the full GSAP integration pattern.
## CSS+GSAP Fallback (No Library)
For deterministic rendering without the library, see [references/css-patterns.md](./references/css-patterns.md) — pure CSS+GSAP implementations of all five modes. Fully seekable and timeline-controlled, but without the hand-drawn canvas aesthetic.
## HyperFrames Integration Notes
- **One highlighter per container.** The library clears ALL `.highlight` divs from the parent element on init. Don't create multiple MarkerHighlighter instances on sibling marks in the same `<p>` — use one instance for the whole container.
- **No visible background-color on marks.** Use `data-color` + `data-original-bgcolor` to pass colors without a visible CSS flash. Set `mark { background-color: transparent }` in CSS.
- **Canvas pre-draw + clear pattern.** Init with `animate: false` (pre-draws statically), hide canvases, then clear + show + `reanimateMark()` at trigger time. This gives clean animated reveals.
- **Rewind support.** Use `onReverseComplete` to hide the highlight div when the timeline seeks backward past the trigger point.
- **rAF-based animation.** Highlights are not GSAP-driven and not seekable mid-stroke. Scrubbing won't show partial draw progress.
- The canvas overlay is positioned absolutely relative to the mark's parent. The library sets `position: relative` on the container's parent automatically.
- For multi-line text, the library creates separate canvas segments per line and animates them sequentially when `multiLineDelay > 0`.
- Nested `<mark>` tags work — inner marks render on top of outer marks via z-index layering.
@@ -0,0 +1,363 @@
# CSS Patterns for Marker Highlighting
Pure CSS + GSAP implementations of all five MarkerHighlight.js drawing modes. Use these for deterministic rendering in HyperFrames compositions — no external library dependency, full GSAP timeline control.
## 1. Highlight Mode
Yellow marker sweep behind text. The most common mode.
```html
<div class="mh-highlight-wrap">
<div class="mh-highlight-bar" id="hl-1"></div>
<span class="mh-highlight-text">highlighted text</span>
</div>
```
```css
.mh-highlight-wrap {
position: relative;
display: inline-block;
}
.mh-highlight-bar {
position: absolute;
top: 0;
left: -6px;
right: -6px;
bottom: 0;
background: #fdd835;
opacity: 0.35;
transform: scaleX(0);
transform-origin: left center;
border-radius: 3px;
z-index: 0;
}
.mh-highlight-text {
position: relative;
z-index: 1;
}
```
```js
// Sweep in from left
tl.to("#hl-1", { scaleX: 1, duration: 0.5, ease: "power2.out" }, 0.6);
// Optional: skew for hand-drawn feel
// gsap.set("#hl-1", { skewX: -2 });
```
### Multi-line Highlight
Stagger bars across multiple lines:
```js
tl.to(
".mh-highlight-bar",
{
scaleX: 1,
duration: 0.5,
ease: "power2.out",
stagger: 0.3,
},
0.6,
);
```
## 2. Circle Mode
Hand-drawn circle around text. Use `border-radius: 50%` with a slight rotation for organic feel.
```html
<div class="mh-circle-wrap">
<span class="mh-circle-text" id="circle-word">IMPORTANT</span>
<div class="mh-circle-ring" id="circle-1"></div>
</div>
```
```css
.mh-circle-wrap {
position: relative;
display: inline-block;
}
.mh-circle-text {
position: relative;
z-index: 1;
}
.mh-circle-ring {
position: absolute;
top: 50%;
left: 50%;
width: 130%;
height: 160%;
transform: translate(-50%, -50%) rotate(-3deg) scale(0);
border: 3px solid #e53935;
border-radius: 50%;
pointer-events: none;
z-index: 0;
}
```
```js
// Circle scales in with a wobble
tl.to(
"#circle-1",
{
scale: 1,
rotation: -3,
duration: 0.6,
ease: "back.out(1.7)",
transformOrigin: "center center",
},
0.7,
);
```
### Variations
```css
/* Tighter circle (for short words) */
.mh-circle-ring.tight {
width: 150%;
height: 180%;
}
/* Squared circle (rounded rectangle) */
.mh-circle-ring.rounded {
border-radius: 30%;
width: 120%;
height: 140%;
}
/* Ellipse (wider than tall) */
.mh-circle-ring.ellipse {
width: 150%;
height: 130%;
border-radius: 50%;
}
```
## 3. Burst Mode
Radiating lines from text center. Each line is a positioned div rotated to its angle.
```html
<div class="mh-burst-wrap">
<span class="mh-burst-text">WOW</span>
<div class="mh-burst-container" id="burst-1">
<div class="mh-burst-line" style="--angle: 0deg; --len: 70px;"></div>
<div class="mh-burst-line" style="--angle: 30deg; --len: 55px;"></div>
<div class="mh-burst-line" style="--angle: 60deg; --len: 80px;"></div>
<div class="mh-burst-line" style="--angle: 90deg; --len: 45px;"></div>
<div class="mh-burst-line" style="--angle: 120deg; --len: 65px;"></div>
<div class="mh-burst-line" style="--angle: 150deg; --len: 75px;"></div>
<div class="mh-burst-line" style="--angle: 180deg; --len: 50px;"></div>
<div class="mh-burst-line" style="--angle: 210deg; --len: 60px;"></div>
<div class="mh-burst-line" style="--angle: 240deg; --len: 80px;"></div>
<div class="mh-burst-line" style="--angle: 270deg; --len: 40px;"></div>
<div class="mh-burst-line" style="--angle: 300deg; --len: 70px;"></div>
<div class="mh-burst-line" style="--angle: 330deg; --len: 55px;"></div>
</div>
</div>
```
```css
.mh-burst-wrap {
position: relative;
display: inline-block;
}
.mh-burst-text {
position: relative;
z-index: 2;
}
.mh-burst-container {
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
z-index: 1;
}
.mh-burst-line {
position: absolute;
width: 3px;
height: var(--len);
background: #1e88e5;
left: -1.5px;
top: calc(-1 * var(--len));
transform: rotate(var(--angle));
transform-origin: bottom center;
opacity: 0;
}
```
```js
// All lines burst outward simultaneously with slight stagger
tl.fromTo(
"#burst-1 .mh-burst-line",
{ scaleY: 0, opacity: 0 },
{ scaleY: 1, opacity: 1, duration: 0.4, ease: "power2.out", stagger: 0.03 },
0.7,
);
```
**Vary line lengths** (40-80px range) for an organic, hand-drawn feel. Equal lengths look mechanical.
## 4. Scribble Mode
Wavy SVG underlines and strikethroughs that draw themselves via `stroke-dashoffset`.
```html
<div class="mh-scribble-wrap">
<span class="mh-scribble-text">underlined text</span>
<svg class="mh-scribble-svg" viewBox="0 0 500 24" preserveAspectRatio="none">
<path
id="scribble-1"
d="M0,12 Q31,0 62,12 Q93,24 125,12 Q156,0 187,12 Q218,24 250,12 Q281,0 312,12 Q343,24 375,12 Q406,0 437,12 Q468,24 500,12"
fill="none"
stroke="#FDD835"
stroke-width="3"
stroke-linecap="round"
/>
</svg>
</div>
```
```css
.mh-scribble-wrap {
position: relative;
display: inline-block;
}
.mh-scribble-text {
position: relative;
z-index: 1;
}
.mh-scribble-svg {
position: absolute;
left: 0;
bottom: -6px;
width: 100%;
height: 24px;
z-index: 0;
}
```
```js
// Measure path length and set initial dash state
var path = document.querySelector("#scribble-1");
var len = path.getTotalLength();
gsap.set(path, { strokeDasharray: len, strokeDashoffset: len });
// Draw the line
tl.to(
"#scribble-1",
{
strokeDashoffset: 0,
duration: 0.8,
ease: "power1.inOut",
},
0.7,
);
```
### Strikethrough Variant
Position the SVG at `top: 50%; transform: translateY(-50%)` instead of `bottom: -6px`.
### Wavy Path Generator
Scale the path's viewBox width to match text width. The wave pattern `Q x1,y1 x2,y2` alternates between `y=0` and `y=24` for a natural wobble. Adjust the control points for tighter or looser waves:
- **Tight waves**: smaller x-increments (25px per half-wave)
- **Loose waves**: larger x-increments (50px per half-wave)
- **Amplitude**: change the y range (0-24 for standard, 0-16 for subtle)
## 5. Sketchout Mode
Cross-hatch lines over de-emphasized text. Multiple angled lines create a "crossed out" effect.
```html
<div class="mh-sketchout-wrap">
<span class="mh-sketchout-text">old price</span>
<div class="mh-sketchout-lines" id="sketchout-1">
<div class="mh-sketchout-line mh-sketchout-fwd"></div>
<div class="mh-sketchout-line mh-sketchout-bwd"></div>
</div>
</div>
```
```css
.mh-sketchout-wrap {
position: relative;
display: inline-block;
}
.mh-sketchout-text {
position: relative;
z-index: 0;
}
.mh-sketchout-lines {
position: absolute;
top: 0;
left: -4px;
right: -4px;
bottom: 0;
overflow: hidden;
z-index: 1;
}
.mh-sketchout-line {
position: absolute;
top: 50%;
left: 0;
width: 100%;
height: 2px;
background: #e53935;
transform-origin: left center;
transform: scaleX(0);
}
.mh-sketchout-fwd {
transform: scaleX(0) rotate(-12deg);
}
.mh-sketchout-bwd {
transform: scaleX(0) rotate(12deg);
}
```
```js
// Forward slash draws first
tl.to(
"#sketchout-1 .mh-sketchout-fwd",
{
scaleX: 1,
duration: 0.3,
ease: "power2.out",
},
1.0,
);
// Backward slash follows
tl.to(
"#sketchout-1 .mh-sketchout-bwd",
{
scaleX: 1,
duration: 0.3,
ease: "power2.out",
},
1.15,
);
```
## Combining Modes in Captions
Use mode cycling for visual variety across caption groups:
```js
var MODES = ["highlight", "circle", "burst", "scribble"];
GROUPS.forEach(function (group, gi) {
var mode = MODES[gi % MODES.length];
// Apply the mode's CSS pattern to emphasis words in this group
group.emphasisWords.forEach(function (word) {
applyMode(word.el, mode, tl, word.start);
});
});
```
Cycle every 2-3 groups for high energy, every 3-4 for medium, every 4-5 for low.
@@ -0,0 +1,146 @@
# Marker Highlight Examples
## Recipes
### Underline
```html
<mark
data-height="0.15"
data-offset="0.8"
data-padding="0"
data-highlight='{"amplitude":0.2,"wavelength":5,"roughEnds":0}'
data-color="rgba(30, 136, 229, 0.6)"
>important</mark
>
```
### Strikethrough
```html
<mark
data-drawing-mode="highlight"
data-height="0.1"
data-offset="0"
data-highlight='{"amplitude":0.1,"wavelength":3}'
data-color="rgba(229, 57, 53, 0.8)"
>wrong answer</mark
>
```
### Circled Annotation
```html
<mark
data-drawing-mode="circle"
data-circle='{"curve":0.8,"wobble":0.4,"loops":2,"thickness":3}'
data-animation-speed="1200"
data-color="rgba(229, 57, 53, 0.6)"
>this one</mark
>
```
## Full Example in a Composition
```html
<div data-composition-id="highlight-demo" data-width="1920" data-height="1080">
<div
id="content"
style="
position: absolute; inset: 0;
display: flex; align-items: center; justify-content: center;
font-family: 'Inter', sans-serif; font-size: 72px; color: #fff;
background: #111;
"
>
<p id="hero">
The <mark id="m1" data-color="rgba(255, 220, 50, 0.5)">fastest</mark> way to
<mark
id="m2"
data-drawing-mode="circle"
data-circle='{"curve":0.8,"wobble":0.3,"loops":2,"thickness":3}'
data-color="rgba(229, 57, 53, 0.6)"
>ship</mark
>
</p>
</div>
<style>
[data-composition-id="highlight-demo"] mark {
background-color: transparent;
color: inherit;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script src="marker-highlight.global.js"></script>
<script>
window.__timelines = window.__timelines || {};
var tl = gsap.timeline({ paused: true });
// Set colors via data attribute (no visible flash)
document.querySelectorAll("mark[data-color]").forEach(function (m) {
m.setAttribute("data-original-bgcolor", m.getAttribute("data-color"));
});
// Init once after fonts, then hide all canvases
var hl;
document.fonts.ready.then(function () {
setTimeout(function () {
hl = new MarkerHighlighter(document.getElementById("hero"), {
animate: false,
animationSpeed: 800,
padding: 0.3,
highlight: { amplitude: 0.3, wavelength: 5 },
});
setTimeout(function () {
document.querySelectorAll(".highlight").forEach(function (d) {
d.style.opacity = "0";
});
}, 100);
}, 50);
});
function addHighlight(markId, time) {
tl.to(
{},
{
duration: 0.001,
onStart: function () {
var mark = document.getElementById(markId);
var ref = mark.getAttribute("data-mark-ref");
if (!ref || !hl) return;
mark.parentElement
.querySelectorAll('.highlight[data-mark-id="' + ref + '"]')
.forEach(function (div) {
var c = div.querySelector("canvas");
if (c) c.getContext("2d").clearRect(0, 0, c.width, c.height);
div.style.opacity = "1";
});
hl.reanimateMark(mark);
},
onReverseComplete: function () {
var mark = document.getElementById(markId);
var ref = mark.getAttribute("data-mark-ref");
if (!ref) return;
mark.parentElement
.querySelectorAll('.highlight[data-mark-id="' + ref + '"]')
.forEach(function (div) {
div.style.opacity = "0";
});
},
},
time,
);
}
gsap.set("#hero", { opacity: 0 });
tl.to("#hero", { opacity: 1, duration: 0.6 }, 0);
addHighlight("m1", 0.8);
addHighlight("m2", 1.6);
window.__timelines["highlight-demo"] = tl;
</script>
</div>
```