animaly
Documentation menu

Guides

Scroll

scroll ties an animation, or any callback, to how far a page or element has scrolled. Progress runs from 0 to 1 across the whole scroll, or across the stretch where one element passes through the viewport.

Animate with the scroll

Pass the controls of an animation to scroll. The animation stops playing on its own clock and its time follows the scroll: the top of the page is the start, the bottom is the end. Use ease: "linear" so the motion keeps pace with the scrollbar.

A reading progress bar
import { animate, scroll } from "@animaly/dom";

scroll(animate(".progress", { scaleX: [0, 1] }, { ease: "linear" }));

Every value of the call follows the same progress. One scroll event updates all of them in a single pass over the engine's columns, however many elements the animation moves.

Several properties on one animation
import { animate, scroll } from "@animaly/dom";

scroll(
  animate(".progress", {
    scaleX: [0, 1],
    backgroundColor: ["#7c3aed", "#db2777"],
  }, { ease: "linear" }),
);
Keyframes across the page
import { animate, scroll } from "@animaly/dom";

scroll(
  animate(".progress", { opacity: [0, 1, 1, 0] }, {
    times: [0, 0.1, 0.9, 1],
    ease: "linear",
  }),
);

Stagger

A staggered animation spreads out along the scroll. Each element's time is (duration + delay) × progress − delay, as in motion, so later elements start further down the page and every element reaches its end at progress 1. With a duration of 1 and stagger(0.5) over three elements, halfway down the page they stand at 50%, 25% and 0% of their motion.

Stagger a row of elements along the scroll
import { animate, scroll, stagger } from "@animaly/dom";

scroll(
  animate(".row i", { y: [0, -40] }, {
    duration: 1,
    ease: "linear",
    delay: stagger(0.2),
  }),
);

A callback instead of an animation

Pass a function to receive the progress and the full scroll info on every frame the position changes.

Read the progress in a callback
import { scroll } from "@animaly/dom";

const bar = document.querySelector<HTMLElement>(".progress")!;

scroll((progress) => {
  bar.style.transform = `scaleX(${progress})`;
});
Drive a motion value from the scroll
import { motionValue, scroll } from "@animaly/dom";

const progress = motionValue(0);
const bar = document.querySelector<HTMLElement>(".progress")!;

progress.on("change", (latest) => {
  bar.style.transform = `scaleX(${latest})`;
});

scroll((latest) => progress.set(latest));
Smooth the progress with a spring
import { animate, motionValue, scroll } from "@animaly/dom";

const smoothed = motionValue(0);
const bar = document.querySelector<HTMLElement>(".progress")!;

smoothed.on("change", (latest) => {
  bar.style.transform = `scaleX(${latest})`;
});

scroll((progress) => {
  animate(smoothed, progress, { type: "spring", stiffness: 200, damping: 30 });
});

Stopping

scroll returns a function that removes the listeners. For an animation it also releases the animation from the scroll.

Stop following the scroll
import { animate, scroll } from "@animaly/dom";

const stop = scroll(animate(".progress", { scaleX: [0, 1] }, { ease: "linear" }));

stop();
In a React component
import { animate, scroll } from "@animaly/dom";
import { useEffect, useRef } from "react";

export function ReadingProgress() {
  const bar = useRef<HTMLDivElement>(null);

  useEffect(() => {
    if (!bar.current) return;
    return scroll(animate(bar.current, { scaleX: [0, 1] }, { ease: "linear" }));
  }, []);

  return <div ref={bar} className="progress" />;
}

Track an element

With a target, progress measures the target's trip through the container. offset says where that trip starts and ends. Each entry names a point on the target, then a point on the container: "start end" means the target's top meets the viewport's bottom.

Track one element through the viewport
import { animate, scroll } from "@animaly/dom";

const card = document.querySelector(".card")!;

scroll(animate(card, { opacity: [0, 1], y: [60, 0] }, { ease: "linear" }), {
  target: card,
  offset: ["start end", "end end"],
});
Progress of an element, as a number
import { scroll } from "@animaly/dom";

const card = document.querySelector<HTMLElement>(".card")!;

scroll((progress) => {
  card.style.setProperty("--seen", String(progress));
}, { target: card, offset: ["start end", "end start"] });

Presets

scrollOffsets holds four common trips, the same as motion's. Enter runs from 0 to 1 while the target comes into view and Exit while it leaves.

Fade in while entering
import { animate, scroll, scrollOffsets } from "@animaly/dom";

const card = document.querySelector(".card")!;

scroll(animate(card, { opacity: [0, 1] }, { ease: "linear" }), {
  target: card,
  offset: scrollOffsets.Enter,
});
Shrink while leaving
import { animate, scroll, scrollOffsets } from "@animaly/dom";

const card = document.querySelector(".card")!;

scroll(animate(card, { scale: [1, 0.8] }, { ease: "linear" }), {
  target: card,
  offset: scrollOffsets.Exit,
});

