Install
That writes one file:compositions/components/scroll-feed.html.
Paste it into your composition
Opencompositions/components/scroll-feed.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 |
|---|---|---|---|
speed | doom | doom, frantic | Pace of the upward feed movement. |
card_count | 6 | 4 to 10, step 1 | Number of skeleton posts in one repeated feed cycle. |
cues | “ | string | Comma-separated seconds (from mount start); each cue advances the feed by one card. Empty synthesizes evenly spaced flicks, one per card. |
exit | none | none, fade, up | Optional departure. Default none: the feed scrolls 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="scroll-feed"
data-composition-src="compositions/components/scroll-feed.html"
data-variable-values='{"speed":"doom","card_count":6,"cues":"","exit":"none"}'
></div>
Source
scroll-feed.html
scroll-feed.html
<!doctype html>
<!--
scroll-feed: HyperFrames video primitive (effects / agitation / loop)
Concept: a self-scrolling column of skeleton post cards recycles without
a visible seam. The default register is a doomscroll: distinct thumb
flicks (fast start, power3 decay to a settle) with a read pause between
them, one flick per card. Two low-opacity copies trail the main column to
suggest motion blur while deterministic index math varies every card.
Variables:
- speed (doom | frantic, default doom): changes the card pitch, so the
column covers more distance during the same duration in frantic mode.
- card_count (number, 4 to 10, default 6): cards in one repeated cycle.
- cues (string, default ""): comma-separated seconds (from mount start);
each cue advances the feed by exactly one card pitch (stepped rhythm).
Empty synthesizes evenly spaced flicks, one per card, that still cover
exactly one full cycle.
- exit (none | fade | up, default "none"): optional departure over a
short reserved tail.
Envelope: IN = 0s, HOLD = D minus any exit tail, OUT = 0s or the exit
tail. HOLD owns the complete scroll and is the only elastic phase. A
longer mount stretches HOLD directly, never through timeScale. With no
cues and exit none, zero-length IN and OUT keep the first and final
frames loop-compatible and each column moves exactly one complete card
cycle. Cues or an enabled exit trade that loop guarantee for the cue
rhythm or the departure.
Determinism: cards are created synchronously. Their dimensions vary only
through index arithmetic. No clocks, random values, timers, or CSS
animations participate in rendering.
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 hardcoded
scroll-feed id.
-->
<html
lang="en"
data-composition-id="scroll-feed"
data-composition-duration="4"
data-composition-variables='[
{ "id": "speed", "type": "enum", "role": "timing", "label": "Scroll speed", "description": "Pace of the upward feed movement.", "default": "doom", "options": [{ "value": "doom", "label": "Doom" }, { "value": "frantic", "label": "Frantic" }] },
{ "id": "card_count", "type": "number", "role": "content", "label": "Card count", "description": "Number of skeleton posts in one repeated feed cycle.", "default": 6, "min": 4, "max": 10, "step": 1 },
{ "id": "cues", "type": "string", "role": "timing", "label": "Cues", "description": "Comma-separated seconds (from mount start); each cue advances the feed by one card. Empty synthesizes evenly spaced flicks, one per card.", "default": "" },
{ "id": "exit", "type": "enum", "role": "timing", "label": "Exit", "description": "Optional departure. Default none: the feed scrolls 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>Scroll Feed</title>
</head>
<body>
<template>
<div id="root" data-composition-id="scroll-feed" data-duration="4" data-fps="30">
<style>
*,
*::before,
*::after {
box-sizing: border-box;
}
#root {
position: absolute;
inset: 0;
overflow: hidden;
container-type: size;
isolation: isolate;
background: linear-gradient(180deg, var(--bg, #171b23) 0%, var(--bg, #0f1218) 100%);
color: var(--fg, #d8dee9);
font-family: var(--font-body, Inter, system-ui, sans-serif);
pointer-events: none;
}
.sf-viewport {
position: absolute;
inset: 0;
overflow: hidden;
/* Soft fade at both feed edges so cards never clip hard mid-media. */
mask-image: linear-gradient(
180deg,
transparent 0,
#000 7cqh,
#000 calc(100% - 7cqh),
transparent 100%
);
}
.sf-column {
position: absolute;
top: -20cqh;
left: 12cqw;
width: 76cqw;
will-change: transform;
}
.sf-cycle {
display: flex;
flex-direction: column;
}
.sf-card {
flex: 0 0 var(--sf-card-h);
width: 76cqw;
height: var(--sf-card-h);
margin-bottom: var(--sf-gap);
padding: var(--space-2, 2.3cqh) var(--space-2, 2.4cqw);
overflow: hidden;
border: 0.12cqw solid var(--border, rgba(160, 174, 192, 0.2));
border-radius: var(--radius, 2.2cqw);
/* Second stop derives from --surface so the gradient survives a
host theme instead of collapsing to a flat fill. */
background: linear-gradient(
145deg,
var(--surface, #29303b) 0%,
color-mix(in srgb, var(--surface, #29303b) 76%, var(--bg, #0f1218)) 100%
);
box-shadow: 0 1.8cqh 4cqh rgba(0, 0, 0, 0.28);
}
.sf-card-header {
display: flex;
align-items: center;
gap: var(--space-1, 1.4cqw);
height: 4.4cqh;
margin-bottom: var(--space-1, 1.5cqh);
}
.sf-avatar {
flex: 0 0 auto;
width: var(--sf-avatar);
height: var(--sf-avatar);
border-radius: 50%;
background: var(--muted, #758094);
}
.sf-identity {
display: flex;
flex: 1 1 auto;
min-width: 0;
flex-direction: column;
gap: var(--space-1, 0.65cqh);
}
.sf-line,
.sf-meta,
.sf-action {
display: block;
border-radius: 99cqw;
}
.sf-line {
height: 0.85cqh;
background: var(--muted, #7d8798);
}
.sf-line-primary {
width: var(--sf-primary-w);
}
.sf-line-secondary {
width: var(--sf-secondary-w);
opacity: 0.62;
}
.sf-meta {
flex: 0 0 auto;
width: var(--sf-meta-w);
height: 0.75cqh;
background: var(--muted, #667184);
opacity: 0.72;
}
.sf-media {
position: relative;
height: calc(var(--sf-card-h) - 12.2cqh);
min-height: 6cqh;
overflow: hidden;
border-radius: var(--radius, 1.4cqw);
/* The media block gets its own derivation (lifted toward --fg)
so card and media stay distinct under any --surface. */
background: linear-gradient(
135deg,
color-mix(in srgb, var(--surface, #29303b) 88%, var(--fg, #d8dee9)) 0%,
color-mix(in srgb, var(--surface, #29303b) 90%, var(--bg, #0f1218)) 100%
);
}
.sf-media::before {
position: absolute;
top: var(--sf-media-y);
left: var(--sf-media-x);
width: 18cqw;
height: 18cqw;
border-radius: 50%;
background: var(--muted, rgba(130, 143, 160, 0.16));
content: "";
}
.sf-card-footer {
display: flex;
align-items: center;
gap: var(--space-2, 2.4cqw);
height: 2cqh;
margin-top: var(--space-1, 1.35cqh);
}
.sf-action {
width: var(--sf-action-w);
height: 0.8cqh;
background: var(--muted, #717c8e);
opacity: 0.72;
}
.sf-blur {
z-index: 0;
opacity: 0.1;
filter: blur(0.28cqh);
}
.sf-blur-a {
top: -19.2cqh;
left: 11.55cqw;
}
.sf-blur-b {
top: -20.8cqh;
left: 12.45cqw;
}
.sf-main {
z-index: 1;
}
</style>
<div
id="scroll-feed-clip"
class="sf-viewport clip"
data-start="0"
data-duration="4"
data-track-index="0"
>
<div class="sf-column sf-blur sf-blur-a" aria-hidden="true"></div>
<div class="sf-column sf-blur sf-blur-b" aria-hidden="true"></div>
<div class="sf-column sf-main" aria-hidden="true"></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 mainColumn = root.querySelector(".sf-main");
var blurA = root.querySelector(".sf-blur-a");
var blurB = root.querySelector(".sf-blur-b");
var columns = root.querySelectorAll(".sf-column");
var vars =
window.__hyperframes && window.__hyperframes.getVariables
? window.__hyperframes.getVariables()
: {};
var speed = vars.speed === "frantic" ? "frantic" : "doom";
var countValue = Number(vars.card_count);
var cardCount = Number.isFinite(countValue)
? Math.max(4, Math.min(10, Math.round(countValue)))
: 6;
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;
}, [])
.sort(function (a, b) {
return a - b;
});
// Frantic mode travels farther in the same HOLD while still
// completing exactly one full cycle at the final frame.
var cardHeight = speed === "frantic" ? 32 : 24;
var gap = speed === "frantic" ? 4 : 3;
var cycleDistance = cardCount * (cardHeight + gap);
root.style.setProperty("--sf-card-h", cardHeight + "cqh");
root.style.setProperty("--sf-gap", gap + "cqh");
var cycle = document.createElement("div");
cycle.className = "sf-cycle";
for (var index = 0; index < cardCount; index += 1) {
var card = document.createElement("article");
card.className = "sf-card";
card.style.setProperty("--sf-avatar", 3.2 + (index % 3) * 0.45 + "cqh");
card.style.setProperty("--sf-primary-w", 18 + ((index * 11) % 24) + "cqw");
card.style.setProperty("--sf-secondary-w", 12 + ((index * 7) % 19) + "cqw");
card.style.setProperty("--sf-meta-w", 7 + ((index * 5) % 9) + "cqw");
card.style.setProperty("--sf-media-x", 5 + ((index * 17) % 48) + "cqw");
card.style.setProperty("--sf-media-y", -8 + ((index * 13) % 10) + "cqh");
card.style.setProperty("--sf-action-w", 7 + ((index * 3) % 8) + "cqw");
card.innerHTML =
'<header class="sf-card-header"><span class="sf-avatar"></span><span class="sf-identity"><span class="sf-line sf-line-primary"></span><span class="sf-line sf-line-secondary"></span></span><span class="sf-meta"></span></header><div class="sf-media"></div><footer class="sf-card-footer"><span class="sf-action"></span><span class="sf-action"></span><span class="sf-action"></span></footer>';
cycle.appendChild(card);
}
// Three identical cycles keep the viewport covered before, during,
// and after the exact one-cycle translation. The blur trails clone
// this single deterministic source instead of recomputing cards.
mainColumn.appendChild(cycle);
mainColumn.appendChild(cycle.cloneNode(true));
mainColumn.appendChild(cycle.cloneNode(true));
blurA.innerHTML = mainColumn.innerHTML;
blurB.innerHTML = mainColumn.innerHTML;
// IN stays zero. Exit none keeps OUT at zero for a clean loop;
// an enabled exit reserves a short tail after the scroll.
var duration = Math.max(0.001, parseFloat(root.dataset.duration || "4"));
var EXIT = exit === "none" ? 0 : Math.min(0.4, duration * 0.15);
var SCROLL_END = Math.max(0.001, duration - EXIT);
var finalY = -cycleDistance;
var tl = gsap.timeline({ paused: true });
// Stepped rhythm: each step advances the feed by exactly one
// card pitch (thumb flick: fast start, power3 decay, settle).
// Three stacked cycles keep the viewport covered through two
// full cycles of steps. Without cues the default doomscroll
// synthesizes one evenly spaced flick per card, so the column
// still travels exactly one full cycle by SCROLL_END and the
// first and final frames stay loop-compatible.
var pitch = cardHeight + gap;
var steps = [];
var autoFlick = cues.length === 0;
if (autoFlick) {
for (var step = 0; step < cardCount; step += 1) {
steps.push((SCROLL_END / cardCount) * step);
}
} else {
cues.forEach(function (cueAt) {
if (steps.length >= cardCount * 2) return;
var at = Math.min(SCROLL_END, cueAt);
if (steps.length === 0 || at > steps[steps.length - 1] + 0.01) {
steps.push(at);
}
});
}
finalY = -pitch * steps.length;
steps.forEach(function (cueAt, index) {
var next = index + 1 < steps.length ? steps[index + 1] : SCROLL_END;
// Auto flicks keep a longer rest so each card gets a read;
// explicit cues keep the documented cue-mode timing.
var stepDuration = autoFlick
? Math.max(0.05, Math.min(0.45, (next - cueAt) * 0.6))
: Math.max(0.05, Math.min(0.5, (next - cueAt) * 0.8, duration - cueAt));
var target = { y: -pitch * (index + 1) + "cqh" };
if (index === 0) {
tl.fromTo(
columns,
{ y: "0cqh" },
{
y: target.y,
duration: stepDuration,
ease: "power3.out",
},
cueAt,
);
} else {
tl.to(columns, { y: target.y, duration: stepDuration, ease: "power3.out" }, cueAt);
}
});
// Optional departure; exit none scrolls until the frame cuts.
if (exit === "fade") {
tl.to(columns, { opacity: 0, duration: EXIT, ease: "power2.in" }, SCROLL_END);
} else if (exit === "up") {
tl.to(columns, { opacity: 0, duration: EXIT, ease: "power2.in" }, SCROLL_END);
tl.to(
columns,
{ y: finalY - 10 + "cqh", duration: EXIT, ease: "power2.in" },
SCROLL_END,
);
}
tl.seek(0);
window.__timelines = window.__timelines || {};
window.__timelines["scroll-feed"] = tl;
})();
</script>
</div>
</template>
</body>
</html>
motion-primitive effects scroll feed loop agitation.