motion-video-maker
A self-contained skill for authoring cinematic HTML animations and rendering them to frame-accurate MP4 video. One composition = one HTML file driven by a tiny deterministic timeline runtime.
Hard Rules — READ FIRST (non-negotiable)
These prevent the bugs we keep seeing in agent-generated videos.
scripts/lint.mjs enforces most of them and exits non-zero on violations.
| ❌ Don't | ✅ Do |
|---|---|
style="font-size: 180px" on a long heading |
use .title-2xl / .title-xl / .title-lg class (see Typography limits) |
.text-aurora (blue-cyan gradient) over a cool palette |
match palette family → use .text-on-cool instead |
data-background without data-palette and without data-colors |
always pick a named palette (cool-deep, warm-glow, etc.) |
style="position: relative" on a [data-background] element |
leave it alone — CSS already sets position: absolute |
Math.random() / Date.now() / new Date() inside <script> |
use __mvm.random("seed") / pass t from mvm-seek event |
odd data-width / data-height (e.g. 1921×1081) |
use even integers — H.264 refuses odd dimensions |
data-animation-out on a non-final scene without a <div data-transition> |
bridge the cut with a transition (pixel-dissolve / iris / glitch / wipe-left / flash) |
inline <style> with transition: / @keyframes |
use data-animation + the runtime — CSS transitions don't seek and break determinism |
| fade non-final scenes to opacity 0 | only the last scene may fade to black; everything else must hand off via a transition |
background-image: url(...) (CSS bg image, browser doesn't fire load event) |
use <img class="bg-img" style="position:absolute;inset:0;"> |
gsap.utils.random(...) / Math.random() inside any gsap.to/from/timeline value |
wrap with __mvm.random("seed") or pass a deterministic precomputed value — see GSAP integration |
setTimeout(() => gsap.to(...), 1000) / wall-clock-driven GSAP construction |
build the timeline at parse-time, anchor it with __mvmGsap.timeline({at: <s>}) so mvm-seek drives it |
Importing / referencing ScrollTrigger / ScrollSmoother / Draggable / Observer / InertiaPlugin |
these need user interaction that doesn't exist in offline render — use a GSAP timeline driven by mvm-seek instead |
Loading gsap-bridge.js BEFORE gsap.min.js and the plugin scripts |
always load gsap core + plugins FIRST, then the bridge — the bridge only registers what already exists on window |
Run node scripts/lint.mjs path/to/index.html before rendering. Add
--strict in CI so warnings also fail.
Determinism Contract
Two renders of the same HTML file MUST produce byte-identical MP4 output. The runtime + render pipeline already enforce this — but author code can break it. The rules:
- Never call
Math.random()in inline scripts. Use one of:window.__mvm.random("a-stable-string-seed")→ stateful PRNG functionwindow.__mvm.randomSample("seed")→ one deterministic value
- Never call
Date.now()/new Date()/performance.now()for anything visible. Time MUST come from themvm-seekevent detail. - Never use CSS
transition/animationon stage elements — those don't respect the timeline's seek mechanism, so they play in preview but appear "frozen" in the rendered MP4. - Never use
setTimeout/setIntervalto drive visuals. The renderer issuesseek(t)at 30 fps step boundaries; any wall-clock-driven code will skip frames. - All async resources go through
delayRender/continueRender:
The renderer waits forconst h = window.__mvm.delayRender('Loading hero image'); img.onload = () => window.__mvm.continueRender(h); img.onerror = () => window.__mvm.cancelRender(new Error('hero image failed'));__mvm.ready === true(i.e. all handles cleared) before screenshotting each frame; failures surface immediately with the pending handle labels printed.
Scene Transitions — non-negotiable rules
Borrowed from Hyperframes. Failing these is the #1 reason a video feels "choppy" or "broken".
- Every scene boundary must have a
<div data-transition>. Pick one of:pixel-dissolve/iris/glitch/wipe-left/wipe-right/wipe-up/wipe-down/flash. The transition straddles the cut. - Every text/element must have a
data-animation(entrance). Don't appear out of nothing — fade, slide, drop, mask in. - Don't add
data-animation-outto a non-final scene. The transition IS the exit. Adding an extra fade-out makes the screen go blank for ~0.3s and looks like a bug.scripts/lint.mjsflags this asE005. - Only the final scene may fade to opacity 0. Earlier scenes hand off via the transition; the last scene closes the video.
<!-- Scene 1 -->
<div data-background="meta-balls" data-palette="warm-glow"
data-clip data-start="0" data-duration="5.8"
data-animation="fadeIn"></div> <!-- ✅ entrance only -->
<!-- Transition (bridges 5.3s → 6.0s) -->
<div data-transition="pixel-dissolve" data-start="5.3" data-duration="0.7"></div>
<!-- Scene 2 starts at 5.8s -->
<div data-background="liquid-ether" data-palette="cool-deep"
data-clip data-start="5.8" data-duration="5.5"
data-animation="fadeIn"></div> <!-- ✅ no data-animation-out -->
Style Presets — 8 recipes to start from or hybridise
Each preset is a complete vibe: palette + typography + animation pattern + transition + pacing. They're not classes you import; they're templates you copy from when you want a recognisable look. Hybridise freely — use Asian Ink typography with Glitch Tech transitions, etc.
1. Cinematic Trailer
Big serif over warm darkness. Long beats, single ideas per scene. Feels like a film opening.
| Palette | warm-glow / warm-ember / mono-deep |
| Background | meta-balls (slow data-intensity="0.8") / lightning (sparingly) / solid #0a0606 |
| Typography | .title-3xl or .title-2xl, cn-serif (Noto Serif SC), letter-spacing: 0.04em |
| Text color | .text-on-warm (off-white) or #FFE87A |
| Entrance | data-text-animation="split-text" with data-stagger="0.06" data-easing="easeOutQuart" |
| Pacing | 5–8s per scene, hold one idea at a time |
| Transitions | flash (0.4s) between climactic beats, iris (1.0s) for softer cuts |
| Camera | data-camera-zoom="1.08" (slow push-in) on hero shots |
| Avoid | bright pinks, fast cuts, springBouncy, glitch-text |
2. Glitch Tech / Cyberpunk
Hard cuts, electric edges, decrypting text. Reads "future" or "hacker".
| Palette | cool-neon / prismatic-cyber / mono-ink |
| Background | lightning (data-intensity="1.4"), prismatic-burst, letter-glitch canvas |
| Typography | .title-xl mono fonts (cn-mono / JetBrains Mono), text-transform: uppercase, letter-spacing: 0.25em |
| Text color | .text-on-prismatic (white + halo) or #5BC0EB / #FFD400 accents |
| Entrance | data-text-animation="decrypted-text" / glitch-text / scramble-text |
| Pacing | 1.5–3s clips, lots of them. Quick rhythm. |
| Transitions | glitch (0.5s) almost everywhere, occasional pixel-dissolve (0.4s) |
| Effects | data-effect="electric-border" on hero card, star-border accents |
| Avoid | cn-brush, slow ease, warm palettes, single-scene 8s holds |
3. Asian Ink / 中国风水墨
留白、宋体、行楷。Soft paper background, brushed accents, slow rhythm.
| Palette | light-paper (background) or warm-ember (subtle dark) |
| Background | solid #f7f5ef paper, or aurora warm-ember very faint, or a iridescence shader at data-intensity="0.3" |
| Typography | cn-serif (Noto Serif SC) for body, cn-brush (Ma Shan Zheng / Long Cang) for hero accents, .title-2xl to .title-3xl |
| Text color | .text-on-light (deep ink #1a0a08) or #3a1c08 |
| Entrance | data-animation="unmaskUp" or unmaskRight slow (data-in-duration="1.2"), data-easing="easeInOutQuart" |
| Pacing | 6–10s per scene. Let the brush strokes breathe. |
| Transitions | wipe-up or wipe-left (1.2s) with data-color="#f7f5ef", soft flash (0.5s) |
| Special | red seal stamp: small square mvm-card with cn-brush "印", data-animation="dropIn" |
| Avoid | electric-border, glitch, neon palettes, mono fonts |
4. Data Story
Hero numbers + supporting cards. Each stat lands with weight.
| Palette | cool-deep (background), accents via .mvm-stat--red/cool/warm borders |
| Background | liquid-ether cool-deep or iridescence cool-violet, kept moving but quiet |
| Typography | .mvm-stat preset (built-in 120px digit + label), .title-lg for section headers |
| Entrance | data-text-animation="count-up" + data-odometer="true" on every number; stat cards data-animation="magneticIn" / dropIn / slideBlurIn (alternate for variety) |
| Pacing | 3.5–5s per stat, 1s in / 2s hold / 1s out |
| Transitions | iris (0.7s) between stat groups, pixel-dissolve between sections |
| Layout | 1, 2, or 3 stats stacked vertically with gap: 32px |
| Avoid | text-aurora, glitch-text, busy shaders behind the numbers |
5. Pop Vibrant
Saturated colors, bouncy springs, candy. Reads "consumer brand" or "playful product".
| Palette | prismatic-vapor / prismatic-magic / custom data-colors="#FF3CAC,#FFD400,#2af598,#5BC0EB" |
| Background | meta-balls, iridescence at high data-intensity="1.2" |
| Typography | cn-sans (Noto Sans SC) bold, .title-xl/title-2xl, can mix multiple sizes in one scene |
| Entrance | data-easing="springBouncy" or springWobbly everywhere; data-animation="cubeIn" / flipInX / magneticIn (rotate per scene for variety) |
| Pacing | 3–5s clips, lots of motion |
| Transitions | pixel-dissolve with bright data-color="#FFD400", occasional flash |
| Avoid | cn-serif, slow ease, mono palette |
6. Minimal Editorial
90% empty space. Tiny eyebrow over a giant serif. One movement at a time.
| Palette | mono-graphite or solid #0a0a14 (no shader) |
| Background | <div> with solid color OR iridescence cool-violet at data-intensity="0.25" |
| Typography | One huge .title-3xl (160px) + one tiny .body-sm eyebrow; serif everywhere |
| Entrance | unmaskUp very slow (data-in-duration="1.4"), easeInOutQuart |
| Pacing | 8–12s per scene, very few scenes (3–4 total in 30s) |
| Camera | data-camera-zoom="1.06" ken-burns push on background, never on text |
| Transitions | wipe-up (1.2s) only |
| Avoid | crowded compositions, multiple animations per scene, decorative effects |
7. Liquid Dreamy
Iridescent shaders, soft text, ethereal pacing. Reads "calm" or "luxury".
| Palette | cool-violet / prismatic-vapor |
| Background | iridescence (data-intensity="0.7" data-scale="1.4"), layered with a aurora at low opacity |
| Typography | cn-wenkai (LXGW WenKai), .title-2xl, letter-spacing: 0.12em |
| Text color | .text-on-cool-soft (warm cream) |
| Entrance | data-animation="slideBlurIn" data-easing="springGentle"; blur-text for body |
| Pacing | 6–8s scenes, slow drift |
| Transitions | long flash (0.8s) with data-color="#9d8df1" (lavender), iris (1.2s) |
| Avoid | hard cuts, glitch, springBouncy, primary colors |
8. Documentary
Sepia-ish, slow zoom on stills, somber typography, long holds.
| Palette | warm-autumn / mono-graphite (desaturated) |
| Background | A real <img> photo (b/w or duotone via CSS filter: grayscale(80%) sepia(20%)) OR aurora warm-autumn at low intensity |
| Typography | cn-serif, .title-lg/title-md, body uses .body-md |
| Entrance | fadeIn + data-camera-zoom="1.15" (slow ken-burns); subtitles use unmaskUp |
| Pacing | 8–14s per scene, lots of held screen time |
| Transitions | wipe-right (1.5s) only; very slow flash (0.6s) for chapter breaks |
| Avoid | shaders, bright colors, fast text animations |
Hybridising — yes, do it
The presets are starting points, not boxes. Real "stand-out" videos cross-pollinate:
- "Cinematic + Glitch" — warm palette + serif type + occasional glitch transition + decrypted-text eyebrow. Hero shot stays cinematic, intro chyrons feel tech.
- "Asian Ink + Data Story" — paper background + ink red stat numbers + cn-brush labels. Annual report visual language.
- "Liquid Dreamy + Pop Vibrant" — iridescent shader + bouncy spring. Reads "beauty / cosmetics brand".
When in doubt: commit to one style for 80% of the runtime, sprinkle the other style on 1 scene as contrast. Pure-blend rarely reads.
motion-video-maker/
├── runtime/ # timeline.js, components.js, spring.js, shaders.js,
│ │ # transitions.js, effects.js, styles.css,
│ │ # contrast-check.js, layout-check.js,
│ │ # gsap-bridge.js (← determinism bridge for GSAP)
│ └── gsap/ # gsap.min.js + every plugin we ship: CustomEase,
│ # CustomWiggle, CustomBounce, EasePack,
│ # DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin,
│ # Physics2DPlugin, PhysicsPropsPlugin, Flip,
│ # SplitText, ScrambleTextPlugin, TextPlugin
├── assets/fonts/ # 12 open-source Chinese fonts (auto-installed)
├── scripts/ # render.mjs, preview.mjs, install-fonts.mjs,
│ # new-video.mjs, debug.mjs, snap.mjs, lint.mjs
├── templates/ # base.html, diagnostic.html, showcase.html,
│ # effects-showcase.html (signature effects sampler)
├── examples/ # time-flies/ (30s reference)
│ # raycast-deep-dive/ (40s v1: canvas only)
│ # raycast-deep-dive-v2/ (40s v2: + spring + shaders + transitions)
│ # raycast-deep-dive-v3/ (40s v3: + electric-border / image-trail / odometer)
│ # gsap-showcase/ (42s GSAP plugin sampler — every plugin in one file)
└── reference/ # components.md, workflow.md
Creative Latitude — these are tools, not training wheels
The Hard Rules above prevent broken videos. They are NOT a recipe for good videos. Many compositions look identical because agents stop at "safe and lint-clean" instead of pushing the medium. Push the medium.
What "push the medium" means in practice:
Pick a STYLE first, primitives second. Don't reach for
split-textfadeIn+meta-ballsby reflex. Decide what the video should feel like (see Style decision tree), then pick primitives that serve that feeling. The same primitive read differently in different styles —split-textat 0.04 stagger feels urgent and confident; the same animation at 0.2 stagger withspringGentleeasing feels meditative.
Combine primitives to invent new effects. Every "signature" effect in this Skill (electric-border, image-trail, odometer flip) is a composition of simpler parts. You can do the same:
mask-text+gradient-texton a duplicate layered behind = neon edge title that bleeds color through the negative space.image-trail+ a small camera-pan = parallax-feeling depth.- Two
liquid-ethershaders with differentdata-paletteand oppositedata-scale, blendedmix-blend-mode: screen= iridescent fluid impossible to get with one shader. wave-textwithdata-amplitude="3"+springSnap= subtle "breathing" hero title for slow scenes.
Bend the timeline. A 30s video doesn't need 6 scenes of 5s each.
- Cinematic trailers spend 8s on a single hero shot then sprint through 4 quick cuts in 6s.
- Glitch / tech videos use very short clips (1.2–2s) with hard cuts between shaders + glitch transitions.
- Editorial / minimalist videos hold one scene for 12s with a slow
kenBurnsInon the background.
Custom animations are welcome. If none of the built-in
data-animationvalues fit, you can:- Write your own keyframe-like animation by listening to
mvm-seekand applying transforms based onevent.detail.time. Seeruntime/components.jsfor the pattern. - Use
data-effect="custom"with inlinestyle="--my-anim-progress: ..."to drive your own CSS variables off the timeline. - Compose multiple
data-animationby nesting elements with independent animations.
- Write your own keyframe-like animation by listening to
Camera moves. Wrap a scene in
<div class="mvm-cam" data-camera-zoom="1.15" data-camera-pan-x="-40" data-camera-pan-y="20">and the whole scene slowly zooms / pans across its lifetime. Subtle camera moves are the single biggest "I am watching a film, not a slideshow" cue.
You are encouraged to read users' descriptive cues literally and translate them. "Cinematic", "elegant", "punchy", "ink wash", "data-driven", "playful", "otherworldly", "vintage", "futurist", "documentary" — each maps to a different combination of palette / typography / motion. See Style Presets for 8 ready-made recipes you can adopt or hybridise.
Style decision tree — pick by user intent
When the user describes the feeling but not the parts, use this map:
| User mentions... | Style preset | Why |
|---|---|---|
| "电影感 / cinematic / trailer / epic" | Cinematic Trailer | slow + big serif + warm/mono + flash transitions |
| "科技 / cyber / tech / futurist / glitch" | Glitch Tech | electric-border + glitch-text + prismatic/cool + fast cuts |
| "水墨 / 中国风 / 书法 / 古典 / ink" | Asian Ink | cn-brush + paper palette + slow unmask + Ma Shan Zheng |
| "数据 / data / report / 业绩 / stats" | Data Story | odometer + mvm-stat + cards + drop / magnetic entry |
| "活泼 / playful / pop / bouncy / 卡通" | Pop Vibrant | saturated + springBouncy + meta-balls + cube/flip |
| "极简 / minimal / 高级 / editorial / 杂志" | Minimal Editorial | huge serif + lots of whitespace + slow ken burns |
| "梦幻 / 流体 / liquid / dreamy / 治愈" | Liquid Dreamy | iridescence + slow + soft + warm-soft tones |
| "纪录片 / documentary / 历史 / 沉稳" | Documentary | desaturated + ken-burns + long holds + serif body |
| 没说,但内容像 PPT 介绍 | Cinematic Trailer | safest default, looks production-grade |
| 没说,但内容是产品发布 | Glitch Tech or Cinematic | depends on product vibe |
If the user mixes cues ("中国风但要现代感"), hybridise: take Asian Ink's typography + palette and Cinematic Trailer's camera moves + flash.
You decide. Don't always ask back — read the prompt for tone cues and commit to a style. Variety is the goal.
Every composition MUST include this script chain in this order:
<script src="../../runtime/spring.js"></script> <!-- physics -->
<script src="../../runtime/timeline.js"></script> <!-- seekable timeline -->
<script src="../../runtime/components.js"></script> <!-- text / canvas bg -->
<script src="../../runtime/shaders.js"></script> <!-- WebGL shader bg -->
<script src="../../runtime/transitions.js"></script> <!-- scene transitions -->
<script src="../../runtime/effects.js"></script> <!-- electric / star / image-trail -->
<!-- GSAP — load any plugins you'll use BEFORE the bridge.
The bridge tolerates missing plugins; drop ones you don't need. -->
<script src="../../runtime/gsap/gsap.min.js"></script>
<script src="../../runtime/gsap/CustomEase.min.js"></script>
<script src="../../runtime/gsap/EasePack.min.js"></script>
<script src="../../runtime/gsap/SplitText.min.js"></script>
<script src="../../runtime/gsap/DrawSVGPlugin.min.js"></script>
<script src="../../runtime/gsap/MorphSVGPlugin.min.js"></script>
<script src="../../runtime/gsap/MotionPathPlugin.min.js"></script>
<script src="../../runtime/gsap/Physics2DPlugin.min.js"></script>
<script src="../../runtime/gsap/Flip.min.js"></script>
<script src="../../runtime/gsap/ScrambleTextPlugin.min.js"></script>
<script src="../../runtime/gsap-bridge.js"></script> <!-- MUST come AFTER gsap -->
Quick Start
# 1) One-time setup (installs Chinese fonts; idempotent)
node scripts/install-fonts.mjs
# 2) Scaffold a new composition
node scripts/new-video.mjs my-video --duration 12 --fps 30 --bg aurora
# 3) Live-preview in a browser
node scripts/preview.mjs examples/my-video/index.html
# → http://localhost:5173/examples/my-video/index.html
# 4) Render to MP4 (frame-accurate, deterministic)
node scripts/render.mjs examples/my-video/index.html examples/my-video/output.mp4
See examples/time-flies/index.html for a
complete 30-second reference composition that exercises every component.
How a Composition Works
A composition is one HTML file with three layers:
<div id="stage" data-*>— declares pixel size, fps, total duration.[data-background]— full-bleed animated backgrounds (canvas).[data-clip]— any element that appears, moves, or animates.
<div id="stage" data-composition-id="my-video"
data-width="1920" data-height="1080"
data-fps="30" data-duration="10">
<div data-background="aurora"
data-colors="#3a1c71,#d76d77,#ffaf7b"></div>
<h1 class="title text-aurora"
data-clip data-start="0.5" data-duration="9"
data-text-animation="split-text"
data-stagger="0.06" data-char-duration="0.9">你好,世界</h1>
<p class="subtitle"
data-clip data-start="1.4" data-duration="8"
data-animation="fadeInUp" data-in-duration="0.8">第一帧</p>
</div>
<script src="../../runtime/timeline.js"></script>
<script src="../../runtime/components.js"></script>
The renderer (render.mjs) launches headless Chromium, sets
window.__mvmRenderMode = true to suppress the wall-clock preview loop,
then for every frame i calls window.__mvm.seek(i / fps) and screenshots
the page. Frames are encoded to H.264 MP4 via FFmpeg.
Composition Attributes Reference
Stage (#stage)
| Attribute | Purpose | Example |
|---|---|---|
data-width / data-height |
Canvas pixel size | 1920 / 1080 |
data-fps |
Frames per second | 30 (recommended) |
data-duration |
Total seconds | 30 |
data-background |
Page-level bg color | #0a0a0f |
Clip ([data-clip])
Governs visibility window and outer in/out animation of any element.
| Attribute | Purpose |
|---|---|
data-start |
When the clip first appears (seconds) |
data-duration |
How long the clip stays visible |
data-animation (or data-animation-in) |
In animation: fadeIn fadeInUp fadeInDown fadeInLeft fadeInRight slideUp slideDown zoomIn zoomOut pop blurIn blurOut rotateIn swing3D floatIn |
data-animation-out |
Out animation (any of the above ending in Out or any name from the list) |
data-in-duration / data-out-duration |
Animation duration (default 0.6s) |
data-easing / data-easing-out |
Standard: linear easeIn easeOut easeInOut easeOutCubic easeInOutCubic easeOutQuart easeInOutQuart easeOutQuint easeOutExpo easeInOutExpo easeOutBack easeOutElastic easeOutBounce. Spring physics: springGentle springSoft springSnap springSmooth springBouncy springWobbly springStiff |
data-hide-mode |
visibility (default — keeps the layout slot so siblings DON'T jump when a later element fades in) or display (legacy — removes from layout). Set display only when you actually want surrounding content to collapse into the empty space. |
data-scrim |
Auto-inject a backdrop layer behind the element so text stays readable on busy shader/particle backgrounds. Values: card (translucent dark pill + blur), blur (semi-dark + backdrop-filter blur), radial (radial darkness fade), auto (radial + auto drop-shadow). Tune with data-scrim-padding, data-scrim-opacity, data-scrim-radius, data-scrim-color, data-scrim-blur. |
Available outer-animation presets (for data-animation / data-animation-in / data-animation-out):
| Group | Names |
|---|---|
| Fade / slide | fadeIn fadeOut fadeInUp fadeInDown fadeInLeft fadeInRight slideUp slideDown |
| Scale / pop | zoomIn zoomOut pop kenBurnsIn irisIn |
| Blur | blurIn blurOut slideBlurIn |
| Rotate / 3D | rotateIn swing3D flipInX flipInY cubeIn skewIn |
| Mask reveal | unmaskUp unmaskDown unmaskLeft unmaskRight (clip-path inset – feels cinematic) |
| Physics-ish | floatIn magneticIn dropIn glitchIn |
Variety tip: when you have N parallel elements (cards, list items, layers), give each one a different preset from a different group — that's exactly what turns a "generic" composition into a polished one.
Text animations ([data-text-animation])
Use on the element whose text you want to animate. Combine with data-clip so the element has a defined visibility window. The text component reads data-anim-duration for its own animation length, falling back to data-duration.
data-text-animation |
Effect | Key attributes |
|---|---|---|
split-text |
Per-char staggered fade+slide | data-stagger data-char-duration data-travel data-split-mode (char/word) |
blur-text |
Per-char blur → focus | data-stagger data-char-duration |
shiny-text |
Metallic sheen sweep | data-speed |
gradient-text |
Animated rainbow sweep | data-colors data-speed |
glitch-text |
RGB split + jitter | data-intensity |
decrypted-text |
Hacker-style decode | data-anim-duration |
type-text |
Typewriter + blinking cursor | data-cps (chars/second) |
rotating-text |
Cycle through phrases | data-phrases (pipe-separated) data-each |
count-up |
Animated number counter — set data-odometer="true" for mechanical-flip rendering (each digit is a separate column that gently rotates as it carries) |
data-from data-to data-anim-duration data-decimals data-prefix data-suffix data-separator data-odometer |
shuffle-text |
Char shuffle reveal | data-anim-duration |
mask-text |
Whole-text clip-path reveal — wipe direction set by data-mask-from (left / right / top / bottom / center). Note: does NOT compose well with .text-raycast / .text-aurora / .text-fire gradient classes that use -webkit-text-fill-color: transparent (Chromium hides the gradient under a clip-path). Use on solid-color text or pair with data-scrim. |
data-mask-from data-anim-duration data-easing |
wave-text |
Per-char vertical sine wave that travels across the text — characters ride the crest into place. | data-stagger data-char-duration data-amplitude data-wave data-easing |
scramble-text |
Symbol/digit scramble that resolves left→right (more aggressive than shuffle-text — uses `!@#$%&*+={}[]\ |
01101001`). Great for technical labels. |
Backgrounds ([data-background])
Absolute-positioned full-stage backgrounds — both Canvas2D and WebGL.
Canvas backgrounds (lighter, no GPU):
data-background |
Effect | Key attributes |
|---|---|---|
aurora |
Lightful gradient orbs | data-colors |
particles |
Drifting particle field | data-count data-color data-size data-seed |
starfield |
Galaxy / parallax stars | data-count data-speed data-seed data-bg |
threads |
Sinusoidal flowing lines | data-lines data-color data-bg |
waves |
Layered ocean waves | data-colors data-bg |
dot-grid |
Pulsing radial dot grid | data-gap data-color data-bg |
hyperspeed |
Radial speed-line warp | data-count data-colors data-seed |
letter-glitch |
Matrix-style char cells | data-cell data-colors data-bg |
noise |
Film-grain overlay | data-strength |
magnet-lines |
Vector-field grid where short line segments align toward animated magnetic poles | data-colors data-poles data-gap data-length data-bg |
ribbons |
Multi-layer parametric flowing ribbons with glow | data-colors data-count data-bg |
WebGL fragment-shader backgrounds (richer, real shaders — require shaders.js):
data-background |
Effect | Common attributes |
|---|---|---|
liquid-ether |
Domain-warped fbm fluid, iridescent rim | data-colors (4 stops) data-intensity data-scale |
iridescence |
Curved rainbow interference bands | data-colors data-intensity data-scale data-seed |
prismatic-burst |
Center-radiating volumetric light + chromatic split | data-colors data-intensity |
lightning |
Procedural lightning bolts with flicker + glow | data-colors data-intensity data-seed |
plasma |
Classic organic plasma turbulence | data-colors data-intensity data-scale |
beams |
Crossing rotating light beams + hot spot | data-colors data-intensity |
meta-balls |
N orbiting isosurface meta-balls with gradient fill + chromatic edge highlight | data-colors data-intensity data-scale data-seed |
All shader backgrounds accept data-colors as 4 hex stops (e.g. "#FF6363,#FF8B5E,#FFD400,#0a0a16").
Scene transitions ([data-transition])
Place a <div data-transition="..." data-start="..." data-duration="...">
anywhere inside #stage — it auto-mounts a fullscreen overlay canvas
that paints the transition over everything from start to start+duration.
data-transition |
Effect | Extra attributes |
|---|---|---|
wipe-up / wipe-down / wipe-left / wipe-right |
Solid bar sweeps across | data-color data-easing |
wipe (with data-dir="diag") |
Diagonal sweep | as above |
iris / circle-reveal |
Circular mask shrinks then expands | data-color data-easing |
pixel-dissolve |
Deterministic tile reveal + hide | data-color data-tile |
shape |
Twin-shape morph with circle erase | data-color data-easing |
flash |
Quick full-screen flash | data-color (white default) |
glitch |
RGB-split + horizontal panel tear | — |
The full transition window is duration; half-way through (p=0.5) the
overlay is fully opaque so the underlying scenes can swap invisibly.
Effects ([data-fx])
data-fx |
Effect | Key attributes |
|---|---|---|
clock |
Analog clock with hands | data-speed (time multiplier) |
Element-level effects ([data-effect])
Decorations that attach to an existing element and animate around it. Inspired by react-bits' ElectricBorder / StarBorder / ImageTrail.
data-effect |
Effect | Key attributes |
|---|---|---|
electric-border |
RGB-split, noise-perturbed electric arcs flowing around the element's rounded perimeter | data-color data-color2 data-color3 data-intensity data-speed data-padding data-border-radius |
star-border |
N glowing 4-point stars orbit the perimeter with comet trails | data-color data-count data-tail data-speed data-size data-padding |
image-trail |
While the element is entering, draws N ghost copies that lag the in-animation progress, fading out behind it. Reads the element's data-animation to reuse its in-style. |
data-trail-count data-trail-stride data-trail-decay |
<!-- card with electric flowing border, perfect for "chosen" highlights -->
<div class="card chosen"
data-effect="electric-border"
data-color="#FF6363" data-color2="#FFD400" data-color3="#5BC0EB"
data-intensity="1.3" data-speed="1.4" data-padding="22"
data-border-radius="18"
data-clip data-start="2" data-duration="6"
data-animation="pop" data-easing="springBouncy"
style="opacity:0;">CHOSEN</div>
<!-- title with image-trail during its slide-in -->
<h1 data-effect="image-trail"
data-trail-count="5" data-trail-stride="0.06" data-trail-decay="0.7"
data-clip data-start="0" data-duration="4"
data-animation="fadeInLeft" data-in-duration="0.9"
data-easing="springSmooth">FLOW</h1>
GSAP integration — every plugin, deterministic, bridged
The runtime now bundles GSAP 3.15 plus every plugin Webflow makes free
(see lineage). All of them animate on the same mvm-seek clock as
the native runtime — two renders are still byte-identical.
What you get
| Capability | Plugin | data-* attribute | Programmatic |
|---|---|---|---|
| Tween any CSS / transform property with arbitrary ease | core | data-gsap-from / data-gsap-to |
__mvmGsap.timeline({at}).to(...) |
| Per-line / -word / -char text reveals with masks | SplitText | data-gsap-split="lines,words,chars" |
SplitText.create(...) then tween |
| Stroke draw on an SVG path | DrawSVGPlugin | data-draw-svg="0% 100%" |
gsap.fromTo(path, {drawSVG:'0 0'}, {drawSVG:'100%'}) |
| Morph one SVG path into another | MorphSVGPlugin | data-morph-to="#otherPath" |
gsap.to(p, {morphSVG:'#x'}) |
| Animate any element along an SVG path | MotionPathPlugin | data-motion-path="#pathId" |
gsap.to(el, {motionPath:{path}}) |
| Velocity / angle / gravity physics | Physics2DPlugin | data-physics2d='{"velocity":300,"angle":-80,"gravity":600}' |
gsap.to(el, {physics2D: {...}}) |
| Per-property velocity / acceleration | PhysicsPropsPlugin | (programmatic only) | gsap.to(el, {physicsProps: {x:{velocity}}}) |
| FLIP layout transitions between scenes | Flip | class="mvm-flip" data-flip-id="x" data-flip-at="3.0" |
Flip.getState() / Flip.from() |
| Decoder scramble | ScrambleTextPlugin | data-scramble='{"text":"DONE"}' |
gsap.to(el, {scrambleText: {...}}) |
| Type / replace text content over time | TextPlugin | (programmatic only) | gsap.to(el, {text: "new"}) |
| Custom cubic / SVG easing curves | CustomEase | data-easing="mvm.<name>" |
__mvmGsap.registerCustomEase(name, def) |
| Wiggle / oscillating ease | CustomWiggle | data-easing="mywiggle" |
CustomWiggle.create("mywiggle", {...}) |
| Real bounce ease | CustomBounce | data-easing="mybounce" |
CustomBounce.create("mybounce", {...}) |
| SlowMo / RoughEase / ExpoScaleEase | EasePack | data-easing="slow(0.7,0.7)" etc. |
string in any tween's ease |
Deliberately omitted (because they need user interaction that doesn't exist in offline render):
ScrollTrigger/ScrollSmoother— there is no scrollDraggable/Observer/InertiaPlugin— there is no pointer
If you reference these, scripts/lint.mjs raises W006. Replace
them with a timeline that you anchor onto gsap.globalTimeline —
the bridge will seek into it on every mvm-seek.
How the bridge works (the determinism contract)
- The bridge calls
gsap.ticker.lagSmoothing(0)and removesgsap.updateRootfromgsap.tickerso GSAP's RAF clock never advances anything on its own in render mode. - On every
mvm-seekevent the bridge callsgsap.updateRoot(seekTime)— the official "drive me from a custom clock" API. Every tween, child timeline, and plugin advances to that exact absolute second. (gsap.com/docs) - The bridge proxies
window.__mvm.easingso any GSAP ease string (e.g."power2.inOut","back.out(1.7)","elastic.out(1, 0.3)") silently resolves throughgsap.parseEase(...)and is then available to the nativedata-animationsystem too. So you can writedata-animation="fadeInUp" data-easing="back.out(1.6)"and the runtime now understands it. - Anything you author with
data-gsap-from/data-gsap-to/ etc. becomes agsap.fromTo(...)tween anchored at the host element'sdata-start + data-gsap-delay. The clip's lifecycle still owns visibility — GSAP only handles the tween itself.
Author hard-rules:
- Don't wrap
gsap.to()/gsap.from()insetTimeout/setInterval/requestAnimationFrame. Wall-clock timers bypassgsap.updateRootand produce different output every render.lint.mjs W007flags this.- Don't call
gsap.utils.random()— it usesMath.randomunder the hood. Wrap with__mvm.random("seed")or pass a pre-computed deterministic value.lint.mjs W008flags this.- Build the timeline at parse-time; don't try to construct new tweens inside an
mvm-seekhandler (that fires every frame and would create thousands of tweens).
Declarative recipes — copy these
1) Drop-in tween (works on any element with data-clip)
<h1 class="title text-readable"
data-clip data-start="2.0" data-duration="6"
data-gsap-from='{"y":120,"opacity":0,"rotationX":-30}'
data-gsap-to='{"y":0,"opacity":1,"rotationX":0}'
data-gsap-duration="1.0"
data-gsap-ease="back.out(1.4)">Hello GSAP</h1>
2) Pro SplitText (line / word / char reveal with mask)
<h1 class="title"
data-clip data-start="0.6" data-duration="5"
data-gsap-split="lines,words,chars"
data-gsap-mask="lines"
data-gsap-stagger="0.05" data-gsap-duration="0.9"
data-gsap-y="120" data-gsap-ease="power3.out">动画引擎</h1>
data-gsap-mask="lines" clips each line to its own bounding box —
characters that translate up appear "lifted" out of an invisible
slot. Combine with a serif font for an editorial feel.
3) DrawSVG — animate a stroke
<svg class="logo" viewBox="0 0 1100 520">
<path d="M 80 440 L 80 100 L 240 360 L 400 100 L 400 440"
fill="none" stroke="#FFD400" stroke-width="4"
data-clip data-start="6.4" data-duration="5"
data-draw-svg="0% 100%"
data-gsap-duration="1.6"
data-gsap-ease="power2.inOut" />
</svg>
The path MUST have a visible stroke (CSS or attribute). Multiple
paths can share data-clip data-start to draw simultaneously, or
stagger them by 0.2-0.5s for a "logotype building itself" effect.
4) MorphSVG — square → triangle → star
Hide the target shapes (so they don't render their own strokes/fill), keep the visible morphing path, and animate it through targets:
<svg viewBox="0 0 540 540">
<!-- targets are visibility:hidden so only their `d` matters -->
<path id="shape-triangle" style="visibility:hidden"
d="M 270 60 L 480 470 L 60 470 Z" />
<path id="shape-star" style="visibility:hidden"
d="M 270 60 L 320 220 L 480 220 L 350 320 L 400 480
L 270 380 L 140 480 L 190 320 L 60 220 L 220 220 Z" />
<!-- The visible path morphs through them. First hop is declarative,
further hops are sequenced on a real GSAP timeline. -->
<path id="morph"
d="M 80 80 L 460 80 L 460 460 L 80 460 Z"
fill="none" stroke="#5BC0EB" stroke-width="6"
data-clip data-start="12.4" data-duration="5"
data-morph-to="#shape-triangle"
data-gsap-duration="1.0" />
</svg>
<script>
document.addEventListener('DOMContentLoaded', () => {
const tl = window.__mvmGsap.timeline({ at: 13.6 });
tl.to('#morph', { morphSVG: '#shape-star', duration: 1, ease: 'power2.inOut' })
.to('#morph', { morphSVG: '#shape-circle', duration: 1, ease: 'power2.inOut' }, '+=0.4');
});
</script>
5) MotionPath — anything along an SVG path
<svg class="track" viewBox="0 0 1280 540" style="position:absolute;top:50%;left:50%;
transform:translate(-50%,-50%); width:1280px; height:540px;">
<path id="rocket-path" d="M 60 480 C 260 480 360 60 640 270
C 920 480 1020 60 1220 60"
fill="none" stroke="#FFD400" stroke-width="2"
stroke-dasharray="4 6" />
</svg>
<div style="position:absolute; left:0; top:0; font-size:84px;"
data-clip data-start="18.6" data-duration="5"
data-motion-path="#rocke
…(truncated)