animaly
Documentation menu

Recipes

Scroll and gesture recipes

Scroll progress, scroll-linked scenes, reveals, drag and throw, carousels, theme and page transitions. Each recipe has the markup and the code it needs, and every example runs against the published @animaly/dom.

On this page

Scroll progress bar

Spring a bar toward the scroll position. The spring smooths out the jumps of wheel scrolling.

index.html
<div class="scroll-bar" style="position:fixed;top:0;left:0;right:0;height:3px;background:#7c3aed;transform-origin:left;transform:scaleX(0)"></div>
<main style="height:300vh"></main>
scroll-progress.ts
import { animate } from "@animaly/dom";

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

function update(): void {
  const scrollable = document.documentElement.scrollHeight - window.innerHeight;
  const progress = scrollable > 0 ? window.scrollY / scrollable : 0;
  animate(bar, { scaleX: progress }, { type: "spring", stiffness: 300, damping: 40 });
}

window.addEventListener("scroll", update, { passive: true });
update();

Scroll-linked hero fade

Pause an animation and set its time from the scroll position. The animation becomes a timeline that scroll scrubs.

index.html
<section class="hero" style="height:100vh;display:grid;place-items:center">
  <h1 class="hero-title">Animation, computed in columns.</h1>
</section>
<section style="height:150vh"></section>
hero-fade.ts
import { animate } from "@animaly/dom";

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

// A paused animation is a timeline you can scrub: time 0 is the top of the page.
const fade = animate(".hero-title", { opacity: [1, 0], y: [0, -60], scale: [1, 0.96] }, { duration: 1, ease: "linear" });
fade.pause();

function update(): void {
  fade.time = Math.min(1, Math.max(0, window.scrollY / hero.offsetHeight));
}

window.addEventListener("scroll", update, { passive: true });
update();

Reveal on scroll

An IntersectionObserver starts each block's animation once, the first time it enters the viewport.

index.html
<section style="height:120vh"></section>
<article class="reveal">First block</article>
<article class="reveal">Second block</article>
<article class="reveal">Third block</article>
reveal-on-scroll.ts
import { animate } from "@animaly/dom";

const blocks = document.querySelectorAll<HTMLElement>(".reveal");
blocks.forEach((block) => {
  block.style.opacity = "0";
});

const observer = new IntersectionObserver(
  (entries) => {
    entries
      .filter((entry) => entry.isIntersecting)
      .forEach((entry) => {
        observer.unobserve(entry.target);
        animate(entry.target as HTMLElement, { y: [32, 0], opacity: [0, 1] }, { type: "spring", stiffness: 180, damping: 24 });
      });
  },
  { rootMargin: "0px 0px -10% 0px" },
);

blocks.forEach((block) => observer.observe(block));

Drag and throw

Two motion values follow the pointer. On release, inertia continues with the pointer's velocity inside the area, then a spring brings the box back.

index.html
<div class="drag-area" style="position:relative;height:320px;border-radius:20px;background:#f2f2f2;touch-action:none">
  <div class="draggable" style="position:absolute;left:calc(50% - 40px);top:calc(50% - 40px);width:80px;height:80px;border-radius:20px;background:#7c3aed;cursor:grab"></div>
</div>
drag-and-throw.ts
import { animate, motionValue } from "@animaly/dom";

const area = document.querySelector<HTMLElement>(".drag-area")!;
const box = document.querySelector<HTMLElement>(".draggable")!;
const x = motionValue(0);
const y = motionValue(0);

const render = (): void => {
  box.style.transform = "translate(" + x.get() + "px, " + y.get() + "px)";
};
x.on("change", render);
y.on("change", render);

const limitX = (area.clientWidth - box.offsetWidth) / 2;
const limitY = (area.clientHeight - box.offsetHeight) / 2;
let start = { pointerX: 0, pointerY: 0, x: 0, y: 0 };
let samples: { x: number; y: number; t: number }[] = [];

box.addEventListener("pointerdown", (event) => {
  box.setPointerCapture(event.pointerId);
  x.stop();
  y.stop();
  start = { pointerX: event.clientX, pointerY: event.clientY, x: x.get(), y: y.get() };
  samples = [{ x: event.clientX, y: event.clientY, t: event.timeStamp }];
});

box.addEventListener("pointermove", (event) => {
  if (!box.hasPointerCapture(event.pointerId)) return;
  x.set(start.x + event.clientX - start.pointerX);
  y.set(start.y + event.clientY - start.pointerY);
  samples = [...samples, { x: event.clientX, y: event.clientY, t: event.timeStamp }].slice(-4);
});

// Release: glide with the pointer's velocity inside the area, then spring back to the center.
box.addEventListener("pointerup", async () => {
  const first = samples[0];
  const last = samples[samples.length - 1];
  const seconds = Math.max(0.001, (last.t - first.t) / 1000);
  const throwX = animate(x, x.get(), { type: "inertia", velocity: (last.x - first.x) / seconds, min: -limitX, max: limitX });
  const throwY = animate(y, y.get(), { type: "inertia", velocity: (last.y - first.y) / seconds, min: -limitY, max: limitY });
  await Promise.all([throwX.finished, throwY.finished]);
  animate(x, 0, { type: "spring", stiffness: 200, damping: 20 });
  animate(y, 0, { type: "spring", stiffness: 200, damping: 20 });
});

