# 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.

Canonical: https://zxn.studio/da/motion/thinking-in-frames

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.

```tsx
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:

```tsx
  // 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:

```tsx
        <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

```ts
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:

```tsx
  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:

```tsx
      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:

```tsx
  // 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:

```tsx
  // 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:

```tsx
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" },
  },
})];
```

@video(checklist)

**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:

```tsx
  // 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](/da/motion/composition-api) — den fulde reference.
- [Tegn på canvas](/da/motion/canvas) — når et felt af mange ting hører hjemme på ét `<Canvas2D>`.
- [Tempo og musik](/da/motion/tempo-and-music) — placering af sekvenser på takter og slag.
- [Brug af pakker](/da/motion/using-packages) — styring af afspilningspositionen i GSAP, Lottie, d3 og simplex-noise ud fra billednummeret.
- [Ydeevne](/da/motion/performance) — hvad et billede koster.
- [Opskrifter](/da/motion/recipes) — de verificerede eksempler, der citeres på denne side.

