Install
That writes one file:compositions/components/oversized-cursor.html.
Paste it into your composition
Opencompositions/components/oversized-cursor.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 |
|---|---|---|---|
cursor_variant | light | light, dark | |
target_x | 55 | 15% to 85%, step 1% | |
target_y | 55 | 15% to 85%, step 1% | |
click_label | Generate | string | |
exit | none | none, fade, up | Optional whole-stage departure. Default none: the ignited target stays until the frame cuts. |
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:
<div
data-composition-id="oversized-cursor"
data-composition-src="compositions/components/oversized-cursor.html"
data-variable-values='{"cursor_variant":"light","target_x":55,"target_y":55,"click_label":"Generate","exit":"none"}'
></div>
Source
oversized-cursor.html
oversized-cursor.html
<!doctype html>
<!--
Oversized Cursor (actor primitive), mountable sub-composition
concept: a deliberately oversized macOS-style pointer that enters the frame
from off-screen, travels in one continuous glide to a target, taps it (the
click ignites a visible reaction on the target itself), drifts aside during
the dwell, then exits off-screen. One mechanic: a pointer-driven click that
visibly causes something. Family: pointers. Profile: interaction.
compiled-from: spec ready (oversized-cursor SKILL.md, ~/Downloads/oversized-cursor);
actor file mechanics compose (size/look, entry law, tip-targeting, click-ignition,
exit law).
use-when: kicking off a UI scene, igniting a morph/transition/typing run with a
causal click, or carrying the eye across a scene that would otherwise read as
static or stale.
mount contract: this file is a HyperFrames sub-composition, loaded by a host via
data-composition-src, never opened standalone. The runtime clones ONLY the
<template> contents into the host slot (see
skills/hyperframes-core/references/sub-compositions.md); everything outside
<template>, including this <head>, is discarded at render. The root is #root:
elastic, no data-width/data-height declared here, it fills whatever box the host
clip gives it (position:absolute; inset:0). See demo.html for the mount usage
(data-composition-src="./oversized-cursor.html" on a sized host clip).
variables:
cursor_variant (enum light|dark) pointer fill/stroke pairing, pick per scene contrast
target_x, target_y (number, percent of the HOST box) the tip's landing point
click_label (string) label on the target the cursor clicks
exit (enum none|fade|up, default none) optional whole-stage departure during
OUT; the cursor's own off-screen exit always plays (it is the mechanic)
envelope (of data-composition-duration, default 4.2s):
IN 0.00s to 1.17s (fixed) off-screen entry glide (0.85s) then click tap (0.32s)
HOLD 1.17s to D-0.60s (elastic) click ignition plays, cursor drifts aside, then dwells
OUT D-0.60s to D (fixed) cursor accelerates off-screen; with exit fade|up the
whole stage (target included) also departs
sound: one click tap SFX (soft UI click) fires at the click moment, the fixed
offset into IN documented below as SYNC POINT. This file never plays audio: it
only marks the cue's timing for the mix stage to pick up.
-->
<html
lang="en"
data-composition-id="oversized-cursor"
data-composition-duration="4.2"
data-composition-variables='[
{ "id": "cursor_variant", "type": "enum", "role": "style", "label": "Cursor fill", "default": "light", "options": [ { "value": "light", "label": "Light (white body)" }, { "value": "dark", "label": "Dark (near-black body)" } ] },
{ "id": "target_x", "type": "number", "role": "layout", "label": "Target X", "default": 55, "min": 15, "max": 85, "step": 1, "unit": "%" },
{ "id": "target_y", "type": "number", "role": "layout", "label": "Target Y", "default": 55, "min": 15, "max": 85, "step": 1, "unit": "%" },
{ "id": "click_label", "type": "string", "role": "content", "label": "Click label", "default": "Generate" },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional whole-stage departure. Default none: the ignited target stays until the frame cuts.", "default": "none", "options": [ { "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Up" } ] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Oversized Cursor</title>
<!-- head is metadata for the source file only; the runtime discards it on mount -->
</head>
<body>
<template>
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
/* Root is styled by #root, never a class: the compositor scopes CSS to
[data-composition-id="oversized-cursor"] as a descendant selector, so a
rule keyed on the root's own class would never match the root itself.
Elastic and position-blind: fills whatever box the host slot gives it
(inset:0), owns its own stacking + container-query context, no
position:fixed anywhere in this file. */
#root {
position: absolute;
inset: 0;
overflow: hidden;
isolation: isolate;
container-type: size;
container-name: oc-stage;
font-family: var(--font-body, Inter, system-ui, sans-serif);
}
.oc-stage {
position: absolute;
inset: 0;
/* Transparent by design: this actor overlays whatever scene it is
dropped into. demo.html supplies its own backdrop around it. */
}
/* EDIT ZONE: target look. Safe to retheme via tokens; keep it a simple,
legible UI surface so the click reaction reads clearly. */
.oc-target {
position: absolute;
left: 50%;
top: 50%;
display: flex;
align-items: center;
gap: var(--space-1, 0.6cqw);
padding: var(--space-2, 1.5cqw) var(--space-3, 2.4cqw);
border-radius: var(--radius, 999px);
border: 1px solid var(--border, rgba(148, 163, 184, 0.35));
background: color-mix(
in srgb,
var(--surface, #f8fafc) calc((1 - var(--oc-ignite, 0)) * 100%),
var(--brand, #52525b) calc(var(--oc-ignite, 0) * 100%)
);
box-shadow: 0 1.2cqw 3.2cqw rgba(2, 6, 23, 0.24);
--oc-ignite: 0;
z-index: 10;
}
.oc-target-check {
width: 1.8cqw;
height: 1.8cqw;
min-width: 14px;
min-height: 14px;
flex: none;
fill: none;
stroke: var(--fg, #ffffff);
stroke-width: 2.4;
stroke-linecap: round;
stroke-linejoin: round;
opacity: clamp(0, var(--oc-ignite, 0), 1);
transform: scale(calc(0.5 + var(--oc-ignite, 0) * 0.5));
}
.oc-target-label {
font-size: clamp(12px, 1.7cqw, 30px);
font-weight: 650;
line-height: 1;
white-space: nowrap;
color: color-mix(
in srgb,
var(--fg, #0f172a) calc((1 - var(--oc-ignite, 0)) * 100%),
var(--fg, #ffffff) calc(var(--oc-ignite, 0) * 100%)
);
}
/* INVARIANT: cursor size floor is 7cqw for full-frame scenes (house
convention). Never go smaller; an actual-size cursor disappears at
video scale. */
.oc-cursor {
position: absolute;
left: 0;
top: 0;
width: 7cqw;
height: 7cqw;
min-width: 34px;
min-height: 34px;
pointer-events: none;
will-change: transform;
filter: drop-shadow(0 4px 6px rgba(0, 0, 0, 0.3));
z-index: 20;
}
.oc-cursor-svg {
display: block;
width: 100%;
height: 100%;
stroke-linejoin: round;
stroke-linecap: round;
}
/* EDIT ZONE: the two house-convention fill pairings. Pick per scene
contrast, keep it constant per film. */
.oc-cursor[data-variant="light"] .oc-cursor-svg {
fill: #ffffff;
stroke: #141414;
stroke-width: 1.4;
}
.oc-cursor[data-variant="dark"] .oc-cursor-svg {
fill: #1c1c1c;
stroke: #ffffff;
stroke-width: 1.4;
}
/* INVARIANT: ripple anchors to the arrow's tip point (21%, 14% of the
cursor box), the same point transformOrigin pivots the click tap on. */
.oc-ripple {
position: absolute;
left: 21%;
top: 14%;
width: 2.6cqw;
height: 2.6cqw;
min-width: 12px;
min-height: 12px;
border-radius: 999px;
border: 0.24cqw solid color-mix(in srgb, var(--fg, #ffffff) 45%, transparent);
transform: translate(-50%, -50%);
opacity: 0;
}
</style>
<div id="root" data-composition-id="oversized-cursor" data-duration="4.2">
<div class="oc-stage">
<div class="oc-target" id="oc-target">
<svg class="oc-target-check" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
<span class="oc-target-label" id="oc-target-label"></span>
</div>
<div class="oc-cursor" id="oc-cursor" data-variant="light" aria-hidden="true">
<div class="oc-ripple"></div>
<svg class="oc-cursor-svg" viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 3 L5 19 L9 15 L12 22 L15 20.5 L11.5 14 L18 14 Z" />
</svg>
</div>
</div>
</div>
<script>
(function () {
"use strict";
// NOTE: once mounted, document.documentElement is the HOST page's
// root element, not this file's own (the mount contract discards
// this file's head and html elements after the loader reads declared
// variables/duration once, before cloning). html.getAttribute(...)
// below therefore resolves to null under mount, and every read
// falls through to its inline default (55 / 55 / "Generate" /
// "light"), which match this file's own declared defaults above.
var html = document.documentElement;
var root = document.getElementById("root");
var cursor = document.getElementById("oc-cursor");
var ripple = cursor.querySelector(".oc-ripple");
var target = document.getElementById("oc-target");
var targetLabel = document.getElementById("oc-target-label");
// Declared defaults, then the render/preview engine's override object.
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 hardcoded defaults below */
}
// Under mount, the runtime resolves this file's own declared
// variables merged with the host clip's per-instance
// data-variable-values through a scoped window.__hyperframes,
// shadowed in just for this file's script. window.__hfVariables
// stays in the merge too, as a raw fallback for the (non-mount)
// case where this file is driven by an older render/preview path.
var scopedVariables = {};
try {
if (window.__hyperframes && typeof window.__hyperframes.getVariables === "function") {
scopedVariables = window.__hyperframes.getVariables() || {};
}
} catch (error) {
/* no scoped variables API in this context, fall through */
}
var vars = Object.assign({}, DEFAULTS, window.__hfVariables || {}, scopedVariables);
function clampPercent(value, fallback) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.max(0, Math.min(100, n));
}
// Hardcoded per the mount contract: deriving the id from the document
// returns null once the compositor rewrites the wrapper's attributes,
// which registers the timeline under "null" and breaks seek binding.
var compositionId = "oversized-cursor";
var duration = Math.max(
0.001,
parseFloat(
root.dataset.duration || html.getAttribute("data-composition-duration") || "4.2",
),
);
var variant = vars.cursor_variant === "dark" ? "dark" : "light";
var targetX = clampPercent(vars.target_x, 55);
var targetY = clampPercent(vars.target_y, 55);
var clickLabel = String(vars.click_label || "Generate");
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
var stage = root.querySelector(".oc-stage");
cursor.setAttribute("data-variant", variant);
targetLabel.textContent = clickLabel;
target.style.left = targetX + "%";
target.style.top = targetY + "%";
// Motion runs on transforms only (x/y px), never left/top: layout
// props snap to integer device pixels under the seek-by-frame
// capture engine and stutter on slow/eased motion. Stage size is
// read once, synchronously, at load (deterministic: fixed viewport
// per render, no resize mid-render) to convert the percent-based
// target/off-screen positions (percent of the HOST box, not a fixed
// 1920 stage) into the px x/y GSAP needs.
var stageW = root.clientWidth || 1920;
var stageH = root.clientHeight || 1080;
function xAt(percent) {
return (percent / 100) * stageW;
}
function yAt(percent) {
return (percent / 100) * stageH;
}
// Tip-targeting: the arrow's visual tip sits at (21%, 14%) inside the
// cursor's own box. Anchor the box by that offset once, up front, so
// every later x/y tween places the TIP (not the box corner) at the
// given stage position. Never tween xPercent/yPercent.
gsap.set(cursor, { xPercent: -21, yPercent: -14 });
gsap.set(target, { xPercent: -50, yPercent: -50, scale: 1 });
// RETIME RANGE: IN/OUT are fixed durations (house convention: entry
// glide 0.4-0.92s + click tap 0.32s). They only shrink, proportionally,
// if the composition duration is too short to hold IN + OUT at all.
var IN_BASE = 1.17; // 0.85s glide + 0.1s compress + 0.22s expand
var OUT_BASE = 0.6;
var totalBase = IN_BASE + OUT_BASE;
var IN = IN_BASE;
var OUT = OUT_BASE;
if (duration < totalBase) {
var shrink = duration / totalBase;
IN = IN_BASE * shrink;
OUT = OUT_BASE * shrink;
}
var HOLD = Math.max(0, duration - (IN + OUT));
var OUT_AT = IN + HOLD;
var shrinkFactor = IN / IN_BASE;
var glideDur = 0.85 * shrinkFactor;
var clickInDur = 0.1 * shrinkFactor;
var clickOutDur = 0.22 * shrinkFactor;
// SYNC POINT: click moment (entry settle), a fixed offset into IN,
// never inside the elastic HOLD.
var CLICK_AT = glideDur;
var offX = xAt(48);
var offY = yAt(116); // resting pose IS off-screen, below the stage
var tl = gsap.timeline({ paused: true });
// ---- IN: off-screen entry, one continuous vector, then the tap ----
tl.set(cursor, { scale: 1 });
tl.set(ripple, { opacity: 0, scale: 0.4 });
tl.set(target, { "--oc-ignite": 0 });
tl.fromTo(
cursor,
{ x: offX, y: offY },
{ x: xAt(targetX), y: yAt(targetY), duration: glideDur, ease: "power3.out" },
0,
);
tl.to(
cursor,
{ scale: 0.84, duration: clickInDur, ease: "power2.in", transformOrigin: "21% 14%" },
CLICK_AT,
);
tl.to(
cursor,
{ scale: 1, duration: clickOutDur, ease: "power2.out", transformOrigin: "21% 14%" },
CLICK_AT + clickInDur,
);
// ---- the click ignites the target: parallel reaction, same frame ----
tl.to(target, { scale: 0.94, duration: clickInDur, ease: "power2.in" }, CLICK_AT);
tl.to(
target,
{ scale: 1, duration: clickOutDur, ease: "power2.out" },
CLICK_AT + clickInDur,
);
tl.fromTo(
ripple,
{ opacity: 0.85, scale: 0.4 },
{ opacity: 0, scale: 2.4, duration: 0.42, ease: "power2.out" },
CLICK_AT + clickInDur,
);
tl.to(
target,
{ "--oc-ignite": 1, duration: 0.34, ease: "back.out(2.2)" },
CLICK_AT + clickInDur,
);
// ---- HOLD (elastic): drift aside once, then dwell, never wobble ----
var driftDelay = Math.min(0.22, HOLD * 0.3);
var driftDur = Math.max(0, Math.min(0.7, HOLD - driftDelay));
if (driftDur > 0.05) {
var asideX = clampPercent(targetX + (targetX < 50 ? 16 : -16), targetX);
var asideY = clampPercent(targetY + 10, targetY);
tl.to(
cursor,
{
x: xAt(asideX),
y: yAt(asideY),
duration: driftDur,
ease: "power2.out",
overwrite: "auto",
},
IN + driftDelay,
);
}
// ---- OUT: leave the frame, physically, never a fade-in-place ----
tl.to(
cursor,
{ y: yAt(120), duration: OUT, ease: "power2.in", overwrite: "auto" },
OUT_AT,
);
// Optional whole-stage departure on top of the cursor's own exit;
// exit none leaves the ignited target until the frame cuts.
if (exit === "fade") {
tl.to(stage, { opacity: 0, duration: OUT, ease: "power2.in" }, OUT_AT);
} else if (exit === "up") {
tl.to(stage, { opacity: 0, y: "-4cqh", duration: OUT, ease: "power2.in" }, OUT_AT);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines[compositionId] = tl;
})();
</script>
</template>
</body>
</html>
motion-primitive actor cursor pointer interaction.