Tænk i billeder
Hvorfor en film skal være en ren funktion af sit billednummer, hvilke hooks og primitiver der gør det praktisk, og hvordan bevægelser bliver lette at aflæse.
De fleste animationer, du tidligere har skrevet, fik besked på at starte. Du tilføjede en CSS-overgang, kaldte play() på en tidslinje eller kørte en requestAnimationFrame-løkke, der flyttede en værdi en smule ved hvert tick. Tiden gik frem af sig selv, og billedet fulgte med.
En film fungerer omvendt. Intet starter, intet afspilles, og intet tikker. En vært vælger et billednummer, monterer træet, tager billedet og kasserer træet. Billede 47 er et spørgsmål — hvordan ser dette ud ved billede 47? — og filmens eneste opgave er at svare.
Billeder kommer i vilkårlig rækkefølge
Det er ikke et spørgsmål om stil. Tre ting beder om billeder, og kun én af dem gør det i rækkefølge:
- afspilleren, når nogen trækker i tidslinjeskyderen — baglæns, i spring og til vilkårlige steder;
- forhåndsvisningen, som kan blive monteret helt forfra ved det billede, afspilningsmarkøren står på;
- eksporten, som går gennem 0, 1, 2, …, men genmonterer ved hvert billede.
En akkumuleret værdi er kun korrekt, hvis alle tidligere billeder blev kørt først. Trækker du afspilningsmarkøren forbi den, bliver den forkert; eksporterer du den, stemmer den ikke med det, du godkendte i forhåndsvisningen. Derfor er reglen selve designet:
Hvert billede beregnes ud fra billednummeret og props og intet andet.
Gør du det rigtigt, er forhåndsvisning, flytning af afspilningsmarkøren og eksport samme billede i kraft af konstruktionen, ikke af tests.
Hvad det udelukker
Her er en film, der gør alt forkert. Den består typekontrollen, den ser endda fin ud, hvis du trykker afspil fra begyndelsen, og den tåler ikke, at du trækker i tidslinjeskyderen.
import { useEffect, useState } from "react";
import { FullFrame, defineFilm } from "@zxn/motion-core";
type Props = { readonly label: string; readonly accent: string };
function Drifting({ label, accent }: Props) {
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
const startedAt = Date.now();
const timer = setInterval(() => setElapsed(Date.now() - startedAt), 16);
return () => clearInterval(timer);
}, []);
return (
<FullFrame style={{ background: "#0b0c0e", alignItems: "center", justifyContent: "center" }}>
<h1 style={{ fontSize: 140, color: accent, opacity: Math.min(1, elapsed / 1000) }}>{label}</h1>
</FullFrame>
);
}
export const films = [defineFilm({
id: "drifting",
title: "Drifting",
width: 1920,
height: 1080,
frameRate: 30,
frames: 90,
component: Drifting,
defaultProps: { label: "Wrong", accent: "#ff6f61" },
controls: { label: { type: "text", label: "Label" }, accent: { type: "color", label: "Accent" } },
})];
Kontrolværktøjet markerer alle tre vaner i den, og listen over det, værktøjet markerer, opsummerer fint, hvad en film ikke må gøre:
| Undgå | Gør dette i stedet |
|---|---|
Date.now(), new Date(), performance.now() |
useTimeline().frame eller useVirtualTime().seconds |
setInterval, requestAnimationFrame |
Beregn hele billedet ud fra billednummeret |
useState, useReducer til at videreføre værdier mellem billeder |
Udled værdien; der er intet at videreføre |
Math.random() |
seededRandom(seed) |
| En støjgenerator uden startværdi | createNoise3D(() => seededRandom("field")) |
CSS transition, animation, @keyframes |
Interpoler selv værdien, og sæt den inline |
timeline.play(), lottie.play() |
Opret den pauset, og sæt dens position ved hvert billede |
Det sidste er det generelle tilfælde. Et bibliotek kan bruges i en film, hvis dets afspilningsposition kan sættes, og er ubrugeligt, hvis det kun kan afspille. GSAP opfylder kravet, og mønstret er to linjer:
// After the build effect on the first frame, and on every frame after it.
useLayoutEffect(() => { timeline.current?.seek(seconds, false); });
Lottie opfylder det gennem goToAndStop(frame, true), et videoelement gennem currentTime og Web Animations API gennem sit eget currentTime. Et bibliotek, hvis eneste indgang er »start«, gør ikke.
React-tilstand er ikke forbudt, fordi tilstand er dårligt; den er forbudt, fordi en film ikke har noget sted at opbevare den. Komponenten monteres fra ny til et billede, den måske aldrig ser igen. useLayoutEffect er fint og ofte nødvendigt — både Canvas2D og GSAP-opskriften bruger det — fordi det kører efter opdateringen og før værtens optagelse og derfor stadig tilhører det efterspurgte billede.
Tidshooks
useTimeline() er den, du kommer til at bruge mest. Den returnerer { frame, absoluteFrame, film }. frame er lokalt for den nærmeste omsluttende <Cue>; absoluteFrame er altid filmens eget. film indeholder width, height, frameRate, frames og tempo, hvis et er angivet.
useFilmConfig() returnerer kun film-delen til en komponent, der skal bruge størrelsen eller billedfrekvensen, men ikke tiden.
useVirtualTime() returnerer { frame, seconds, milliseconds } for det billede, der tegnes. Brug den, når noget ønsker tid i sekunder frem for billeder — oftest til at sætte et biblioteks afspilningsposition. Det er den ærlige udgave af Date.now(): Den fungerer uændret i tests, i editorens forhåndsvisning og uden for renderingens sandkasse.
Grundelementer til tidsstyring
Fire komponenter placerer ting i tiden, uden at nogen komponent behøver at vide, hvor den selv ligger.
<FullFrame> fylder filmen og er en flex-container.
<Cue start length> viser sine børn i length billeder fra start — og, afgørende, genstarter frame ved nul indeni. En komponent, der er skrevet til at animere fra billede 0, animerer identisk, uanset hvor du placerer den; du tilføjer aldrig en forskydning manuelt og trækker aldrig en fra igen. layout="none" holder sekvensen i det overordnede elements layoutflow i stedet for at fylde billedet, og length er valgfri: Udelad den, så fortsætter sekvensen til slutningen. Med et deklareret tempo kan en sekvens i stedet placeres musikalsk:
<Cue key={word} startBar={index} lengthBars={1}>
<Hold frame> fryser sine børn ved ét billede — nyttigt til et stillbillede af noget animeret eller til at udforme en positur.
<Repeat every> gentager sine børns lokale tid, så en cyklus, der er skrevet én gang, afspilles, så længe du lader den være på skærmen.
Animationsmatematik
mapRange(frame, [0, 30], [0, 1], { clamp: true, ease: easing.easeOut });
springValue({ frame, frameRate: 30, stiffness: 170, damping: 26, clamp: true });
seededRandom("stream-7");
mapRange omsætter en værdi fra én liste af støttepunkter til en anden. Listerne skal have samme længde, mindst to punkter, og input skal være strengt stigende. To punkter er en rampe; fire er en hel indgang-pause-udgang på én læselig linje:
const slide = springValue({ frame, frameRate: film.frameRate, stiffness: 150, damping: 20, clamp: true });
const leave = mapRange(frame, [last - 16, last], [0, 1], { clamp: true, ease: easing.easeIn });
const text = mapRange(frame, [8, 22, last - 20, last - 8], [0, 1, 1, 0], { clamp: true, ease: easing.easeOut });
clamp: true fastholder slutværdierne uden for intervallet, hvilket næsten altid er det ønskede; uden den fortsætter linjen med at ekstrapolere, og opaciteten ender på 3.
easing indeholder linear, easeIn, easeOut og easeInOut, og ease tager enhver funktion fra fremdrift til fremdrift, så en tilpasset kurve blot er én closure.
springValue tager { frame, frameRate, from, to, mass, stiffness, damping, velocity, clamp } og returnerer fjederens position ved det billede. Den bruger en lukket formel: Den beregner den analytiske løsning for den dæmpede oscillator ved frame / frameRate i stedet for at føre en simulering frem trinvis. Det betyder noget af præcis én grund, og det er grunden til hele denne side — en trinvist simuleret fjeder ved kun, hvor den er, på grund af alle tidligere trin. Derfor giver det et andet billede at trække afspilningsmarkøren ind midt i bevægelsen end at afspille frem til den, og eksporten afviger fra forhåndsvisningen. En fjeder med en lukket formel har ingen hukommelse, der kan være forkert.
Lav dæmpning giver oversving og falder til ro. Høj dæmpning ankommer og stopper. clamp: true fjerner oversvinget helt, hvilket er rigtigt for opacitet og forkert for noget med vægt.
seededRandom(seed) hasher en streng eller et tal til et stabilt tal i [0, 1). Tricket er at bruge det, der identificerer det tegnede, som startværdi, inklusive billednummeret, når det skal ændre sig:
const value = seededRandom(`${frame}-${x}-${y}`);
Det er tv-støj, der flimrer på hvert billede, men alligevel er den samme støj, hver gang du renderer billede 40.
Gør bevægelser lette at aflæse
Alt ovenfor gør en film korrekt. Intet af det gør en film god. Resten af siden handler om håndværk, og det er lige så mekanisk, som det lyder — det er tallene, der adskiller en bevidst designet bevægelse fra noget, der ligner en værdi, som stiger.
Brug blød acceleration og opbremsning overalt. Intet i den fysiske verden starter og stopper ved fuld fart. En lineær bevægelse virker som et diagram; brug easing.easeOut til ankomster, easing.easeIn til afgange og easeInOut til en bevægelse, hvor begge ender er på skærmen. Hvis du bruger linear, bør det være til en vedvarende gentagelse, der ikke må pulsere ved samlingen.
Brug fjedre, når noget har vægt. En easing-kurve er en kurve, du har tegnet. En fjeder er en ting, der lander. Vælg springValue, når et objekt ankommer — et kort, et panel eller en titel, der falder ind — og tillad lidt oversving ved at holde damping lav i forhold til stiffness. Omkring stiffness: 150, damping: 20 giver en sikker ankomst; damping: 11 giver et hop, du bemærker:
// The drop: a loose spring, so it overshoots and settles like something with weight.
const drop = springValue({ frame, frameRate: fps, stiffness: 140, damping: 11 });
Hold stille længe nok til, at teksten kan læses. Den mest almindelige fejl i genererede animationer er tekst, der forsvinder, før nogen har læst den færdig. Afsæt cirka ét sekund pr. tre ord til stilstand på skærmen, efter indgangen er færdig, og før udgangen begynder. En linje med seks ord kræver to sekunders stilstand — 60 billeder ved 30 fps — og derfor er en navnebjælke 150 billeder og ikke 60. Skriv pausen som eksplicitte støttepunkter i stedet for at håbe, at mellemrummet mellem indgang og udgang er langt nok:
// Hold on the start time, sweep, then hold on the end so both can be read.
const progress = mapRange(frame, [24, film.frames - 36], [0, 1], { clamp: true, ease: easing.easeInOut });
Forskyd relaterede elementer med 2–4 billeder. En liste, hvor rækkerne ankommer samtidigt, er én begivenhed; en liste, hvor rækkerne ankommer med 20 billeders mellemrum, er en kø, du står og venter i. To til fire billeder ved 30 fps opleves som én bevægelse med en retning. <Cue> gør det enkelt, fordi hver række animerer fra sit eget billede nul:
import { Cue, FullFrame, defineFilm, springValue, useTimeline } from "@zxn/motion-core";
type Props = { readonly heading: string; readonly steps: string; readonly accent: string };
function Row({ label, accent }: { readonly label: string; readonly accent: string }) {
const { frame, film } = useTimeline();
const enter = springValue({ frame, frameRate: film.frameRate, stiffness: 180, damping: 18, clamp: true });
return (
<div style={{ display: "flex", alignItems: "center", gap: 28, opacity: Math.min(1, enter * 1.6), transform: `translateX(${(1 - enter) * 60}px)` }}>
<div style={{ width: 22, height: 22, borderRadius: 11, background: accent }} />
<span style={{ fontSize: 64, fontWeight: 500, color: "#c9c5bd" }}>{label}</span>
</div>
);
}
function Checklist({ heading, steps, accent }: Props) {
const { frame, film } = useTimeline();
const titleIn = springValue({ frame, frameRate: film.frameRate, stiffness: 160, damping: 20, clamp: true });
const rows = steps.split(",").map((step) => step.trim()).filter((step) => step.length > 0);
return (
<FullFrame style={{ background: "#0b0c0e", flexDirection: "column", justifyContent: "center", padding: "0 180px", gap: 40, fontFamily: "Inter, system-ui, sans-serif" }}>
<h1 style={{ margin: 0, fontSize: 104, fontWeight: 800, letterSpacing: "-0.03em", color: "#f3efe7", opacity: titleIn, transform: `translateY(${(1 - titleIn) * 40}px)` }}>{heading}</h1>
{rows.map((row, index) => (
<Cue key={row} start={26 + index * 3} layout="none">
<Row label={row} accent={accent} />
</Cue>
))}
</FullFrame>
);
}
export const films = [defineFilm({
id: "checklist",
title: "Checklist",
width: 1920,
height: 1080,
frameRate: 30,
frames: 150,
component: Checklist,
defaultProps: { heading: "Three steps", steps: "Record, Edit, Export", accent: "#2ec4b6" },
controls: {
heading: { type: "text", label: "Heading" },
steps: { type: "text", label: "Steps (comma separated)" },
accent: { type: "color", label: "Accent" },
},
})];
Forbered, skyd over, og lad resten følge efter. En bevægelse er tydeligere, hvis noget sker lige før, lige efter og et lille slag senere. Forberedelsen er nogle få billeder i den modsatte retning før bevægelsen. Oversving kommer gratis fra en fjeder. Efterbevægelsen er et sekundært element, der bruger samme fjeder nogle billeder efter det primære, hvilket får to dele til at se sammenhængende ud:
// The antennae follow the body a few frames late, which is what makes them look attached by springs.
const lag = springValue({ frame: frame - 5, frameRate: fps, stiffness: 90, damping: 7 });
const wobble = (1 - lag) * 38;
Bemærk teknikken: samme fjeder, forskudt med fem billeder. springValue begrænser internt negative billeder til nul, så det forsinkede element ganske enkelt står stille, indtil det er dets tur.
Lad afstand og varighed passe sammen. En bevægelse gennem hele billedet bør tage cirka 0,4 til 0,8 sekunder — 12 til 24 billeder ved 30 fps. Hurtigere end 12, og øjet kan ikke følge med; langsommere end 30, og filmen føles, som om den venter på noget. Korte bevægelser skal være korte: Et element, der glider 40 pixels på plads, har brug for 8–12 billeder, ikke de samme 20, som du gav bevægelsen hen over hele skærmen. Indgange, der tilbagelægger lang afstand, bør også tone ind, så øjet ikke skal følge noget, der krydser billedet med høj fart.
Én idé ad gangen. Hvis to ting bevæger sig samtidigt, vælger seeren én og overser den anden. Lad overskriften komme ind og falde til ro, og lad så undertitlen komme. Flyt kameraet, og afslør derefter det, det ser på. Med en film, hvor hvert billede kan vælges direkte, er det fristende at koreografere alt parallelt, fordi man kan; stå imod.
Animer ikke alt. Stilstand gør bevægelse tydelig. Et billede, hvor ét element bevæger sig, og otte står stille, styrer opmærksomheden; et billede, hvor alle ni bevæger sig, er støj. Hvis baggrunden driver, bør forgrunden stå stille og omvendt. Det samme gælder egenskaber: At animere position, skalering, rotation, farve og opacitet på ét element ligner sjældent fem gange så godt håndværk.
Foretræk transformationer og opacitet. De er billige, og rendereren betaler for layout og optegning af hvert billede. At animere bredde, højde, margin eller filter: blur() koster reel tid pr. billede, og sløring inde i en .map er den sikreste måde at få en film til at krybe af sted.
Næste skridt
- Kompositions-API — den fulde reference.
- Tegn på canvas — når et felt af mange ting hører hjemme på ét
<Canvas2D>. - Tempo og musik — placering af sekvenser på takter og slag.
- Brug af pakker — styring af afspilningspositionen i GSAP, Lottie, d3 og simplex-noise ud fra billednummeret.
- Ydeevne — hvad et billede koster.
- Opskrifter — de verificerede eksempler, der citeres på denne side.