Twelve motion skills for Claude Code, with their source on GitHub. Each one builds its animation into a Next.js project in one pass, and each was tested in three fresh projects before I published it.
A skill is a set of instructions Claude Code follows. It reads your project, writes the component where your project keeps components, uses it where you ask, and runs its own checks at the end. Copy the twelve into your commands folder and call one by name:
git clone https://github.com/lscottart/animation-library.gitcp animation-library/skills/*.md ~/.claude/commands/animations/ # then, inside a Next.js project, in Claude Code:/animations:focus-dim the project grid on the home pageThey're the motion details I reach for on studio sites. None of them is a template: each is a small primitive that fits many places, and each runs in the App Router or the Pages Router, with or without Tailwind.
A skill ships only when its code, applied word for word to projects nobody prepared for it, passes everything below. Each suite measures the motion frame by frame: where the fill is mid-slide, which digit rolls first, whether a paused video still asks for frames.
The controls are where the notes below come from. Every "what broke" is a number from a run, and anything we couldn't measure is labelled as a guard in the skill.
Each line of a heading rises out of its own mask, staggered, with GSAP's SplitText. Then it hands the text back to React when it lands, which is the hard part.
SplitText.create(el, { type: "lines", mask: "lines", autoSplit: true, onRevert: keepReactNodes(el), // put back the DOM nodes React rendered onSplit(self) { tween = gsap.from(self.lines, { y: (_i, line) => line.offsetHeight + 2 * room, duration, stagger, ease, delay, onComplete: () => self.revert(), // plain text again }); return tween; },});What broke: SplitText's revert rebuilds the element from an HTML string, so React lost its nodes. Links full-reloaded the page, click handlers died and updates vanished. On a busy page the reveal also skipped 84 to 88% of its rise, until it started on its own first frame.
A link list whose hover fill slides from row to row like one bar, after the Obys DES project index. It's pure CSS: each row's fill grows from the top as you enter and collapses toward the bottom as you leave, so the two together read as one bar moving.
.sfl-fill { transform: scaleY(0); transform-origin: 50% 100%; /* idle: collapses to the bottom edge */ transition: transform 500ms var(--sfl-ease); /* transform only */}.sfl-row:hover .sfl-fill { transform: scaleY(1); transform-origin: 50% 0%; /* hover: grows from the top edge */}What broke: transition: all also animates the origin, and each row held its own bar floating mid-row. One bar became two.
A link's letters roll up one line in a 15 ms wave, while a dimmer copy rises from below. The copy is generated content, so the label is in the page once, for copying and for screen readers.
.roll-letter { transition: transform 0.3s cubic-bezier(0.76, 0, 0.24, 1); transition-delay: calc(var(--i) * 15ms); /* the wave, any length */}.roll-letter::after { content: attr(data-ch) / ""; /* kept out of the accessible name */ position: absolute; top: 100%; opacity: 0.5;}.roll-link:hover .roll-letter { transform: translateY(-100%);}What broke: splitting the label with for…of broke 13 graphemes into 18, so accents and emoji rolled in pieces.
The DES "Log In" button: the fill only ever travels one way. Hover sends it out the top, leaving brings it back in from the bottom. I measured the timing frame by frame from a capture: out in 221 ms, back in 207 ms.
const cover = () => { if (progress(active) <= 0) { move(active, 0, timing.cover); // still arriving: keep coming forward } else { // on its way out: let it go, and bring the other panel in behind it active = 1 - active; place(active, -101); move(active, 0, timing.cover); }};const clear = () => move(active, 101, timing.clear); // always forwardWhat broke: with one panel, coming back mid-wipe made the fill crawl back the way it came, in all three browsers. Two panels mean nothing ever reverses.
A rail of section cards that follows your reading on a soft spring, with a viewfinder on the current section. It's the rail my own field notes had before they became panels.
// a damped spring, four sub-steps per frame so a long frame can't blow it upconst spring = (pos, vel, goal, w, z, dt) => { for (let s = 0; s < 4; s++) { const h = dt / 4; vel += (w * w * (goal - pos) - 2 * z * w * vel) * h; pos += vel * h; } return [pos, vel];};// the strip: 0.8 Hz, damping 0.55. Once everything settles, the loop sleeps.What broke: a rail that never sleeps asks for 61 to 167 frames a second while you read a still page.
A word like "(View)" rolls into a one-line window at the cursor over anything marked for it, and trails the pointer a step behind. It's white with a difference blend, so one label reads on paper and on a dark photograph.
const evaluate = (el) => { const item = el ? el.closest("[data-cursor-label]") : null; if (state === "shown") { if (item === shownFor) return; if (item) pending = item; // the next label waits for this one to leave exit(); } else if (state === "exiting") { if (item === shownFor) { state = "shown"; // came back: catch the roll, don't replay it gsap.to(text, { yPercent: 0, duration: 0.25, ease: "expo.out" }); } else pending = item; } else if (item) enter(item);};What broke: without the queue, two items that touch swapped the label in place, and coming back mid-exit replayed the roll. A grid with gaps hides this, so the test grid has items that touch.
A picture moves inside a still frame as the frame crosses the screen. There are three modes: drift; zoom, 1.45 to 1, measured on Telha Clarke; and settle, 1.12 to 1 over 1.8 s, measured on OH Architecture.
const bleed = travel + 0.02; // extra picture above and below the frame// drift: the layer is (1 + 2 × bleed) of the frame's heightconst pct = (travel / (1 + 2 * bleed)) * 100;gsap.fromTo(layer, { yPercent: -pct }, { yPercent: pct, ease: "none", scrollTrigger: { trigger: frame, start: "top bottom", end: "bottom top", scrub: true },});What broke: with no bleed, the frame's background showed at an edge as soon as the picture moved, in every browser.
A statement fills from grey to ink, character by character, at the pace you scroll. The characters are split in React on the server, so the sentence is in the HTML and nothing rewrites the DOM.
// one span per user-perceived character, on the server and in the browser alikeconst characters = (text) => Array.from(new Intl.Segmenter(undefined, { granularity: "grapheme" }).segment(text), (s) => s.segment); gsap.fromTo(chars, { color: from }, { color: to, ease: "none", stagger: 0.05, scrollTrigger: { trigger: root, start: "top 78%", end: "bottom 42%", scrub: 0.4 },});What broke: Array.from split a decomposed é into a letter and a floating accent, an emoji into four pieces and a flag into two letters.
One highlight travels between a set of items and resizes to each, instead of every item owning its own hover. The block is a second copy of the row, paper on ink, clipped to the highlight, so a word under the block's edge is half ink, half paper.
const b = boxIn(nav, link); // the item's box inside the navconst clip = `inset(${b.t}px ${W - b.l - b.w}px ${H - b.t - b.h}px ${b.l}px)`;// nothing was highlighted: appear on the item, don't slide in from elsewhereif (wasHidden) gsap.set(block, { clipPath: clip });gsap.to(block, { clipPath: clip, opacity: 1, duration: 0.5, ease: "expo.out", overwrite: true });What broke: without that set, the block slid in from its last position. Without a CSS rule for the first paint, the current page had no highlight until JavaScript ran.
Point at one item in a group and its siblings step back to a quarter opacity and grey. It's pure CSS with :has() and ships no JavaScript.
.focus-dim > * { transition: opacity 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) 0.08s, filter 0.5s cubic-bezier(0.2, 0.7, 0.2, 1) 0.08s;}@media (hover: hover) { .focus-dim:has(> :hover) > :not(:hover) { opacity: var(--fd-dim, 0.25); filter: var(--fd-filter, grayscale(1)); transition-delay: 0s; }}What broke: without the 80 ms wait on the way back, a pointer pausing in the 24 px gap between two cards flashed the dimmed ones from 25% back up to 60%. Without the hover guard, one tap on a phone left three of four cards dimmed.
Digits roll to a new value, right-most first, while the colon and the letters stay put. Each digit is a CSS-generated strip from 0 to 9, moved by one custom property, so the page's text holds the value once.
.number-roll__col { clip-path: inset(0); /* not overflow: hidden, which lifts the baseline */ font-variant-numeric: tabular-nums; /* the digits only */}.number-roll__col::before { content: "0\A 1\A 2\A 3\A 4\A 5\A 6\A 7\A 8\A 9"; white-space: pre; transform: translateY(calc(var(--d) * -1em)); transition: transform 1.1s cubic-bezier(0.16, 1, 0.3, 1) calc(var(--i) * 60ms);}What broke: overflow: hidden moved the digits 15 px off the baseline at 96 px. Tabular figures on the whole number gave this site's typeface a colon three times its width, so 4:38 read as "4 : 38".
A video card holds its poster until you point at it, then plays. Leave, and it pauses on the frame it reached, with a hairline along the bottom showing where the loop is. On a phone it plays while it's mostly in view.
// the hairline only updates while the video plays: a paused card costs nothingconst draw = () => { bar.style.transform = `scaleX(${v.currentTime / v.duration})`; raf = requestAnimationFrame(draw);};v.addEventListener("playing", () => (raf = requestAnimationFrame(draw)));v.addEventListener("pause", () => cancelAnimationFrame(raf));What broke: a loop left running asked for 61 frames a second per paused card, 165 in Firefox. And with a WebM listed first, WebKit said it could play it and then stalled with no error, so it never fell back to the MP4.