Any covers every frame in which part of the target is visible, and All every frame in which all of it is. Both list their later edge first, so their progress falls from 1 to 0 as you scroll down. For a 0 to 1 trip across the whole visible stretch, write the offset out.

Across the whole time it is visible
import { animate, scroll, scrollOffsets } from "@animaly/dom";

const card = document.querySelector(".card")!;

// 0 when the top edge appears, 1 when the bottom edge leaves.
scroll(animate(card, { rotate: [-8, 8] }, { ease: "linear" }), {
  target: card,
  offset: ["start end", "end start"],
});

// The preset covers the same stretch, from 1 down to 0.
scroll(animate(card, { opacity: [0.4, 1] }, { ease: "linear" }), {
  target: card,
  offset: scrollOffsets.Any,
});

Offset values

A point is start, center, end, a number from 0 to 1, or a length in px, %, vw or vh. A pair of numbers works the same way as a pair of names.

Offsets in pixels, percent and viewport units
import { animate, scroll } from "@animaly/dom";

const card = document.querySelector(".card")!;

scroll(animate(card, { opacity: [0, 1] }, { ease: "linear" }), {
  target: card,
  offset: ["start 80%", "start 40vh"],
});

scroll(animate(card, { y: [40, 0] }, { ease: "linear" }), {
  target: card,
  offset: ["-100px end", "100px end"],
});
Offsets as number pairs
import { animate, scroll } from "@animaly/dom";

const card = document.querySelector(".card")!;

scroll(animate(card, { scale: [0.9, 1] }, { ease: "linear" }), {
  target: card,
  offset: [[0, 1], [0.5, 0.5]],
});

Many elements

Give each element its own call so each one tracks its own trip through the viewport.

One scroll animation per element
import { animate, scroll, scrollOffsets } from "@animaly/dom";

document.querySelectorAll(".card").forEach((card) => {
  scroll(animate(card, { opacity: [0, 1], x: [-40, 0] }, { ease: "linear" }), {
    target: card,
    offset: scrollOffsets.Enter,
  });
});

Scrolling elements

container switches from the page to any scrolling element. axis: "x" measures horizontal scrolling.

Scroll inside an element
import { animate, scroll } from "@animaly/dom";

scroll(animate(".list-progress", { scaleX: [0, 1] }, { ease: "linear" }), {
  container: document.querySelector(".list")!,
});
Horizontal scrolling
import { animate, scroll } from "@animaly/dom";

scroll(animate(".gallery-progress", { scaleX: [0, 1] }, { ease: "linear" }), {
  container: document.querySelector(".gallery")!,
  axis: "x",
});

Progress is measured again when the window or the container resizes. If content grows inside a container that keeps its size, pass trackContentSize.

Content that grows after load
import { animate, scroll } from "@animaly/dom";

scroll(animate(".list-progress", { scaleX: [0, 1] }, { ease: "linear" }), {
  container: document.querySelector(".list")!,
  trackContentSize: true,
});

scrollInfo

scrollInfo reports both axes with their position, length and velocity, once per frame while scrolling. It takes the same options.

Position, length and velocity
import { scrollInfo } from "@animaly/dom";

scrollInfo(({ y }) => {
  console.log(y.current, y.scrollLength, y.progress, y.velocity);
});
Both axes of a container
import { scrollInfo } from "@animaly/dom";

scrollInfo(({ x, y }) => {
  console.log(x.progress, y.progress);
}, { container: document.querySelector(".gallery")! });
Skew with scroll speed
import { animate, scrollInfo } from "@animaly/dom";

const cards = document.querySelectorAll(".card");

scrollInfo(({ y }) => {
  const skew = Math.max(-10, Math.min(10, y.velocity / 300));
  animate(cards, { skewY: skew }, { type: "spring", stiffness: 300, damping: 30 });
});
Fields of each axis
OptionTypeDefaultWhat it does
currentnumbernoneScroll position in pixels.
progressnumbernone0 to 1 between the resolved offsets.
scrollLengthnumbernoneHow far the container can scroll, in pixels.
velocitynumbernonePixels per second, 0 when the last scroll event is more than 50 ms old.
offsetnumber[]noneThe resolved offsets in pixels.
containerLengthnumbernoneVisible size of the container.
targetLengthnumbernoneSize of the target, or of the scrollable content without one.
targetOffsetnumbernoneDistance from the container's start to the target.

Options

scroll and scrollInfo options
OptionTypeDefaultWhat it does
containerElementthe pageThe element that scrolls.
axis"x" | "y""y"Which direction counts as progress.
targetElementnoneMeasure progress while this element passes through the container instead of across the whole scroll.
offsetScrollOffsetscrollOffsets.AllWhere progress starts and ends. Each entry pairs a point on the target with a point on the container.
trackContentSizebooleanfalseCheck the content size every frame, for content that grows without resizing the container.

Scroll-linked animations run on the JS path. With createAnimator({ maxAccelerated }), animations that were handed to CSS animations don't follow the scroll; leave maxAccelerated at its default 0 for elements you pass to scroll.