> ## Documentation Index
> Fetch the complete documentation index at: https://hyperframes.heygen.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Scroll Feed

> A loop-friendly column of varied skeleton post cards scrolls upward with subtle motion trails and an agitated pace.

export const InstallCommand = ({command}) => {
  const [copied, setCopied] = React.useState(false);
  const copy = async () => {
    try {
      if (navigator.clipboard && window.isSecureContext) {
        await navigator.clipboard.writeText(command);
      } else {
        const previous = document.activeElement;
        const scratch = document.createElement("textarea");
        scratch.value = command;
        scratch.setAttribute("readonly", "");
        scratch.style.position = "fixed";
        scratch.style.opacity = "0";
        document.body.appendChild(scratch);
        scratch.select();
        document.execCommand("copy");
        document.body.removeChild(scratch);
        previous?.focus?.();
      }
      setCopied(true);
      setTimeout(() => setCopied(false), 2000);
    } catch {}
  };
  return <div className="hf-install-command not-prose my-4 flex items-stretch overflow-hidden rounded-xl border border-zinc-200 bg-zinc-50 dark:border-zinc-800 dark:bg-zinc-900">
      <code className="flex-1 overflow-x-auto whitespace-nowrap border-r border-zinc-200 px-4 py-3 font-mono text-sm text-zinc-800 dark:border-zinc-800 dark:text-zinc-100">
        {command}
      </code>
      <button type="button" onClick={copy} data-copied={copied ? "true" : "false"} aria-label={`Copy ${command} to the clipboard`} className="hf-install-copy">
        <svg className="hf-install-copy-clipboard" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M14.25 5.25H7.25C6.14543 5.25 5.25 6.14543 5.25 7.25V14.25C5.25 15.3546 6.14543 16.25 7.25 16.25H14.25C15.3546 16.25 16.25 15.3546 16.25 14.25V7.25C16.25 6.14543 15.3546 5.25 14.25 5.25Z" />
          <path d="M2.80103 11.998L1.77203 5.07397C1.61003 3.98097 2.36403 2.96397 3.45603 2.80197L10.38 1.77297C11.313 1.63397 12.19 2.16297 12.528 3.00097" />
        </svg>
        <svg className="hf-install-copy-check" xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
          <path d="M2.75 9.5L6.5 13.25L15.25 4.5" />
        </svg>
      </button>
      <span className="hf-install-copy-status" role="status" aria-live="polite">
        {copied ? "Copied" : ""}
      </span>
    </div>;
};

<iframe className="w-full aspect-video rounded-xl border-0 bg-zinc-100 dark:bg-zinc-800" title="scroll-feed preview" loading="lazy" srcDoc={`<!doctype html><html><head><meta charset="utf-8"><style>html,body{margin:0;height:100%;overflow:hidden;background:transparent}hyperframes-player{display:block;width:100%;height:100%}</style><script src="https://cdn.jsdelivr.net/npm/@hyperframes/player@0.7/dist/hyperframes-player.global.js"><\/script></head><body><script>fetch("/public/catalog/components/scroll-feed.json").then(function(r){return r.json()}).then(function(d){var p=document.createElement("hyperframes-player");p.setAttribute("srcdoc",d.html);p.setAttribute("controls","");p.setAttribute("autoplay","");p.setAttribute("loop","");p.setAttribute("muted","");p.setAttribute("poster","https://static.heygen.ai/hyperframes-oss/docs/images/catalog/components/scroll-feed.png");document.body.appendChild(p)});<\/script></body></html>`} />

## Install

<InstallCommand command="npx hyperframes add scroll-feed" />

That writes one file: `compositions/components/scroll-feed.html`.

## Paste it into your composition

Open `compositions/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.                                                                  |

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 theme={null}
<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

<Accordion title={`scroll-feed.html`}>
  ```html theme={null}
  <!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>
  ```
</Accordion>

Tagged `motion-primitive` `effects` `scroll` `feed` `loop` `agitation`.

## Related topics

* [Browse the complete Catalog](/catalog)
* [Add assets and Catalog items in Studio](/studio/assets-and-blocks)
* [Build a richer composition](/go-further)


## Related topics

- [Scroll Camera Story](/catalog/components/scroll-camera-story.md)
- [Share feedback](/guides/feedback.md)
- [Code animations](/prompting/code-blocks.md)
