Files
Ular Kimsanov a06fdfe26e docs(catalog): put each item page's title, description and Docs text in the server HTML (#6711)
* docs(catalog): put each item page's title, description and Docs text in the server HTML

Mintlify renders snippet components in the browser only, so everything inside
CatalogDetail (title, standfirst, the Docs tab) was missing from the HTML search
engines read: 448 catalog pages served Googlebot no body text. The generator now
writes the H1 and standfirst as page markup above the component and the Docs tab
as page markup after it, shown with that tab (docs/custom.css). The component no
longer draws its own title. Pages patched structurally only; registry drift on 14
pages is left for a regeneration of its own.

* docs(catalog): keep the title's top spacing the component gave it

* fix(docs): catalog titles and descriptions keep markdown markers literal

MDX reads text inside the static <h1> and <p> as inline markdown, so @_blue_sweater_
rendered as italic blue_sweater. mdxText now writes backslash, backtick, * _ [ ] ~ as
character references too, with a unit test; the four affected pages are re-escaped.
2026-10-30 15:48:25 +00:00

583 lines
23 KiB
Plaintext

---
title: "Annotate Cursor"
description: "A marker pen glides onto the frame and hand-draws a loose loop, an underline, or an arrow around a region, the ink laid exactly under its nib, then lifts and leaves."
mode: "frame"
---
import { CatalogDetail, CatalogSlot } from "/snippets/catalog-detail.jsx";
import { InstallCommand } from "/snippets/install-command.jsx";
<div className="hf-ve-static-head not-prose">
<h1>Annotate Cursor</h1>
<p>A marker pen glides onto the frame and hand-draws a loose loop, an underline, or an arrow around a region, the ink laid exactly under its nib, then lifts and leaves.</p>
</div>
<CatalogDetail
previewSrc="/public/catalog/components/cursor-annotate.json"
compositionId="cursor-annotate"
compositionSrc="compositions/components/cursor-annotate.html"
title="Annotate Cursor"
variables={[{"id":"shape","type":"enum","role":"style","label":"Mark","default":"circle","options":[{"value":"circle","label":"Loose loop"},{"value":"underline","label":"Underline"},{"value":"arrow","label":"Arrow"}]},{"id":"color","type":"color","role":"style","label":"Ink","default":"#f43f5e"},{"id":"target_x","type":"number","role":"layout","label":"Region left","default":40,"min":0,"max":95,"step":1,"unit":"%"},{"id":"target_y","type":"number","role":"layout","label":"Region top","default":38,"min":0,"max":95,"step":1,"unit":"%"},{"id":"target_w","type":"number","role":"layout","label":"Region width","default":20,"min":2,"max":90,"step":1,"unit":"%"},{"id":"target_h","type":"number","role":"layout","label":"Region height","default":16,"min":2,"max":90,"step":1,"unit":"%"}]}
meta={{"category":"Cursors","badge":"Stable","codeLines":494}}
attribution={{"path":"registry/components/cursor-annotate","tags":["cursor","motion-primitive","pen","annotation","draw-on","highlight"]}}
hasCode
rawUrl="https://raw.githubusercontent.com/heygen-com/hyperframes/main/registry/components/cursor-annotate/cursor-annotate.html"
>
<CatalogSlot slot="code">
```html cursor-annotate.html
<!doctype html>
<!--
Annotate Cursor (actor primitive), mountable sub-composition
concept: a marker pen glides to a region of the host UI, touches down and hand-draws
one annotation around it (a loose loop, an underline, or an arrow), then lifts and
leaves. The ink is revealed exactly under the nib: both read the same path length.
mount contract: loaded by a host through data-composition-src; only <template> is
cloned. #root fills the host clip (inset:0). See demo.html.
variables:
shape (circle|underline|arrow) the mark the pen draws
color marker ink
target_x, target_y, target_w, target_h (% of host box) the region being marked
envelope (default 2.95s): glide in 0-0.5s, touch down, draw 0.7-1.8s, lift and step
clear of the mark, hold, exit in the last 0.35s. Shorter durations compress every beat evenly.
The ink is a tapered marker stroke: thick where the pen moves slowly, thin where it is fast.
-->
<html
lang="en"
data-composition-id="cursor-annotate"
data-composition-duration="2.95"
data-composition-variables='[
{ "id": "shape", "type": "enum", "role": "style", "label": "Mark", "default": "circle", "options": [ { "value": "circle", "label": "Loose loop" }, { "value": "underline", "label": "Underline" }, { "value": "arrow", "label": "Arrow" } ] },
{ "id": "color", "type": "color", "role": "style", "label": "Ink", "default": "#f43f5e" },
{ "id": "target_x", "type": "number", "role": "layout", "label": "Region left", "default": 40, "min": 0, "max": 95, "step": 1, "unit": "%" },
{ "id": "target_y", "type": "number", "role": "layout", "label": "Region top", "default": 38, "min": 0, "max": 95, "step": 1, "unit": "%" },
{ "id": "target_w", "type": "number", "role": "layout", "label": "Region width", "default": 20, "min": 2, "max": 90, "step": 1, "unit": "%" },
{ "id": "target_h", "type": "number", "role": "layout", "label": "Region height", "default": 16, "min": 2, "max": 90, "step": 1, "unit": "%" }
]'
>
<head>
<meta charset="UTF-8" />
<title>Annotate Cursor</title>
</head>
<body>
<template>
<style>
#root {
position: absolute;
inset: 0;
overflow: hidden;
isolation: isolate;
pointer-events: none;
}
.ca-ink {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
}
.ca-ink path {
fill: var(--ca-ink, #f43f5e);
}
.ca-pen {
position: absolute;
left: 0;
top: 0;
width: 0;
height: 0;
will-change: transform;
}
/* The nib sits at the svg origin, so the wrapper's translate places the nib. */
.ca-pen svg {
position: absolute;
left: -40px;
top: -200px;
width: 240px;
height: 240px;
overflow: visible;
}
</style>
<div id="root" data-composition-id="cursor-annotate" data-duration="2.95">
<svg class="ca-ink" id="ca-ink" aria-hidden="true"></svg>
<div class="ca-pen" id="ca-pen" aria-hidden="true">
<svg viewBox="-40 -200 240 240" aria-hidden="true">
<g id="ca-pen-body">
<g transform="rotate(-50)">
<path d="M0 0 L15 -5.5 L15 5.5 Z" fill="#1d1d22" />
<path id="ca-pen-tip" d="M3 -1.4 L15 -5.5 L15 5.5 L3 1.4 Z" />
<rect x="15" y="-7.5" width="14" height="15" rx="2" fill="#2a2a31" />
<rect
x="29"
y="-9"
width="62"
height="18"
rx="4"
fill="#ffffff"
stroke="#141414"
stroke-width="2"
/>
<rect x="36" y="-9" width="8" height="18" id="ca-pen-band" />
<rect x="91" y="-9.5" width="16" height="19" rx="6" fill="#1d1d22" />
</g>
</g>
</svg>
</div>
</div>
<script>
(function () {
"use strict";
var html = document.documentElement;
var root = document.getElementById("root");
var ink = document.getElementById("ca-ink");
var pen = document.getElementById("ca-pen");
var penBody = document.getElementById("ca-pen-body");
var SVG_NS = "http://www.w3.org/2000/svg";
var DEFAULTS = {};
try {
JSON.parse(html.getAttribute("data-composition-variables") || "[]").forEach(
function (variable) {
DEFAULTS[variable.id] = variable.default;
},
);
} catch (error) {
/* malformed declaration falls back to the inline defaults below */
}
var scopedVariables = {};
try {
if (window.__hyperframes && typeof window.__hyperframes.getVariables === "function") {
scopedVariables = window.__hyperframes.getVariables() || {};
}
} catch (error) {
/* no scoped variables API in this context */
}
var vars = Object.assign({}, DEFAULTS, window.__hfVariables || {}, scopedVariables);
function num(value, fallback, lo, hi) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.max(lo, Math.min(hi, n));
}
// Hardcoded: the compositor rewrites the wrapper's attributes on mount.
var compositionId = "cursor-annotate";
var duration = Math.max(
0.001,
parseFloat(
root.dataset.duration || html.getAttribute("data-composition-duration") || "2.95",
),
);
var shape =
["circle", "underline", "arrow"].indexOf(vars.shape) >= 0 ? vars.shape : "circle";
var color = /^#[0-9a-f]{3,8}$/i.test(String(vars.color)) ? vars.color : "#f43f5e";
var W = root.clientWidth || 1920;
var H = root.clientHeight || 1080;
var unit = W / 1920;
var box = {
x: (num(vars.target_x, 40, 0, 100) / 100) * W,
y: (num(vars.target_y, 38, 0, 100) / 100) * H,
w: (num(vars.target_w, 20, 1, 100) / 100) * W,
h: (num(vars.target_h, 16, 1, 100) / 100) * H,
};
ink.setAttribute("viewBox", "0 0 " + W + " " + H);
root.style.setProperty("--ca-ink", color);
document.getElementById("ca-pen-tip").setAttribute("fill", color);
document.getElementById("ca-pen-band").setAttribute("fill", color);
// Each mark is one or more strokes; a stroke maps u in [0,1] to a point on the page.
// A hand loop: starts upper-left, runs a little past its start and wider on the way out.
function loopStroke() {
var cx = box.x + box.w / 2;
var cy = box.y + box.h / 2;
var rx = box.w * 0.62 + 18 * unit;
var ry = box.h * 0.66 + 16 * unit;
var tilt = (-5 * Math.PI) / 180;
return [
function (u) {
var a = -2.55 + (Math.PI * 2 + 0.5) * u;
var r = (1 + 0.035 * Math.sin(3 * a + 1.1)) * (1 + 0.08 * u);
var ex = Math.cos(a) * rx * r;
var ey = Math.sin(a) * ry * r;
return [
cx + ex * Math.cos(tilt) - ey * Math.sin(tilt),
cy + ex * Math.sin(tilt) + ey * Math.cos(tilt),
];
},
];
}
function underlineStroke() {
var y = box.y + box.h + 14 * unit;
var x0 = box.x - box.w * 0.04;
var span = box.w * 1.08;
return [
function (u) {
return [
x0 + span * u,
y + 4 * unit * Math.sin(u * Math.PI * 1.3) - 12 * unit * u * u,
];
},
];
}
function smooth(x) {
return x * x * (3 - 2 * x);
}
function lerp(a, b, u) {
return [a[0] + (b[0] - a[0]) * u, a[1] + (b[1] - a[1]) * u];
}
// Shaft from below-left, curving onto the region; the head is one V that slows into its point.
function arrowStrokes() {
var tip = [box.x - 10 * unit, box.y + box.h + 8 * unit];
var from = [
tip[0] - Math.max(160 * unit, box.w * 0.7),
tip[1] + Math.max(120 * unit, box.h * 0.9),
];
var ctrl = [from[0] + (tip[0] - from[0]) * 0.15, tip[1] + (from[1] - tip[1]) * 0.05];
var back = Math.atan2(ctrl[1] - tip[1], ctrl[0] - tip[0]);
var len = 30 * unit;
var barb = function (spread) {
return [
tip[0] + Math.cos(back + spread) * len,
tip[1] + Math.sin(back + spread) * len,
];
};
return [
function (u) {
var v = 1 - u;
return [
v * v * from[0] + 2 * v * u * ctrl[0] + u * u * tip[0],
v * v * from[1] + 2 * v * u * ctrl[1] + u * u * tip[1],
];
},
function (u) {
return u < 0.5
? lerp(barb(0.5), tip, smooth(u * 2))
: lerp(tip, barb(-0.5), smooth(u * 2 - 1));
},
];
}
var paths =
shape === "underline"
? underlineStroke()
: shape === "arrow"
? arrowStrokes()
: loopStroke();
// Beats at the default 2.95s; a shorter composition compresses them evenly.
var squeeze = Math.min(1, duration / 2.95);
var T_LAND = 0.5 * squeeze;
var T_DRAW = 0.7 * squeeze;
var HOP = 0.2 * squeeze;
var ASIDE_TIME = 0.35 * squeeze;
var EXIT_TIME = 0.35 * squeeze;
var drawTotal = (shape === "circle" ? 1.1 : shape === "arrow" ? 0.95 : 0.6) * squeeze;
var glide = gsap.parseEase("power3.out");
var strokeEase = gsap.parseEase("sine.inOut");
var hop = gsap.parseEase("power2.inOut");
var aside = gsap.parseEase("power2.out");
var leave = gsap.parseEase("power2.in");
function clamp01(u) {
return u < 0 ? 0 : u > 1 ? 1 : u;
}
function arcLength(fn) {
var sum = 0;
var prev = fn(0);
for (var i = 1; i <= 200; i++) {
var p = fn(i / 200);
sum += Math.hypot(p[0] - prev[0], p[1] - prev[1]);
prev = p;
}
return sum;
}
// Samples at even steps of stroke time, so spacing between samples is the pen's speed.
// Width follows speed (7u slow, 3u fast) and thins back to 3u as the pen lifts.
var STEPS = 160;
var MIN_W = 3 * unit;
var MAX_W = 7 * unit;
var strokes = paths.map(function (fn) {
var pts = [];
for (var k = 0; k <= STEPS; k++) {
var u = strokeEase(k / STEPS);
var p = fn(u);
var a = fn(Math.max(0, u - 1e-4));
var b = fn(Math.min(1, u + 1e-4));
var tlen = Math.hypot(b[0] - a[0], b[1] - a[1]) || 1;
pts.push({ x: p[0], y: p[1], nx: -(b[1] - a[1]) / tlen, ny: (b[0] - a[0]) / tlen });
}
pts.forEach(function (p, k) {
var a = pts[Math.max(0, k - 1)];
var b = pts[Math.min(STEPS, k + 1)];
p.speed = Math.hypot(b.x - a.x, b.y - a.y);
});
return { pts: pts, length: arcLength(fn) };
});
var topSpeed = 0;
strokes.forEach(function (s) {
s.pts.forEach(function (p) {
topSpeed = Math.max(topSpeed, p.speed);
});
});
strokes.forEach(function (s) {
s.pts.forEach(function (p, k) {
var lift = 1 - smooth(clamp01((k / STEPS - 0.82) / 0.18));
p.w = MIN_W + (MAX_W - MIN_W) * (1 - p.speed / topSpeed) * lift;
});
s.el = document.createElementNS(SVG_NS, "path");
ink.appendChild(s.el);
});
var totalLength = strokes.reduce(function (sum, s) {
return sum + s.length;
}, 0);
var windows = [];
var cursorT = T_DRAW;
strokes.forEach(function (s, i) {
var span = (drawTotal - HOP * (strokes.length - 1)) * (s.length / totalLength);
windows.push({ start: cursorT, end: cursorT + span });
cursorT += span + (i < strokes.length - 1 ? HOP : 0);
});
var T_LIFT = cursorT;
var T_EXIT = Math.max(T_LIFT + ASIDE_TIME, duration - EXIT_TIME);
// The stroke as drawn by stroke-time f: its samples up to f, the last one interpolated.
function drawnTo(s, f) {
var x = clamp01(f) * STEPS;
var k = Math.floor(x);
var out = s.pts.slice(0, k + 1);
if (k < STEPS && x > k) {
var a = s.pts[k];
var b = s.pts[k + 1];
var m = x - k;
out.push({
x: a.x + (b.x - a.x) * m,
y: a.y + (b.y - a.y) * m,
nx: a.nx + (b.nx - a.nx) * m,
ny: a.ny + (b.ny - a.ny) * m,
w: a.w + (b.w - a.w) * m,
});
}
return out;
}
// Filled outline of a variable-width stroke with round ends.
function outline(pts) {
if (pts.length < 2) return "";
function side(p, dir) {
var h = (p.w / 2) * dir;
return (p.x + p.nx * h).toFixed(1) + " " + (p.y + p.ny * h).toFixed(1);
}
function cap(p) {
var r = (p.w / 2).toFixed(2);
return " A" + r + " " + r + " 0 0 0 ";
}
var last = pts[pts.length - 1];
var d = "M" + side(pts[0], 1);
for (var i = 1; i < pts.length; i++) d += " L" + side(pts[i], 1);
d += cap(last) + side(last, -1);
for (var j = pts.length - 2; j >= 0; j--) d += " L" + side(pts[j], -1);
return d + cap(pts[0]) + side(pts[0], 1) + " Z";
}
function nibOf(s, f) {
var pts = drawnTo(s, f);
var p = pts[pts.length - 1];
return { x: p.x, y: p.y };
}
var first = nibOf(strokes[0], 0);
var endPoint = nibOf(strokes[strokes.length - 1], 1);
// After the lift the nib steps straight away from the region until it clears the mark
// by 40u, then keeps going that way off the frame.
var inkBox = { l: box.x, t: box.y, r: box.x + box.w, b: box.y + box.h };
strokes.forEach(function (s) {
s.pts.forEach(function (p) {
inkBox.l = Math.min(inkBox.l, p.x);
inkBox.t = Math.min(inkBox.t, p.y);
inkBox.r = Math.max(inkBox.r, p.x);
inkBox.b = Math.max(inkBox.b, p.y);
});
});
var away = { x: endPoint.x - (box.x + box.w / 2), y: endPoint.y - (box.y + box.h / 2) };
var awayLen = Math.hypot(away.x, away.y) || 1;
away = { x: away.x / awayLen, y: away.y / awayLen };
function reach(p, l, t, r, b) {
var hits = [];
if (away.x > 1e-6) hits.push((r - p.x) / away.x);
if (away.x < -1e-6) hits.push((l - p.x) / away.x);
if (away.y > 1e-6) hits.push((b - p.y) / away.y);
if (away.y < -1e-6) hits.push((t - p.y) / away.y);
return Math.max(0, Math.min.apply(null, hits));
}
var clear = 40 * unit;
var step = reach(
endPoint,
inkBox.l - clear,
inkBox.t - clear,
inkBox.r + clear,
inkBox.b + clear,
);
var ASIDE = {
x: Math.max(40 * unit, Math.min(W - 100 * unit, endPoint.x + away.x * step)),
y: Math.max(100 * unit, Math.min(H - 40 * unit, endPoint.y + away.y * step)),
};
var off = reach(ASIDE, 0, 0, W, H) + 180 * unit;
var END = { x: ASIDE.x + away.x * off, y: ASIDE.y + away.y * off };
var START = { x: first.x + 0.3 * W, y: H * 1.25 };
function mix(a, b, u) {
return { x: a.x + (b.x - a.x) * u, y: a.y + (b.y - a.y) * u };
}
// Returns the nib position and how far off the paper it is (0 = touching).
function penAt(t) {
if (t <= T_LAND) return { p: mix(START, first, glide(clamp01(t / T_LAND))), lift: 1 };
if (t <= T_DRAW)
return { p: first, lift: 1 - clamp01((t - T_LAND) / (T_DRAW - T_LAND)) };
for (var i = 0; i < windows.length; i++) {
var w = windows[i];
if (t <= w.end)
return { p: nibOf(strokes[i], (t - w.start) / (w.end - w.start)), lift: 0 };
var next = windows[i + 1];
if (next && t < next.start) {
var u = (t - w.end) / (next.start - w.end);
return {
p: mix(nibOf(strokes[i], 1), nibOf(strokes[i + 1], 0), hop(u)),
lift: Math.sin(Math.PI * u),
};
}
}
if (t <= T_EXIT) {
var a = clamp01((t - T_LIFT) / ASIDE_TIME);
return { p: mix(endPoint, ASIDE, aside(a)), lift: Math.min(1, a * 2.5) };
}
return {
p: mix(ASIDE, END, leave(clamp01((t - T_EXIT) / (duration - T_EXIT)))),
lift: 1,
};
}
function render(t) {
strokes.forEach(function (s, i) {
var w = windows[i];
s.el.setAttribute("d", outline(drawnTo(s, (t - w.start) / (w.end - w.start))));
});
var state = penAt(t);
var lift = state.lift;
pen.style.transform =
"translate(" + state.p.x.toFixed(2) + "px," + state.p.y.toFixed(2) + "px)";
penBody.setAttribute(
"transform",
"scale(" +
((0.85 + 0.07 * lift) * unit).toFixed(4) +
") rotate(" +
(-4 * lift).toFixed(2) +
")",
);
pen.style.filter =
"drop-shadow(0 " +
((3 + 7 * lift) * unit).toFixed(1) +
"px " +
((4 + 8 * lift) * unit).toFixed(1) +
"px rgba(0,0,0," +
(0.3 - 0.1 * lift).toFixed(3) +
"))";
}
var clock = { t: 0 };
var tl = gsap.timeline({ paused: true });
tl.to(
clock,
{
t: duration,
duration: duration,
ease: "none",
onUpdate: function () {
render(clock.t);
},
},
0,
);
render(0);
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</template>
</body>
</html>
```
</CatalogSlot>
<CatalogSlot slot="install">
<InstallCommand command="npx hyperframes add cursor-annotate" item="cursor-annotate" />
That writes one file: `compositions/components/cursor-annotate.html`.
</CatalogSlot>
</CatalogDetail>
<div data-static-slot="docs" className="prose prose-gray dark:prose-invert">
## Paste it into your composition
Open `compositions/components/cursor-annotate.html` and copy what is inside into your own composition.
A component has no size or duration of its own. It takes both from the composition
you paste it into.
## Variables
Every one of these has a default, so the piece works untouched. Set the ones you
want to change on the element:
| Variable | Default | Accepts | What it does |
| --- | --- | --- | --- |
| `shape` | `circle` | `circle`, `underline`, `arrow` | |
| `color` | `#f43f5e` | color | |
| `target_x` | `40` | 0% to 95%, step 1% | |
| `target_y` | `38` | 0% to 95%, step 1% | |
| `target_w` | `20` | 2% to 90%, step 1% | |
| `target_h` | `16` | 2% to 90%, step 1% | |
Set them with `data-variable-values` on the element that mounts it. These are the
defaults, so this behaves exactly like the preview above until you change one:
```html wrap
<div
data-composition-id="cursor-annotate"
data-composition-src="compositions/components/cursor-annotate.html"
data-variable-values='{"shape":"circle","color":"#f43f5e","target_x":40,"target_y":38,"target_w":20,"target_h":16}'
></div>
```
{/* hf:generated-footer */}
Tagged `cursor` `motion-primitive` `pen` `annotation` `draw-on` `highlight`.
## Related topics
- [Browse the complete Catalog](/catalog)
- [Add assets and Catalog items in the browser editor](/studio/assets-and-blocks)
- [Put blocks together into one video](/concepts/compositions)
</div>