Install
That writes one file:compositions/components/per-word-rise.html.
Paste it into your composition
Opencompositions/components/per-word-rise.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 |
|---|---|---|---|
text | WORDS IN MOTION | string | Text whose words or characters rise into place. |
split | word | word, char | Animate the text as words or individual characters. |
cues | “ | string | Comma-separated seconds (from mount start) for each unit landing. Empty keeps the authored cascade rhythm. |
accent | green | green, blue, violet | Contract accent token used for the text color. |
exit | none | none, fade, up | Optional departure. Default none: the line holds 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="per-word-rise"
data-composition-src="compositions/components/per-word-rise.html"
data-variable-values='{"text":"WORDS IN MOTION","split":"word","cues":"","accent":"green","exit":"none"}'
></div>
Source
per-word-rise.html
per-word-rise.html
<!doctype html>
<!--
per-word-rise: HyperFrames video primitive (text effects / reveal)
Words, or characters through the split variable, rise into place with a
controlled cascade and a small blur-to-sharp settle. Each unit lands with
a short overshoot-free settle tail (the final few pixels glide in over the
closing frames instead of stopping dead), then the finished line holds
truly still until the frame cuts (exit none) or departs with the optional
exit variable.
Variables:
- text (string, default "WORDS IN MOTION"): displayed text.
- split (word | char, default "word"): animated unit size.
- cues (string, default ""): comma-separated seconds (from mount start)
for each unit's landing. Empty keeps the authored cascade rhythm.
- accent (green | blue | violet, default "green"): text color.
- exit (none | fade | up, default "none"): optional departure.
Envelope, fixed IN and OUT with elastic HOLD only:
IN_BASE = 1.45s, the full staggered rise and sharp settle (cues can
stretch the rise phase past IN_BASE when they land later)
HOLD = max(0, D - IN - OUT), truly still: no drift
OUT_BASE = 0.50s staggered departure when exit is fade or up, 0s when
exit is none (the default: the line holds until the cut)
If D is shorter than IN_BASE + OUT_BASE, IN and OUT compress together.
The timeline is never time-scaled.
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 per-word-rise key.
-->
<html
lang="en"
data-composition-id="per-word-rise"
data-composition-duration="3.5"
data-composition-variables='[
{ "id": "text", "type": "string", "role": "content", "label": "Text", "description": "Text whose words or characters rise into place.", "default": "WORDS IN MOTION" },
{ "id": "split", "type": "enum", "role": "content", "label": "Split", "description": "Animate the text as words or individual characters.", "default": "word", "options": [{ "value": "word", "label": "Word" }, { "value": "char", "label": "Character" }] },
{ "id": "cues", "type": "string", "role": "timing", "label": "Cues", "description": "Comma-separated seconds (from mount start) for each unit landing. Empty keeps the authored cascade rhythm.", "default": "" },
{ "id": "accent", "type": "enum", "role": "style", "label": "Accent", "description": "Contract accent token used for the text color.", "default": "green", "options": [{ "value": "green", "label": "Green" }, { "value": "blue", "label": "Blue" }, { "value": "violet", "label": "Violet" }] },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the line holds 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>Per Word Rise</title>
</head>
<body>
<template>
<div id="root" data-composition-id="per-word-rise" data-duration="3.5" 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-display, Inter, system-ui, sans-serif);
pointer-events: none;
}
.pwr-clip {
position: absolute;
inset: 0;
display: grid;
place-items: center;
overflow: hidden;
background: var(--bg, #0b0c0e);
}
.pwr-stage {
display: flex;
width: 88cqw;
max-width: 88cqw;
align-items: baseline;
justify-content: center;
flex-wrap: wrap;
column-gap: var(--space-1, 1.8cqw);
row-gap: var(--space-2, 2.8cqh);
color: var(--pwr-accent);
font-size: var(--pwr-font-size);
font-weight: 780;
line-height: 1.08;
letter-spacing: -0.035em;
text-align: center;
}
.pwr-stage.pwr-char {
column-gap: 0.045em;
letter-spacing: 0;
}
.pwr-unit {
display: inline-block;
flex: 0 0 auto;
filter: blur(var(--pwr-blur, 0cqh));
will-change: transform, filter, opacity;
}
.pwr-space {
width: 0.36em;
}
</style>
<div
id="per-word-rise-clip"
class="pwr-clip clip"
data-start="0"
data-duration="3.5"
data-track-index="0"
>
<div class="pwr-stage" role="img"></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(".pwr-stage");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var text = vars.text == null ? "WORDS IN MOTION" : String(vars.text);
var split = vars.split === "char" ? "char" : "word";
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";
var exit = vars.exit === "fade" || vars.exit === "up" ? vars.exit : "none";
var cuesValue = vars.cues == null ? "" : String(vars.cues);
var cues = cuesValue.split(",").reduce(function (result, token) {
var trimmed = token.trim();
if (trimmed === "") return result;
var seconds = Number(trimmed);
if (!Number.isFinite(seconds) || seconds < 0) return result;
result.push(seconds);
return result;
}, []);
root.style.setProperty("--pwr-accent", accentColors[accent]);
stage.classList.toggle("pwr-char", split === "char");
stage.setAttribute("aria-label", text);
var characterCount = Math.max(1, Array.from(text).length);
var fittedCqw = Math.min(9.5, 126 / characterCount);
root.style.setProperty(
"--pwr-font-size",
"min(" + fittedCqw.toFixed(3) + "cqw, 15cqh)",
);
var segments = split === "char" ? Array.from(text) : text.trim().split(/\s+/);
if (segments.length === 0) segments = [""];
var units = segments.map(function (segment, index) {
var unit = document.createElement("span");
unit.id = "per-word-rise-unit-" + index;
unit.className = "pwr-unit";
if (split === "char" && /\s/.test(segment)) {
unit.classList.add("pwr-space");
unit.textContent = "\u00a0";
} else {
unit.textContent = segment;
}
unit.setAttribute("aria-hidden", "true");
stage.appendChild(unit);
return unit;
});
var IN_BASE = 1.45;
var OUT_BASE = exit === "none" ? 0 : 0.5;
var RISE_BASE = 0.74;
var STAGGER_BASE = 0.62;
var EXIT_STAGGER_BASE = 0.14;
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "3.5"));
var totalBase = Math.max(0.001, IN_BASE + OUT_BASE);
var scale = duration < totalBase ? duration / totalBase : 1;
var IN = IN_BASE * scale;
var OUT = OUT_BASE * scale;
var RISE = RISE_BASE * scale;
var STAGGER = units.length > 1 ? STAGGER_BASE * scale : 0;
var EXIT_STAGGER = units.length > 1 ? EXIT_STAGGER_BASE * scale : 0;
var EXIT_MOVE = Math.max(0.001, OUT - EXIT_STAGGER);
// Landing ease: overshoot-free decel with a settle tail. A plain
// power3.out slams from fast to imperceptible in ~2 frames and the
// words stop dead; this piecewise cubic keeps ~7% of the travel for
// the last 22% of the duration and is C1-continuous at the seam,
// so the word glides its final few pixels over the closing frames
// (a settle) with monotonic velocity: no stall, no hop, no
// overshoot. Pure function of progress, so it stays seek-safe.
var LAND_SPLIT = 0.78; // progress where the settle tail begins
var LAND_RESIDUAL = 0.07; // fraction of travel left for the tail
var LAND_SEAM_SLOPE = ((1 - LAND_RESIDUAL) / LAND_SPLIT) * 0.5; // shared velocity at the seam
function landEase(p) {
if (p <= 0) return 0;
if (p >= 1) return 1;
if (p < LAND_SPLIT) {
var x = p / LAND_SPLIT;
var inv = 1 - x;
// Half cubic-out, half linear: keeps end velocity for the seam.
return (1 - LAND_RESIDUAL) * (0.5 * (1 - inv * inv * inv) + 0.5 * x);
}
var u = (p - LAND_SPLIT) / (1 - LAND_SPLIT);
var b = LAND_SEAM_SLOPE * (1 - LAND_SPLIT);
var c = 3 * LAND_RESIDUAL - 2 * b;
var d = b - 2 * LAND_RESIDUAL;
return 1 - LAND_RESIDUAL + b * u + c * u * u + d * u * u * u;
}
// Cues own the word landings when provided; the authored cascade
// otherwise. Landings beyond the cue list extrapolate at the cue
// list's own gap, and every landing clamps ahead of any exit.
var riseEnd = IN;
var landings;
if (cues.length > 0) {
var lastCue = cues[cues.length - 1];
var cueGap =
cues.length >= 2
? Math.max(0.05, (lastCue - cues[0]) / (cues.length - 1))
: RISE * 0.4;
var latestLanding = Math.max(RISE * 0.5, duration - OUT);
landings = units.map(function (unit, index) {
var landing =
index < cues.length
? cues[index]
: lastCue + (index - (cues.length - 1)) * cueGap;
return Math.min(latestLanding, Math.max(0.05, landing));
});
riseEnd = Math.min(duration - OUT, Math.max(IN, Math.max.apply(null, landings)));
} else {
// Authored cascade: same left-to-right wave the stagger drew,
// expressed as explicit landings so every unit gets the settle.
var step = units.length > 1 ? STAGGER / (units.length - 1) : 0;
landings = units.map(function (unit, index) {
return index * step + RISE;
});
}
var HOLD_START = riseEnd;
var HOLD = Math.max(0, duration - (HOLD_START + OUT));
var OUT_START = HOLD_START + HOLD;
gsap.set(units, { opacity: 0, y: "8cqh", "--pwr-blur": "1.4cqh" });
var tl = gsap.timeline({ paused: true });
units.forEach(function (unit, index) {
var landing = landings[index];
var startAt = Math.max(0, landing - RISE);
tl.to(
unit,
{
opacity: 1,
y: "0cqh",
"--pwr-blur": "0cqh",
duration: Math.max(0.08, landing - startAt),
ease: landEase,
},
startAt,
);
});
// TRUE hold: the finished line is dead still until the exit or
// the cut. The old 0.55cqh drift was sub-perceptual at 1080p.
// OUT: optional departure; exit none holds until the frame cuts.
if (exit === "up") {
tl.to(
units,
{
opacity: 0,
y: "-4.5cqh",
"--pwr-blur": "0.7cqh",
duration: EXIT_MOVE,
stagger: { amount: EXIT_STAGGER, from: "start" },
ease: "power2.in",
},
OUT_START,
);
} else if (exit === "fade") {
tl.to(
units,
{
opacity: 0,
duration: EXIT_MOVE,
stagger: { amount: EXIT_STAGGER, from: "start" },
ease: "power2.in",
},
OUT_START,
);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["per-word-rise"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
motion-primitive text-effects typography stagger reveal.