Theme color transition

Animate CSS variables on the root element; everything that reads them changes color together.

styles.css
:root {
  --page: #f7f6f3;
  --text: #111111;
}

body {
  background: var(--page);
  color: var(--text);
}
index.html
<button class="theme-toggle">Toggle theme</button>
theme.ts
import { animate } from "@animaly/dom";

const light = { "--page": "#f7f6f3", "--text": "#111111" };
const dark = { "--page": "#0b0b0f", "--text": "#f4f4f5" };
let isDark = false;

document.querySelector(".theme-toggle")!.addEventListener("click", () => {
  isDark = !isDark;
  animate(document.documentElement, isDark ? dark : light, { duration: 0.4 });
});

Page transition

Fade the page in on load and out before following a link. Modifier clicks still open new tabs.

index.html
<main class="page">
  <h1>Pricing</h1>
  <a class="page-link" href="/about/">About</a>
</main>
page-transition.ts
import { animate } from "@animaly/dom";

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

animate(page, { opacity: [0, 1], y: [8, 0] }, { duration: 0.3, ease: "easeOut" });

document.querySelectorAll<HTMLAnchorElement>(".page-link").forEach((link) => {
  link.addEventListener("click", async (event) => {
    if (event.metaKey || event.ctrlKey || event.shiftKey) return;
    event.preventDefault();
    await animate(page, { opacity: 0, y: -8 }, { duration: 0.2, ease: "easeIn" });
    window.location.href = link.href;
  });
});

Marquee

Duplicate the items and move the track by half its width on a linear loop. Hovering pauses it.

index.html
<div class="marquee" style="overflow:hidden;white-space:nowrap">
  <div class="marquee-track" style="display:inline-flex;gap:48px">
    <span>Springs</span><span>Tweens</span><span>Keyframes</span><span>Inertia</span><span>Timelines</span>
    <span aria-hidden="true">Springs</span><span aria-hidden="true">Tweens</span><span aria-hidden="true">Keyframes</span><span aria-hidden="true">Inertia</span><span aria-hidden="true">Timelines</span>
  </div>
</div>
marquee.ts
import { animate } from "@animaly/dom";

const track = document.querySelector<HTMLElement>(".marquee-track")!;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;

// The track holds the items twice, so moving it by half its width loops without a jump.
if (!reduce) {
  const marquee = animate(track, { x: [0, -track.scrollWidth / 2] }, { duration: 18, ease: "linear", repeat: Infinity });
  track.addEventListener("pointerenter", () => marquee.pause());
  track.addEventListener("pointerleave", () => marquee.play());
}

Headline word reveal

Split a headline into words and stagger them in. Screen readers still get the full sentence through aria-label.

index.html
<h2 class="headline">Motion that keeps its velocity when you change your mind.</h2>
word-reveal.ts
import { animate, stagger } from "@animaly/dom";

const headline = document.querySelector<HTMLElement>(".headline")!;
const label = headline.textContent ?? "";
headline.setAttribute("aria-label", label);

const words = label.split(" ").map((word) => {
  const span = document.createElement("span");
  span.textContent = word;
  span.style.display = "inline-block";
  span.setAttribute("aria-hidden", "true");
  return span;
});
headline.replaceChildren(...words.flatMap((span, index) => (index ? [document.createTextNode(" "), span] : [span])));

animate(words, { y: [12, 0], opacity: [0, 1] }, { duration: 0.5, ease: "easeOut", delay: stagger(0.04) });

Shuffle a grid

Measure every tile, reorder the DOM, then spring each tile from its old position to its new one.

index.html
<ul class="grid" style="display:grid;grid-template-columns:repeat(3,80px);gap:8px;padding:0;list-style:none">
  <li class="tile" style="height:80px;background:#7c3aed"></li>
  <li class="tile" style="height:80px;background:#ec4899"></li>
  <li class="tile" style="height:80px;background:#0ea5e9"></li>
  <li class="tile" style="height:80px;background:#16a34a"></li>
  <li class="tile" style="height:80px;background:#f59e0b"></li>
  <li class="tile" style="height:80px;background:#111111"></li>
</ul>
<button class="shuffle">Shuffle</button>
shuffle-grid.ts
import { animate } from "@animaly/dom";

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

// FLIP: measure, reorder, then animate each tile from its old position to the new one.
function shuffle(): void {
  const tiles = Array.from(grid.children) as HTMLElement[];
  const before = new Map(tiles.map((tile) => [tile, tile.getBoundingClientRect()]));

  tiles
    .map((tile) => ({ tile, order: Math.random() }))
    .sort((a, b) => a.order - b.order)
    .forEach(({ tile }) => grid.append(tile));

  tiles.forEach((tile) => {
    const old = before.get(tile)!;
    const now = tile.getBoundingClientRect();
    animate(tile, { x: [old.left - now.left, 0], y: [old.top - now.top, 0] }, { type: "spring", stiffness: 300, damping: 28 });
  });
}

document.querySelector(".shuffle")!.addEventListener("click", shuffle);
shuffle();