Install
That writes one file:compositions/components/press-ripple.html.
Paste it into your composition
Opencompositions/components/press-ripple.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 |
|---|---|---|---|
label | Get started | string | Text inside the default pill target. |
target_x | 50 | 8% to 92%, step 1% | Zone center, percent of host width. |
target_y | 50 | 8% to 92%, step 1% | Zone center, percent of host height. |
press_at | 1.4 | 0.9s to 10s, step 0.1s | Seconds from mount start when the press begins. |
cursor | light | light, dark | Pointer tone. |
accent | green | green, blue, violet | Contract color for the ripple ink and pressed fill. |
exit | none | none, fade, up | How the frame leaves. None holds the pressed state. |
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="press-ripple"
data-composition-src="compositions/components/press-ripple.html"
data-variable-values='{"label":"Get started","target_x":50,"target_y":50,"press_at":1.4,"cursor":"light","accent":"green","exit":"none"}'
></div>
Source
press-ripple.html
press-ripple.html
<!doctype html>
<!--
press-ripple: HyperFrames video primitive (product demo / payoff)
A cursor decel-arrives from off-stage (power2.out, it arrives, never passes
through), lands a few px off-center on a caller-positioned target zone, the
target and cursor compress in lockstep, release with two ink ripple rings,
then the cursor exits off-stage the way it came. The pressed state and the
ripple aftermath carry the hold; the cursor never camps on the label.
The target is a slot: the element marked data-slot="target" is the press
surface. Its default content is a token-styled pill carrying the label
variable; replace the slot's children to press anything else. The slot
wrapper flips data-state="idle" to data-state="pressed" at the press, so
custom slot content can style its own pressed look.
Variables:
label: text inside the default pill (ignored when the slot is replaced)
target_x, target_y: zone center as a percent of the host box
press_at: seconds from mount start when the compression begins
cursor: light or dark pointer
accent: green, blue, or violet ink (ripple rings + pressed pill fill)
exit: none, fade, or up (default none; holds end films)
Envelope (elastic HOLD, never time-scaled):
IN = entrance settle, cursor arrival, press, release ripple, cursor exit
HOLD = the rest of D, completely still on the pressed state
OUT = none by default; 0.45s fade or fade-up when exit is set
press_at clamps into the window that keeps the whole gesture inside D.
Sync point: the press lands at press_at (default 1.4s).
Mount contract: the runtime clones only this template. #root fills the host
box, establishes the container query basis, has no data-width or data-height,
and registers one paused timeline under the literal press-ripple key.
-->
<html
lang="en"
data-composition-id="press-ripple"
data-composition-duration="3"
data-composition-variables='[
{ "id": "label", "type": "string", "role": "content", "label": "Label", "description": "Text inside the default pill target.", "default": "Get started" },
{ "id": "target_x", "type": "number", "role": "layout", "label": "Target X", "description": "Zone center, percent of host width.", "default": 50, "min": 8, "max": 92, "step": 1, "unit": "%" },
{ "id": "target_y", "type": "number", "role": "layout", "label": "Target Y", "description": "Zone center, percent of host height.", "default": 50, "min": 8, "max": 92, "step": 1, "unit": "%" },
{ "id": "press_at", "type": "number", "role": "timing", "label": "Press at", "description": "Seconds from mount start when the press begins.", "default": 1.4, "min": 0.9, "max": 10, "step": 0.1, "unit": "s" },
{ "id": "cursor", "type": "enum", "role": "style", "label": "Cursor", "description": "Pointer tone.", "default": "light", "options": [{ "value": "light", "label": "Light" }, { "value": "dark", "label": "Dark" }] },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Contract color for the ripple ink and pressed fill.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "style", "label": "Exit", "description": "How the frame leaves. None holds the pressed state.", "default": "none", "options": [{ "value": "none", "label": "None" }, { "value": "fade", "label": "Fade" }, { "value": "up", "label": "Fade up" }] }
]'
>
<head>
<meta charset="UTF-8" />
<title>Press Ripple</title>
</head>
<body>
<template>
<div id="root" data-composition-id="press-ripple" data-duration="3" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
pointer-events: none;
}
.pr-clip {
position: absolute;
inset: 0;
overflow: hidden;
background: var(--bg, #0b0c0e);
}
.pr-stage {
position: absolute;
inset: 0;
}
/* Caller-positioned target zone; --pr-x / --pr-y come from the
target_x / target_y variables. */
.pr-zone {
position: absolute;
left: calc(var(--pr-x, 50) * 1cqw);
top: calc(var(--pr-y, 50) * 1cqh);
transform: translate(-50%, -50%);
}
/* Ink ripple rings, centered under the slot, hidden until release.
The ink mixes the accent toward --fg so the rings carry real
weight against the host background on both light and dark
themes; thickness reads as drawn ink, not a hairline. */
.pr-ring {
position: absolute;
left: 50%;
top: 50%;
width: 20cqmin;
height: 20cqmin;
margin: -10cqmin 0 0 -10cqmin;
border: 0.85cqmin solid
color-mix(in srgb, var(--pr-accent, #71f5a7) 72%, var(--fg, #f8fafc));
border-radius: 50%;
opacity: 0;
will-change: transform, opacity;
}
/* GSAP owns this wrapper's opacity/y (entrance) and scale (press). */
.pr-actor {
position: relative;
will-change: transform, opacity;
}
.pr-pill {
display: inline-flex;
align-items: center;
padding: var(--space-2, 2.6cqmin) calc(var(--space-2, 2.6cqmin) * 2.1);
border: 0.22cqmin solid var(--border, #343a46);
border-radius: 999px;
background: var(--surface, #14171c);
color: var(--fg, #f8fafc);
font-family: var(--font-body, Inter, system-ui, sans-serif);
font-size: 4.6cqmin;
font-weight: 600;
line-height: 1;
letter-spacing: -0.015em;
white-space: nowrap;
}
.pr-target-slot[data-state="pressed"] .pr-pill {
border-color: color-mix(in srgb, var(--pr-accent, #71f5a7) 72%, var(--fg, #f8fafc));
background: var(--pr-accent, #71f5a7);
color: var(--bg, #0b0c0e);
}
/* Pointer: SVG tip sits exactly at this box's top-left corner, so
the box position places the tip itself. GSAP moves it with x/y
transforms only (never left/top: layout props snap to integer
device pixels under the seek-by-frame capture engine) and scales
it about the tip (press). */
.pr-cursor {
position: absolute;
left: 0;
top: 0;
width: 7cqmin;
height: 7cqmin;
transform-origin: 0 0;
filter: drop-shadow(0 0.4cqmin 0.9cqmin rgba(2, 6, 23, 0.45));
will-change: transform, opacity;
}
.pr-cursor svg {
display: block;
width: 100%;
height: 100%;
}
</style>
<div
id="press-ripple-clip"
class="pr-clip clip"
data-start="0"
data-duration="3"
data-track-index="0"
>
<div class="pr-stage">
<div class="pr-zone">
<div class="pr-ring" aria-hidden="true"></div>
<div class="pr-ring" aria-hidden="true"></div>
<div class="pr-actor">
<div class="pr-target-slot" data-slot="target" data-state="idle">
<span class="pr-pill"></span>
</div>
</div>
</div>
<div class="pr-cursor" aria-hidden="true">
<svg viewBox="0 0 20 20">
<path
class="pr-cursor-path"
d="M0.6 0.6 L0.6 14.6 L4.4 11.3 L6.7 16.8 L9.4 15.6 L7.1 10.2 L12.4 9.8 Z"
stroke-width="1.1"
stroke-linejoin="round"
></path>
</svg>
</div>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.14.2/dist/gsap.min.js"></script>
<script>
(function () {
"use strict";
var root = document.getElementById("root");
var stage = root.querySelector(".pr-stage");
var zone = root.querySelector(".pr-zone");
var rings = root.querySelectorAll(".pr-ring");
var actor = root.querySelector(".pr-actor");
var slot = root.querySelector(".pr-target-slot");
var pill = root.querySelector(".pr-pill");
var cursor = root.querySelector(".pr-cursor");
var cursorPath = root.querySelector(".pr-cursor-path");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
function num(value, fallback, min, max) {
var n = Number(value);
if (!isFinite(n)) n = fallback;
return Math.min(max, Math.max(min, n));
}
var labelText =
vars.label == null ? "Get started" : String(vars.label).trim().replace(/\s+/g, " ");
var targetX = num(vars.target_x, 50, 8, 92);
var targetY = num(vars.target_y, 50, 8, 92);
var cursorTone = vars.cursor === "dark" ? "dark" : "light";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
var accentColors = {
green: "var(--brand, #71f5a7)",
blue: "var(--accent, #61a8ff)",
violet: "var(--accent-2, #c5a3ff)",
};
var accent = Object.prototype.hasOwnProperty.call(accentColors, vars.accent)
? vars.accent
: "green";
// The bundler mirrors composition variables as scoped CSS custom
// props, so this unit's own accent variable shadows the contract
// --accent token inside the subtree ("blue" is a valid CSS color
// and would render pure blue). When the shadow is present, fall
// back to the literal contract value instead of var(--accent).
var computedAccent = getComputedStyle(root).getPropertyValue("--accent").trim();
if (
computedAccent === "green" ||
computedAccent === "blue" ||
computedAccent === "violet"
) {
accentColors.blue = "#61a8ff";
}
pill.textContent = labelText;
root.style.setProperty("--pr-accent", accentColors[accent]);
root.style.setProperty("--pr-x", String(targetX));
root.style.setProperty("--pr-y", String(targetY));
if (cursorTone === "dark") {
cursorPath.setAttribute("fill", "#111827");
cursorPath.setAttribute("stroke", "#f8fafc");
} else {
cursorPath.setAttribute("fill", "#f8fafc");
cursorPath.setAttribute("stroke", "#0f172a");
}
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3"));
var OUT = exit === "none" ? 0 : 0.45;
// The whole press tail (compress + release ripple + cursor exit)
// is ~1.05s; keep it inside D, then keep the OUT window after it.
var pressAt = num(vars.press_at, 1.4, 0.9, Math.max(0.9, duration - 1.05 - OUT));
// Human aim: the tip lands a few px off the zone's true center.
var landX = targetX + 0.55;
var landY = targetY + 0.85;
var arriveEnd = pressAt - 0.16;
var arriveStart = Math.max(0, arriveEnd - 0.78);
// 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. Stage size is read once, synchronously, at load
// (deterministic: fixed viewport per render) to convert the
// percent-based land/off-stage positions into px.
var stageW = root.clientWidth || 1920;
var stageH = root.clientHeight || 1080;
function xAt(percent) {
return (percent / 100) * stageW;
}
function yAt(percent) {
return (percent / 100) * stageH;
}
var tl = gsap.timeline({ paused: true });
// IN: target settles in.
tl.fromTo(
actor,
{ opacity: 0, y: "2.6cqh" },
{ opacity: 1, y: 0, duration: 0.5, ease: "power3.out" },
0,
);
// Cursor decel-arrives from off-stage bottom-right. power2.out to
// an exact endpoint: it arrives, it never passes through.
tl.fromTo(
cursor,
{ x: xAt(114), y: yAt(112) },
{
x: xAt(landX),
y: yAt(landY),
duration: arriveEnd - arriveStart,
ease: "power2.out",
},
arriveStart,
);
// Press: target and cursor compress in lockstep (cursor about its
// tip), then release with a soft spring.
tl.to([actor, cursor], { scale: 0.93, duration: 0.12, ease: "power1.in" }, pressAt);
tl.set(slot, { attr: { "data-state": "pressed" } }, pressAt + 0.12);
tl.to(
[actor, cursor],
{ scale: 1, duration: 0.42, ease: "back.out(1.8)" },
pressAt + 0.14,
);
// Ink ripple rings from under the slot: real weight (thick ink,
// fg-mixed contrast, wide travel), still just two rings.
tl.fromTo(
rings,
{ scale: 0.16, autoAlpha: 0.95 },
{
scale: 2.6,
autoAlpha: 0,
duration: 0.66,
ease: "power2.out",
stagger: 0.11,
immediateRender: false,
},
pressAt + 0.1,
);
// The cursor exits off-stage after the release (accelerating,
// power2.in) so it never occludes the label during the hold; the
// pressed state and the ripple aftermath carry the frame. Both
// endpoints are explicit so reverse seeks restore the landing.
tl.fromTo(
cursor,
{ x: xAt(landX), y: yAt(landY) },
{
x: xAt(112),
y: yAt(110),
duration: 0.55,
ease: "power2.in",
immediateRender: false,
},
pressAt + 0.5,
);
// HOLD: dead-still on the pressed state; elastic with D.
// OUT: only when the host opts in; frame roots own transitions.
if (exit !== "none") {
var outStart = Math.max(pressAt + 1.05, duration - OUT);
var outVars = { opacity: 0, duration: OUT, ease: "power2.in" };
if (exit === "up") outVars.y = "-4cqh";
tl.to(stage, outVars, outStart);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["press-ripple"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
motion-primitive product-demo cursor press ripple payoff.