vidscroll0.3.3

Live values

Numbers, clocks and SVG drawings that follow the scroll, updated every frame without re-rendering.

Some effects can’t be written in CSS: a clock that counts minutes, a number that climbs, an SVG ring that fills. For those, useScrollVideoUpdate gives you the state on every frame, and you write the result straight to the page through a ref.

Scroll inside the preview
Example.tsx
import { useRef } from "react";
import { ScrollVideo, useScrollVideoUpdate } from "vidscroll";

function Clock() {
  const ref = useRef<HTMLParagraphElement>(null);
  useScrollVideoUpdate(({ linearProgress }) => {
    const minutes = 7 * 60 + 40 + linearProgress * 35;
    const text = `${Math.floor(minutes / 60)}:${String(Math.floor(minutes % 60)).padStart(2, "0")}`;
    if (ref.current && ref.current.textContent !== text) ref.current.textContent = text;
  });
  return <p ref={ref} style={{ margin: 0, color: "#fff", font: "500 40px monospace" }}>7:40</p>;
}

function Ring() {
  const ref = useRef<SVGCircleElement>(null);
  useScrollVideoUpdate(({ linearProgress }) => {
    ref.current?.setAttribute("stroke-dashoffset", String(1 - linearProgress));
  });
  return (
    <svg viewBox="0 0 100 100" width="88" height="88" aria-hidden="true">
      <circle cx="50" cy="50" r="44" fill="none" stroke="rgb(255 255 255 / 0.25)" strokeWidth="6" />
      <circle
        ref={ref}
        cx="50"
        cy="50"
        r="44"
        fill="none"
        stroke="#f6b98e"
        strokeWidth="6"
        pathLength={1}
        strokeDasharray="1"
        strokeDashoffset="1"
        transform="rotate(-90 50 50)"
      />
    </svg>
  );
}

export default function Example() {
  return (
    <ScrollVideo src="/train.mp4" length="400vh">
      <div style={{ position: "absolute", top: 16, left: 16, display: "flex", alignItems: "center", gap: 16 }}>
        <Ring />
        <Clock />
      </div>
    </ScrollVideo>
  );
}

A clock

Clock.tsx
import { useRef } from "react";
import { useScrollVideoUpdate } from "vidscroll";

export function Clock() {
  const ref = useRef<HTMLParagraphElement>(null);

  useScrollVideoUpdate(({ linearProgress }) => {
    const minutes = 7 * 60 + 40 + linearProgress * 35;
    const text = `${Math.floor(minutes / 60)}:${String(Math.floor(minutes % 60)).padStart(2, "0")}`;
    if (ref.current && ref.current.textContent !== text) ref.current.textContent = text;
  });

  return <p ref={ref}>7:40</p>;
}

The clock runs from 7:40 to 8:15 across the video. Writing only when the text changes keeps the work small.

An SVG ring

pathLength={1} makes the circle’s length 1, so the dash offset is simply 1 - progress:

Ring.tsx
import { useRef } from "react";
import { useScrollVideoUpdate } from "vidscroll";

export function Ring() {
  const ref = useRef<SVGCircleElement>(null);

  useScrollVideoUpdate(({ linearProgress }) => {
    ref.current?.setAttribute("stroke-dashoffset", String(1 - linearProgress));
  });

  return (
    <svg viewBox="0 0 100 100" width="88" height="88">
      <circle
        ref={ref}
        cx="50"
        cy="50"
        r="44"
        fill="none"
        stroke="currentColor"
        strokeWidth="6"
        pathLength={1}
        strokeDasharray="1"
        strokeDashoffset="1"
        transform="rotate(-90 50 50)"
      />
    </svg>
  );
}

Why a ref instead of state

The callback runs on every frame while someone scrolls, about 60 times a second. Setting React state that often re-renders the component every frame, which costs more than the change itself. Writing to the DOM through a ref skips React entirely.

useScrollVideoState is fine for values that change rarely, like the name of the current chapter. For anything continuous, use useScrollVideoUpdate with a ref.

What the callback receives

FieldMeaning
linearProgressSmoothed scroll progress, 0 to 1
timeVideo time in seconds
frameIndex, totalFramesCurrent frame and frame count
durationVideo length in seconds
activeSectionsIds of the active sections

Use linearProgress for things tied to the scroll, and time for things tied to the footage.