Film om brugergrænseflader
Produkt- og funktionsgennemgange: en fast flade, en liste over kameraindstillinger, der navngiver motivet, og en markør, som en kontrol kan aflæse sin egen trykkede tilstand fra.
En film, der viser en brugergrænseflade, er en anden opgave end et titelkort. Noget skal placeres, noget skal se på det, og en markør skal bevæge sig hen til det og trykke på det — og alle tre skal være enige om, hvor »det« er. Sceneværktøjerne består af tre komponenter og én regel: Angiv alles position én gang i fladens pixels, og lad kameraet og markøren læse den tabel.
Alt her kommer fra @zxn/motion-core.
Fladen
<Stage width height layout> er en flade med fast størrelse og sit eget koordinatsystem. layout er én tabel med { x, y, width, height }-rektangler med id som nøgle, og <StageItem id> tegner sig selv ved det rektangel, som dets id angiver.
const layout = {
window: { x: 160, y: 120, width: 1600, height: 840 },
"export-button": { x: 1568, y: 146, width: 160, height: 44 },
};
En scene uden shots er blot en flade: Elementerne står i deres rektangler, og filmen tegnes i 1:1. Med shots placerer scenen et <Camera> foran fladen og viderefører sit layout, så kameraindstillinger og markørens nøglepunkter kan bruge id'er. <StageItem> kaster en fejl, der navngiver id'et, hvis det ikke har en post, i stedet for at tegne ved origo.
Intet måler DOM'en. Det er den beslutning, resten af værktøjerne bygger på. Layout er ikke tilgængeligt før filmens første optegning: Et kamera, der kaldte getBoundingClientRect(), ville ikke have noget at aflæse på billede nul og ville indramme det forkerte på første billede i hver eksport og derefter rette sig på billede ét. Værre endnu ville filmens billede afhænge af den rækkefølge, billeder blev efterspurgt i, hvilket er præcis det, biblioteket afviser. At deklarere rektanglet én gang betyder også, at »kameraet ser på det, markøren lander på det, og det tegner sig selv som trykket« er én samlet sandhed frem for tre koordinater, der holdes i takt manuelt.
Kameraet
<Camera shots> — eller shots-proppen på <Stage> — tager en liste over indstillinger i den rækkefølge, de sker. Hver indstilling er:
| Felt | Betydning |
|---|---|
at |
Det billede, hvor kameraet er ankommet. Et ikke-negativt heltal; indstillinger skal listes i rækkefølge. |
look |
Et punkt på fladen, { x, y }, eller id'et på noget, scenen har placeret. |
zoom |
Hvor tæt på. Udelades den for et navngivet element, indrammer kameraet elementets grænser plus padding; udelades den for et punkt, vises fladen i 1:1. Skal være positiv. |
tilt |
{ x, y } rotationsgrader til et perspektiv som på et skråtstillet kort. |
settle |
Hvordan kameraet rejste hertil fra den foregående indstilling: "spring" (standard), "ease" eller "cut". |
hold |
Antal billeder i stilstand efter ankomst, før rejsen til næste indstilling. |
padding |
Pixels på fladen, der holdes fri omkring et indrammet element. Standard er 64. |
Før første indstilling står kameraet allerede på den, og efter sidste bliver det stående, så der ikke er nogen drift ind i eller ud af filmen. Mellem to indstillinger holder det stille i den første indstillings hold og bevæger sig derefter, så det ankommer præcis ved den næste indstillings at. settle tilhører indstillingen, der rejses til, fordi den beskriver ankomsten.
Zoom interpoleres multiplikativt: En bevægelse fra 1× til 4× er halvvejs ved 2×, ikke 2,5×, så den bruger lige lang tid på 1–2 og 2–4. Lineært zoom opleves som et ryk, der starter hurtigt og kryber af sted til sidst.
contain forhindrer en indstilling i at vise noget uden for fladens kant. En indstilling, der indrammer noget tæt på et hjørne, ville ellers placere fladens kant midt i billedet med ingenting på den anden side; begrænsningen afgrænser i stedet visningsområdets centrum, så motivet står forskudt fra midten, og billedet forbliver fyldt. En akse, visningsområdet ikke kan være indenfor, centreres, fordi ingen begrænsning ville hjælpe.
idle tilføjer en drift med fast startværdi — { amplitude, rate, seed }, som standard seks pixels på fladen ved 0,08 cyklusser i sekundet — så en fastholdt indstilling ikke er helt livløs. Det er åndedræt, ikke bevægelse. vignette mørkner visningsområdets kanter og er som standard slået fra.
Én bevidst bevægelse pr. indstilling, og hold så stille. Et kamera, der ankommer og allerede er på vej videre, virker nervøst, og et publikum, der ikke er færdigt med at læse det sidste, følger ikke med til det næste. reviewCameraShots(shots, frameRate) kontrollerer dette: Den rapporterer indstillinger, der ligger tættere end cirka fire tiendedele sekund, og pauser, der ikke efterlader tid til bevægelsen. Advarslerne vises på kameraelementet som data-zxn-warnings frem for at blive kastet som fejl, fordi en bevidst hurtig panorering mellem to slag godt kan være tilsigtet.
useCamera() returnerer det aktuelle { x, y, zoom, tiltX, tiltY } til alt, der skal reagere på visningen — for eksempel en etiket, der toner ud, når kameraet rykker tættere på.
Parallakse
depth på <StageItem> er en modbevægelse til kameraets bevægelse: 0 bevæger sig med fladen, mens 1 er så langt væk, at kameraets bevægelse slet ikke flytter det. Placer en baggrund ved 0,4–0,6, så føles fladen, som om den står foran noget i stedet for at være klistret på det. Brug det til baggrundstekstur, ikke til noget, markøren rører — en kontrol, der glider i forhold til den flade, den tilhører, ser defekt ud.
Markøren
<Pointer path> tegner en markør i fladens koordinatsystem, så kameraet tager den med: Ved 2× zoom er markøren dobbelt så stor på skærmen, som den ville være i en rigtig optagelse. size og color er de eneste andre props.
En sti er en liste af nøglepunkter, hvert med { at, to, press?, drag? }. to er et punkt eller et element-id; at er det billede, hvor markøren ankommer; press klikker ved ankomst; drag holder knappen nede fra dette nøglepunkt til det næste.
Bevægelsen mellem nøglepunkter accelererer og bremser blødt og buer lidt væk fra den rette linje — buen er størst halvvejs og nul i begge ender, så markøren stadig ankommer præcis på målet. Retningen skifter efter nøglepunktets indeks frem for tilfældigt, så en sti altid buer samme vej. En markør, der glider med konstant fart langs en ret linje, virker som et diagram, ikke som nogen, der bruger et produkt.
reviewPointerPath(path, frameRate, layout) advarer om bevægelser over 4200 pixels på fladen pr. sekund, som ikke længere virker håndstyrede, og om nøglepunkter på samme billede som det foregående, som giver teleportering. Som kameraets kontrol vises det på elementet frem for at blive kastet som en fejl.
usePointer(path) giver markørens tilstand i dette billede — x, y, pressed, pressPhase, ringPhase, dragging, over — samt pressedOn(id), der er sand på præcis de billeder, hvor et tryk på dette id holdes nede. Det er den vigtige del: En knap tegner sin egen trykkede tilstand ud fra samme sti, der bevæger markøren, så den trykkes ned på præcis det billede, markøren lander på den, og bliver i takt, når du flytter nøglepunktet.
En produktgennemgang
Her er hele sættet i arbejde: et efterlignet programvindue, fire kameraindstillinger og en markør, der går til eksportknappen og trykker på den.
import { defineFilm, Pointer, Stage, StageItem, usePointer, useTimeline } from "@zxn/motion-core";
import type { CameraShot, PointerKey, StageLayout } from "@zxn/motion-core";
type Props = {
readonly appName: string;
readonly fileName: string;
readonly accent: string;
readonly surface: string;
};
const layout: StageLayout = {
backdrop: { x: 0, y: 0, width: 1920, height: 1080 },
window: { x: 160, y: 120, width: 1600, height: 840 },
toolbar: { x: 160, y: 120, width: 1600, height: 92 },
"export-button": { x: 1568, y: 146, width: 160, height: 44 },
sidebar: { x: 160, y: 212, width: 340, height: 748 },
canvas: { x: 500, y: 212, width: 1260, height: 748 },
artboard: { x: 620, y: 300, width: 1020, height: 560 },
};
const shots: readonly CameraShot[] = [
{ at: 0, look: "window", padding: 90, hold: 40 },
{ at: 110, look: "sidebar", padding: 36, hold: 30, settle: "ease" },
{ at: 210, look: "artboard", padding: 40, hold: 26 },
{ at: 300, look: "export-button", zoom: 2.4, hold: 40 },
];
const path: readonly PointerKey[] = [
{ at: 0, to: { x: 900, y: 1010 } },
{ at: 120, to: "sidebar" },
{ at: 225, to: "artboard" },
{ at: 318, to: "export-button", press: true },
];
const layers = ["Opening", "Sequence 01", "Callout", "Export preset", "Audio bed"];
const sans = "Inter, system-ui, sans-serif";
function ExportButton({ accent }: { readonly accent: string }) {
const down = usePointer(path).pressedOn("export-button");
return (
<div
style={{
width: "100%", height: "100%", borderRadius: 10,
background: accent,
display: "flex", alignItems: "center", justifyContent: "center",
font: `700 20px ${sans}`, color: "#0b0c0e",
opacity: down ? 0.82 : 1,
transform: `translateY(${String(down ? 2 : 0)}px) scale(${String(down ? 0.96 : 1)})`,
}}
>
Export
</div>
);
}
function Tour({ appName, fileName, accent, surface }: Props) {
const { frame } = useTimeline();
// The selected layer follows the cursor's visit to the sidebar.
const selected = frame >= 120 ? 1 : 0;
return (
<Stage width={1920} height={1080} layout={layout} shots={shots} idle={{ amplitude: 4, rate: 0.06, seed: "tour" }} contain vignette>
<StageItem id="backdrop" depth={0.5} style={{ background: "radial-gradient(circle at 30% 20%, #1b2030 0%, #08090c 70%)" }} />
<StageItem id="window" style={{ background: surface, borderRadius: 22, border: "1px solid rgba(255,255,255,0.09)" }} />
<StageItem id="toolbar" style={{ display: "flex", alignItems: "center", gap: 22, padding: "0 28px", borderBottom: "1px solid rgba(255,255,255,0.08)" }}>
<div style={{ display: "flex", gap: 10 }}>
{["#ff5f57", "#febc2e", "#28c840"].map((light) => (
<div key={light} style={{ width: 14, height: 14, borderRadius: "50%", background: light, opacity: 0.85 }} />
))}
</div>
<div style={{ font: `600 20px ${sans}`, color: "#f3efe7" }}>{appName}</div>
<div style={{ font: `400 18px ${sans}`, color: "rgba(243,239,231,0.45)" }}>{fileName}</div>
</StageItem>
<StageItem id="sidebar" style={{ display: "flex", flexDirection: "column", gap: 10, padding: 20, borderRight: "1px solid rgba(255,255,255,0.08)" }}>
{layers.map((layer, index) => (
<div
key={layer}
style={{
display: "flex", alignItems: "center", gap: 12,
height: 52, padding: "0 14px", borderRadius: 10,
background: index === selected ? "rgba(255,255,255,0.09)" : "transparent",
font: `500 17px ${sans}`,
color: index === selected ? "#f3efe7" : "rgba(243,239,231,0.55)",
}}
>
<div style={{ width: 8, height: 8, borderRadius: 2, background: index === selected ? accent : "rgba(243,239,231,0.3)" }} />
{layer}
</div>
))}
</StageItem>
<StageItem id="canvas" style={{ display: "flex", alignItems: "center", justifyContent: "center", background: "#0d0f13" }} />
<StageItem id="artboard" style={{ background: "#f6f4ef", borderRadius: 14, padding: 56, display: "flex", flexDirection: "column", gap: 26 }}>
<div style={{ width: 300, height: 14, borderRadius: 7, background: accent }} />
<div style={{ font: `800 64px ${sans}`, color: "#14161a", letterSpacing: "-0.03em" }}>Ship it</div>
<div style={{ width: 640, height: 12, borderRadius: 6, background: "rgba(20,22,26,0.18)" }} />
<div style={{ width: 520, height: 12, borderRadius: 6, background: "rgba(20,22,26,0.12)" }} />
</StageItem>
<StageItem id="export-button"><ExportButton accent={accent} /></StageItem>
<Pointer path={path} size={36} />
</Stage>
);
}
export const films = [defineFilm({
id: "product-tour",
title: "Product tour",
width: 1920,
height: 1080,
frameRate: 30,
frames: 380,
component: Tour,
defaultProps: { appName: "Studio", fileName: "launch-film.zxn", accent: "#7a6cff", surface: "#14161a" },
controls: {
appName: { type: "text", label: "App name" },
fileName: { type: "text", label: "File name" },
accent: { type: "color", label: "Accent" },
surface: { type: "color", label: "Window surface" },
},
})];
Layouttabellen. Syv rektangler, og ingen pixelkoordinater optræder andre steder i filmen. At flytte eksportknappen er én ændring: Kameraets sidste indstilling, markørens sidste nøglepunkt og knappens egen position følger alle med, fordi alle tre angiver "export-button".
Listen over kameraindstillinger. Fire indstillinger, hver med én bevægelse og en pause. Filmen åbner på hele vinduet med 90 pixels luft omkring — ingen zoom, så kameraet beregner det zoom, der passer til grænserne — holder stille i 40 billeder, så publikum kan tage det ind, bevæger sig så blødt over til sidepanelet, fjedrer til tegnefladen og rykker til sidst tættere på knappen med 2,4×. reviewCameraShots(shots, 30) returnerer intet for denne liste: Afstandene er 110, 100 og 90 billeder, og ingen pause opsluger sin egen bevægelse. contain betyder især noget i sidste indstilling, fordi knappen sidder tæt på øverste højre hjørne, og et ubegrænset kamera ved 2,4× ville vise 300 pixels tomrum uden for fladen.
Baggrunden. Ét <StageItem> ved depth={0.5}, der fylder fladen og modvirker halvdelen af hver kamerabevægelse. Det er det eneste i filmen, der ikke er en del af brugergrænsefladen, og det forhindrer indrykningerne i at føles som skalering af et fladt billede.
Markøren. Fire nøglepunkter. Den starter under vinduet, ankommer til sidepanelet på billede 120, krydser til tegnefladen og bevæger sig så til knappen og trykker på billede 318 — inden for sidste indstillings pause på 40 billeder, så kameraet allerede står stille, når klikket lander. Intet på stien overstiger 250 pixels på fladen pr. sekund, langt under den hastighed, hvor reviewPointerPath begynder at advare.
Knappen. usePointer(path).pressedOn("export-button") er hele dens tilstand. Der står intet billednummer for trykket i komponenten: Flyt nøglepunktet til billede 280, og knappen trykkes ned på 280. Sidepanelets valgte række bruger til sammenligning en almindelig sammenligning af billeder — den udgave skal du selv holde i takt, og den går galt, når tidsforløbet ændres.
Praktiske bemærkninger
Lav fladen i filmens størrelse, medmindre du faktisk skal tættere på end 1:1; en flade på 1920×1080 i en film på 1920×1080 betyder, at første indstilling kan vise originalstørrelsen frem for en lidt uskarp opskalering. Byg den efterlignede brugergrænseflade med almindelige elementer og inline-stile, og undgå sløring og store skygger på alt, der gentages — se Ydeevne.
Dybdeskarphed og bevægelsessløring er bevidst udeladt. Begge kræver en reel efterbehandling af det renderede billede, som hører til i rendereren, ikke i en DOM-transformation; den visning, useCamera() returnerer, indeholder alt, en sådan behandling skulle bruge, så den kan tilføjes uden at ændre filmen.
Næste skridt
- Kompositions-API for den fulde reference.
- Tempo og musik, hvis gennemgangen skal klippes til et musiknummer.
- Kontroller og egenskaber til at styre gennemgangens tekst fra JSON.
- Opskrifter for flere komplette film.