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.
<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>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.
<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>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.
<section style="height:120vh"></section>
<article class="reveal">First block</article>
<article class="reveal">Second block</article>
<article class="reveal">Third block</article>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.
<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>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 });
});Swipe carousel with snapping
Inertia with modifyTarget picks a resting point on a slide, so a flick always stops on a whole slide.
<div class="carousel" style="overflow:hidden;width:320px;touch-action:pan-y">
<div class="track" style="display:flex">
<div class="slide" style="flex:0 0 320px;height:200px;background:#7c3aed"></div>
<div class="slide" style="flex:0 0 320px;height:200px;background:#ec4899"></div>
<div class="slide" style="flex:0 0 320px;height:200px;background:#0ea5e9"></div>
<div class="slide" style="flex:0 0 320px;height:200px;background:#16a34a"></div>
</div>
</div>import { animate, motionValue } from "@animaly/dom";
const carousel = document.querySelector<HTMLElement>(".carousel")!;
const track = document.querySelector<HTMLElement>(".track")!;
const width = carousel.clientWidth;
const last = -(track.children.length - 1) * width;
const x = motionValue(0);
x.on("change", (latest) => {
track.style.transform = "translateX(" + latest + "px)";
});
let startX = 0;
let startValue = 0;
let previous = { x: 0, t: 0 };
let velocity = 0;
carousel.addEventListener("pointerdown", (event) => {
carousel.setPointerCapture(event.pointerId);
x.stop();
startX = event.clientX;
startValue = x.get();
previous = { x: event.clientX, t: event.timeStamp };
velocity = 0;
});
carousel.addEventListener("pointermove", (event) => {
if (!carousel.hasPointerCapture(event.pointerId)) return;
x.set(Math.min(0, Math.max(last, startValue + event.clientX - startX)));
const seconds = Math.max(0.001, (event.timeStamp - previous.t) / 1000);
velocity = (event.clientX - previous.x) / seconds;
previous = { x: event.clientX, t: event.timeStamp };
});
carousel.addEventListener("pointerup", () => {
animate(x, x.get(), {
type: "inertia",
velocity,
power: 0.3,
min: last,
max: 0,
// Snap the resting point to the nearest slide.
modifyTarget: (ideal) => Math.round(ideal / width) * width,
});
});Theme color transition
Animate CSS variables on the root element; everything that reads them changes color together.
:root {
--page: #f7f6f3;
--text: #111111;
}
body {
background: var(--page);
color: var(--text);
}<button class="theme-toggle">Toggle theme</button>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.
<main class="page">
<h1>Pricing</h1>
<a class="page-link" href="/about/">About</a>
</main>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.
<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>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.
<h2 class="headline">Motion that keeps its velocity when you change your mind.</h2>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) });Shrinking sticky header
Shrink the header and its logo once the page scrolls past a threshold, and only animate when the state changes.
<header class="site-header" style="position:sticky;top:0;height:80px;display:flex;align-items:center;padding:0 24px;background:#fff">
<span class="logo" style="display:inline-block;transform-origin:left">animaly</span>
</header>
<main style="height:300vh"></main>import { animate } from "@animaly/dom";
const header = document.querySelector<HTMLElement>(".site-header")!;
const logo = document.querySelector<HTMLElement>(".logo")!;
const options = { type: "spring", stiffness: 320, damping: 32 } as const;
let compact = false;
function update(): void {
const next = window.scrollY > 40;
if (next === compact) return;
compact = next;
animate(header, { height: compact ? 56 : 80, boxShadow: compact ? "0 1px 0 rgba(0, 0, 0, 0.08)" : "0 0 0 rgba(0, 0, 0, 0)" }, options);
animate(logo, { scale: compact ? 0.85 : 1 }, options);
}
window.addEventListener("scroll", update, { passive: true });
window.scrollTo(0, 200);
update();Shuffle a grid
Measure every tile, reorder the DOM, then spring each tile from its old position to its new one.
<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>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();