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 / Chinese-style ink wash
Negative space, Song typeface, running script. 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 "seal", 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 / cinematic / trailer / epic" | Cinematic Trailer | slow + big serif + warm/mono + flash transitions |
| "tech / cyber / tech / futurist / glitch" | Glitch Tech | electric-border + glitch-text + prismatic/cool + fast cuts |
| "ink wash / Chinese style / calligraphy / classical / ink" | Asian Ink | cn-brush + paper palette + slow unmask + Ma Shan Zheng |
| "data / data / report / performance / stats" | Data Story | odometer + mvm-stat + cards + drop / magnetic entry |
| "lively / playful / pop / bouncy / cartoon" | Pop Vibrant | saturated + springBouncy + meta-balls + cube/flip |
| "minimalist / minimal / high-end / editorial / magazine" | Minimal Editorial | huge serif + lots of whitespace + slow ken burns |
| "dreamy / fluid / liquid / dreamy / healing" | Liquid Dreamy | iridescence + slow + soft + warm-soft tones |
| "documentary / documentary / history / steady" | Documentary | desaturated + ken-burns + long holds + serif body |
| Not specified, but the content looks like a PPT presentation | Cinematic Trailer | safest default, looks production-grade |
| Not specified, but the content is a product launch | Glitch Tech or Cinematic | depends on product vibe |
If the user mixes cues ("Chinese style but with a modern feel"), 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="#rocket-path"
data-motion-path-rotate="true"
data-gsap-duration="4.6"
data-gsap-ease="power1.inOut">🚀</div>
data-motion-path-rotate="true" rotates the host to follow the
tangent of the curve (great for arrows, comets, planes).
6) Physics2D confetti — programmatic, deterministic
<div id="conf-host" style="position:absolute; left:960px; top:540px;"></div>
<script>
document.addEventListener('DOMContentLoaded', () => {
const host = document.getElementById('conf-host');
const palette = ['#FF6363', '#FFD400', '#5BC0EB', '#9d8df1', '#2af598'];
const rng = window.__mvm.random('confetti'); // deterministic PRNG
const tl = window.__mvmGsap.timeline({ at: 24.6 }); // anchor at 24.6s
for (let i = 0; i < 60; i++) {
const dot = document.createElement('div');
Object.assign(dot.style, {
position:'absolute', left:'0', top:'0',
width:'22px', height:'22px', borderRadius:'4px',
background: palette[Math.floor(rng() * palette.length)],
});
host.appendChild(dot);
const angle = -90 + (rng() * 160 - 80);
const velocity = 700 + rng() * 700;
const gravity = 1400 + rng() * 400;
const dur = 2.4 + rng() * 0.9;
tl.to(dot, { physics2D: { velocity, angle, gravity },
rotation: (rng() - 0.5) * 1080,
duration: dur, ease: 'none' }, 0);
tl.to(dot, { opacity: 0, duration: 0.6, ease: 'power2.in' }, dur - 0.6);
}
});
</script>
Use __mvm.random("seed") for the RNG so the burst pattern is
identical between renders. Lint rule W008 enforces this.
7) ScrambleText — decoder reveal
<p class="text-readable"
data-clip data-start="30.6" data-duration="5"
data-scramble='{"text":"DECRYPTING ACCESS","chars":"upperAndLowerCase"}'
data-gsap-duration="2.0">▓▓▓▓▓▓▓▓ ▓▓▓▓▓▓▓▓</p>
<!-- with custom CJK character pool -->
<p data-clip data-start="32" data-duration="3"
data-scramble='{"text":"动画即数据","chars":"龙腾虎跃春夏秋冬天地"}'
data-gsap-duration="1.4">?????</p>
chars accepts:
"upperAndLowerCase"/"upperCase"/"lowerCase"— Latin"01"— binary- any string of glyphs to cycle through (works perfectly with CJK)
8) Programmatic timeline — full GSAP sequencing
When you want to choreograph 10+ elements in one scene:
<script>
document.addEventListener('DOMContentLoaded', () => {
const cells = document.querySelectorAll('.grid-cell');
// Custom ease registered via the bridge
window.__mvmGsap.registerCustomEase(
'mvm.swoop',
'M0,0 C0.2,0.0 0.0,0.6 0.4,0.85 0.7,1.05 0.8,0.98 1,1'
);
const tl = window.__mvmGsap.timeline({ at: 36.4 });
tl.from(cells, {
y: 240, opacity: 0, scale: 0.4, rotation: -12,
stagger: { each: 0.06, from: 'center' },
duration: 0.9, ease: 'mvm.swoop',
}, 0)
.to(cells, {
scale: 1.08, duration: 0.4, ease: 'sine.inOut',
stagger: { each: 0.04, from: 'edges' },
yoyo: true, repeat: 1,
}, 1.4)
.to(cells, {
rotationY: 360, duration: 1.2, ease: 'power2.inOut',
stagger: { each: 0.08, from: 'start', grid: [2, 4], axis: 'x' },
}, 2.6);
});
</script>
__mvmGsap.timeline({at: 36.4}) returns a real gsap.timeline()
that has been added to gsap.globalTimeline at absolute time
36.4s. Internal positioning (0, 1.4, '+=0.2', '<', …)
follows GSAP's position parameter
verbatim.
CustomEase / CustomWiggle / CustomBounce / EasePack
These plug straight into the runtime's existing easing table. Once
the bridge mounts, data-easing="<gsap-ease-name>" works on any
mvm-native preset:
<!-- power easings (the most useful for video pacing) -->
<h1 data-animation="fadeInUp" data-easing="power3.out">…</h1>
<h2 data-animation="slideBlurIn" data-easing="expo.inOut">…</h2>
<!-- back / elastic / bounce -->
<p data-animation="pop" data-easing="back.out(1.7)">…</p>
<p data-animation="dropIn" data-easing="elastic.out(1, 0.3)">…</p>
<!-- EasePack -->
<p data-animation="fadeIn" data-easing="slow(0.7, 0.7, false)">…</p>
<p data-animation="zoomIn" data-easing="rough({clamp:true,points:20})">…</p>
<!-- Author-defined CustomEase -->
<script>
document.addEventListener('DOMContentLoaded', () => {
window.__mvmGsap.registerCustomEase('mvm.swoop',
'M0,0 C0.2,0.0 0.0,0.6 0.4,0.85 0.7,1.05 0.8,0.98 1,1');
});
</script>
<h1 data-animation="fadeInUp" data-easing="mvm.swoop">…</h1>
<!-- CustomWiggle / CustomBounce -->
<script>
CustomWiggle.create('myWiggle', { wiggles: 6, type: 'random' });
CustomBounce.create('myBounce', { strength: 0.7, squash: 3 });
</script>
<div data-gsap-from='{"rotation":-30}' data-gsap-to='{"rotation":30}'
data-gsap-duration="1.5" data-gsap-ease="myWiggle">…</div>
When to use mvm-native vs GSAP
| Goal | Use |
|---|---|
Quick entrance from the 24-preset table (fadeInUp, unmaskUp, magneticIn, …) |
mvm data-animation= |
| Per-character text animation that doesn't need lines or masks | mvm data-text-animation="split-text" |
| Stagger over arbitrary properties / arbitrary easing | GSAP data-gsap-from / data-gsap-to |
| Line-aware split, mask reveal, autoSplit on font-load | GSAP data-gsap-split |
| Stroke draw, shape morph, motion path | GSAP plugins (DrawSVG / MorphSVG / MotionPath) |
| Particle bursts with physics | GSAP Physics2DPlugin |
| Choreographing 5+ elements with overlapping time positions | __mvmGsap.timeline({at}) |
| Layout transitions between scenes (FLIP) | GSAP Flip |
| Custom mathematical ease curve (cubic-bezier or SVG path) | GSAP CustomEase (use anywhere) |
The two systems compose freely — use whichever reads best for the
scene. Existing examples (time-flies, raycast-deep-dive-v*)
still use the mvm-native attributes; new compositions are
encouraged to mix the two for richer motion.
Where to look
runtime/gsap-bridge.js— the determinism layer. Read this to understand exactly howmvm-seekdrives every GSAP tween.runtime/gsap/— bundled GSAP 3.15 + every plugin (UMD).examples/gsap-showcase/index.html— 7-scene 42s reference exercising each plugin (SplitText / DrawSVG / MorphSVG / MotionPath / Physics2D / ScrambleText / programmatic timeline + CustomEase). Render withnpm run render:gsap.
Typography limits — never write font-size: 180px for a long title
The stage is 1920×1080. At that width, "Motion Video Maker" wraps mid-word once you go above ~140px; longer phrases like "HTML-driven cinematic animation engine" need even smaller sizes. Always pick from the safe scale instead of writing px:
<!-- ✅ Safe — fits 1920px stage for typical 15–22 char titles -->
<h1 class="title-2xl text-on-warm">Motion Video Maker</h1>
<!-- ✅ Also safe — for shorter, punchier hero titles -->
<h1 class="title-3xl text-on-cool">时间飞逝</h1>
<!-- ❌ Don't do this — wraps to two left-aligned lines on a 1920 stage -->
<h1 style="font-size: 180px;">Motion Video Maker</h1>
Safe font-size scale (verified non-wrapping @ 1920×1080)
| Class | font-size | Safe up to (latin / CJK) |
|---|---|---|
.title-3xl |
160px | 14 chars / 12 chars |
.title-2xl |
130px | 18 chars / 15 chars |
.title-xl |
100px | 22 chars / 18 chars |
.title-lg |
80px | 28 chars / 22 chars |
.title-md |
60px | 38 chars / 28 chars |
.title-sm |
44px | wide subtitles |
.body-lg/md/sm |
40/32/24px | paragraphs |
The runtime ships layout-check.js: any heading that wraps or extends
past the stage edge produces a [mvm-layout] console.warn during
rendering, and the render script prints a summary at the end so you
know to dial down the font size before re-rendering.
Center by default
The runtime now applies text-align: center; margin: 0 auto; max-width: 92%
to every #stage h1–h6, #stage p. Flex containers no longer cause
left-aligned titles when text wraps. Set text-align: left (or
.pos-left / .pos-right) on the element if you actually want it
off-center.
Background palette + matching text class — the PALETTE PAIR rule
This is the #1 thing other agents got wrong in the wild: choosing a shader palette and a text gradient class from the same hue family, producing invisible text. Examples observed:
.text-aurora(cyan/blue/violet gradient) onliquid-etherwith a blue/violetdata-colors→ text vanishes into the background..text-fire(yellow/orange/red gradient) onmeta-ballswith a red/orangedata-colors→ text vanishes into the background.
To eliminate this entire class of bugs, the runtime now ships named palettes for every shader and matching text classes that have been hand-tuned for 4.5:1 contrast against that palette family. You only ever have to pick one of each.
Named palettes (data-palette="...")
| Family | Palette names | Best with text class |
|---|---|---|
| Cool (blue / violet) | cool-deep cool-arctic cool-neon cool-violet |
.text-on-cool (warm yellow) / .text-on-cool-soft |
| Warm (red / orange / gold) | warm-glow warm-sunset warm-ember warm-autumn |
.text-on-warm (cool white-blue) / .text-on-warm-soft |
| Prismatic (rainbow / vapor) | prismatic-cyber prismatic-vapor prismatic-magic |
.text-on-prismatic (pure white + heavy halo) |
| Mono (deep neutral) | mono-deep mono-ink mono-graphite |
.text-on-mono (warm yellow) |
| Light (paper / misty) | light-paper light-misty |
.text-on-light (dark ink) |
<!-- ✅ Cool palette + cool text class -->
<div data-background="liquid-ether" data-palette="cool-deep"
data-clip data-start="0" data-duration="6"
data-animation="fadeIn" data-animation-out="fadeOut"></div>
<div class="scene-scrim" data-clip data-hide-mode="visibility"
data-start="0" data-duration="5.5"
style="position:absolute; inset:0; z-index:10; display:flex; ...">
<p class="eyebrow text-on-cool">ANIMATION COMPONENTS</p>
<h1 class="text-on-cool" style="font-size:120px; color:#fff;">标题</h1>
</div>
<!-- ❌ Don't do this — .text-aurora over a cool shader = invisible -->
<h1 class="text-aurora" ...>标题</h1>
Drop-in stat-card preset (.mvm-stat)
When showing numeric stats on any shader, use the .mvm-stat family
to skip color-picking entirely. Pair the border accent with the
scene's palette family:
<!-- Cool-themed scene → red/cool/warm accents from .mvm-stat--* -->
<div class="mvm-stat mvm-stat--red"
data-clip ... data-animation="magneticIn">
<span class="mvm-stat-num" data-text-animation="count-up" data-odometer="true"
data-from="0" data-to="17">0</span>
<span class="mvm-stat-label">种文字动画</span>
</div>
The stat card has a solid dark plate built-in (no contrast worry), white digits, muted serif label, and the accent border lets you differentiate three or four stats by hue without changing text color.
See examples/skill-intro/index.html — 6 scenes,
6 different palettes, 0 contrast warnings.
Color & contrast contract — READ THIS FIRST
The runtime gives every composition a white-on-dark default. Follow three rules and your text will always be visible:
Rule 1 — Always link runtime/styles.css BEFORE author CSS
<head>
<link rel="stylesheet" href="../../runtime/styles.css" /> <!-- FIRST -->
<link rel="stylesheet" href="../../assets/fonts/fonts.css" />
<style>/* your overrides */</style>
</head>
Without this, #stage has no default color/background-color and
the browser falls back to black-on-white — invisible against the
dark backgrounds you probably want.
Rule 2 — If you set background: light on ANY element, also set color: dark (and vice versa)
#stage defaults to background: var(--mvm-bg, #0a0a0f) and
color: var(--mvm-text, #fff). Every descendant inherits the white
text unless you (or a class you apply) explicitly changes it.
This is the #1 mistake: an agent writes
<div style="background: white; padding: 30px;">
<h2>这个会看不见</h2>
</div>
…and the heading inherits white → invisible on white card. Solution: use the paired utility classes, NEVER write half-pairs.
| If your container has… | Use this class |
|---|---|
| Dark background | .mvm-card-dark — paints dark + sets text white |
| Light background | .mvm-card-light — paints light + sets text dark |
| Need just text color | .mvm-light (white) / .mvm-dark (#0a0a0f) |
| Need full-scene theme flip | .mvm-bg-light / .mvm-bg-dark on the scene wrapper |
| Need a tonal mute | .mvm-light-muted / .mvm-dark-muted |
<!-- ✅ Good — paired class -->
<div class="mvm-card-light">
<h2>清晰可见 — 亮卡片自动配深字</h2>
</div>
<!-- ✅ Good — explicit pair -->
<div style="background:#FFD400; color:#1a0a08; padding:30px;">
<h2>also fine — explicit dark text on yellow</h2>
</div>
<!-- ❌ Bad — only background, color forgotten -->
<div style="background: white; padding: 30px;">
<h2>invisible — inherits white from #stage</h2>
</div>
Rule 3 — Run the contrast checker (it's automatic during render)
runtime/contrast-check.js runs on every mvm-seek tick and walks
every visible text element under #stage, computes the WCAG luminance
ratio against its effective background, and emits a console.warn for
any element under 2.2:1 (essentially invisible).
render.mjs collects those warnings and prints a one-shot summary at
the end of the export so you never ship an MP4 with hidden text:
[mvm] ⚠ 2 LOW-CONTRAST TEXT ISSUE(S) DETECTED
[mvm] • text "看不见" (color rgb(255,255,255)) on background rgba(255,255,255,1)
[mvm] at <h2.subtitle>. Fix: add .mvm-light / .mvm-card-dark, or set color/background explicitly.
templates/contrast-test.html is a self-test page with 4 cells (2 bad
- 2 good) you can use as a regression test of the safety net.
Theming an entire composition
<!-- Light-themed composition: flip --mvm-bg and --mvm-text on #stage -->
<div id="stage"
style="--mvm-bg: #f7f5ef; --mvm-text: #0a0a0f;"
data-width="1920" data-height="1080" data-fps="30" data-duration="10">
<!-- now every default-text descendant is DARK on light, automatic -->
</div>
Keeping text readable on busy backgrounds
Shader / particle / video backgrounds tend to swallow typography. The skill provides a 3-layer defense — pick the strongest layer your scene needs:
Layer 1 — .scene-scrim on the scene container (recommended baseline)
Adds a CSS-only radial dark vignette covering the entire scene area, sitting between the shader background and the foreground content. Every text inside gets a "free" 30–60% darkness lift with no per-text tuning.
| Class | Darkness profile |
|---|---|
.scene-scrim |
Radial 35–60% center→edge (default) |
.scene-scrim-soft |
15–35% (subtle) |
.scene-scrim-strong |
55–80% (heavy — turns bright backgrounds nearly black) |
.scene-scrim-top |
Linear 0–85% top edge (lower-third style) |
.scene-scrim-bottom |
Linear bottom edge gradient |
<!-- Scene 4 in raycast-deep-dive-v4: iridescence shader auto-darkened -->
<div class="scene-scrim"
data-clip data-hide-mode="visibility"
data-start="14.5" data-duration="7.5"
style="z-index: 10; position: absolute; inset: 0;">
... scene content ...
</div>
Important: apply
.scene-scrimto a container that already hasposition: absolute / fixed / relative. The.scene-scrimstyle does NOT set position itself (so it doesn't clobber your layout).
Layer 2 — per-element wrappers when you need guaranteed contrast
| Helper | What it does | When to use |
|---|---|---|
.text-readable |
Multi-layer dark text-shadow only (safe with gradient text classes) |
Default for any title sitting on a shader |
.text-stroke-dark |
2px dark -webkit-text-stroke with paint-order: stroke fill |
Solid-color body text |
.text-stroke-dark-thick |
4px dark stroke variant | Captions on very bright backgrounds |
.text-glow-dark |
Heavy dark text-shadow alone |
When you want extra "lift" without strokes |
.text-card |
Translucent dark rounded pill backdrop-filter: blur(20px) |
Quotes / hero lower-thirds (best on shaders that support backdrop-filter) |
.text-plate |
Solid dark rgba(6,8,14,0.82) rounded pill — NO backdrop-filter, works in EVERY render path |
Use when you absolutely cannot afford the text to blend in |
.text-plate-tight / -loose / -soft / -hard |
Padding & opacity variants of .text-plate |
|
.text-scrim |
Built-in radial CSS scrim around the element | Pure CSS, render-frame-buffer safe |
data-scrim="card / radial / blur / auto" |
Runtime injects a backdrop element behind the host | One-off "make it readable" |
.scrim-top / .scrim-bottom |
Top/bottom edge gradient bars (lower-third feel) | News-ticker / broadcast captions |
Layer 3 — color-pairing rule of thumb
If your text uses a warm gradient (.text-raycast, .text-fire,
.text-gold), give it a cool background shader (liquid-ether
with blue/violet palette, iridescence with cool stops). If your
text is cool (.text-blue, .text-aurora), choose a warm-dark
background. This single rule eliminates most readability bugs.
Known gotchas (documented from real-world v3 → v4 fixes)
-webkit-text-fill-color: transparent+ 3Dtransformbreaks gradient text in Chromium.flipInX/Y,cubeIn,swing3D, andirisInintroduce perspective; if you must use them, switch to solidcolor: white+text-shadowinstead of a gradient class. That's what closing-title inraycast-deep-dive-v4does.-webkit-text-strokeon transparent-gradient text makes it appear hollow —.text-readabledeliberately uses onlytext-shadow(no stroke, nofilter) so it composes safely with.text-raycast/.text-aurora.mask-text(clip-path) + gradient text also fails in Chromium screenshot mode — fall back tosplit-textfor gradient titles.
<!-- Hero title on a busy shader: cool background + warm gradient + readable -->
<div data-background="liquid-ether"
data-colors="#06061a,#1a1240,#2a2070,#0a3055"></div>
<h1 class="hero-title text-raycast text-readable"
style="filter: drop-shadow(0 8px 40px rgba(255,99,99,0.5))
drop-shadow(0 4px 12px rgba(0,0,0,0.9));"
data-clip data-start="0.7" data-duration="3.3"
data-text-animation="split-text">RAYCAST 2.0</h1>
<!-- Closing quote with an explicit dark card -->
<p class="quote-text text-readable text-card"
style="padding: 32px 80px;"
data-clip data-start="35" data-duration="3"
data-text-animation="wave-text">代码只是手段,产品才是目的。</p>
<!-- Generic "make it readable" -->
<h2 data-scrim="auto" data-scrim-padding="32"
data-clip data-start="5" data-duration="4">任何标题</h2>
Layout stability — siblings never jump when another sibling enters
Earlier versions used display: none as the default hide state, which
meant a later sibling appearing would re-trigger flex/grid layout and
push already-visible siblings around. From this version on,
data-hide-mode defaults to "visibility" instead.
What that means in practice:
- A hidden clip still occupies its layout slot (its width / height are reserved in the flex/grid track).
- When a sibling fades in, surrounding siblings keep their pixel position — zero jump.
- Your
transformanimations (translate, rotate, scale) still move the visual position freely; only the layout slot is preserved.
If you actually want the legacy "fully remove from layout" behaviour (e.g. for a corner element that should completely disappear and let surrounding content collapse into its slot), opt in explicitly:
<div data-clip data-hide-mode="display"
data-start="3" data-duration="2">temporary tooltip</div>
See examples/raycast-deep-dive-v4/stability-readability.jpg for a
visual proof: 5 timestamps of the same scene, every already-visible
element stays exactly where it was while new elements enter.
Animation variety — avoid the "fadeIn everything" trap
A common mistake is to apply the same entrance animation to every element in a scene. The runtime ships 24 outer-animation presets across 6 groups so you can mix them:
<!-- A row of 4 cards, each with a DIFFERENT animation preset -->
<div class="card" data-animation="unmaskLeft" data-easing="easeInOutQuart">...</div>
<div class="card" data-animation="slideBlurIn" data-easing="springSoft">...</div>
<div class="card" data-animation="magneticIn" data-easing="springBouncy">...</div>
<div class="card" data-animation="glitchIn" data-easing="easeOutCubic">...</div>
This is exactly the technique used in examples/raycast-deep-dive-v4/
to upgrade from a "generic" v3 to a polished v4.
Authoring Workflow
- Pick a duration. 6–15s is great for hooks; 30s for fuller pieces.
- Sketch scenes by time. E.g.: 0–4s opener, 4–9s point 1, 9–18s data, 18–30s finale.
- Pick one background per scene (cross-fade by giving the prior bg
data-animation-out="fadeOut"). - Wrap each scene in a container with
data-clip(the defaultdata-hide-mode="visibility"keeps the child layout stable) so all children share the same visibility window:<div class="scene center" data-clip data-hide-mode="visibility" data-start="4.0" data-duration="5.0"> ... clips here ... </div> - Use
text-animationfor kinetic text, plaindata-animationfor outer transforms (zoom, slide, fade). - Preview at 15fps first to iterate quickly:
node scripts/render.mjs path/to/index.html /tmp/preview.mp4 fps=15 preset=ultrafast crf=24 - Render final at 30fps:
node scripts/render.mjs path/to/index.html path/to/output.mp4 fps=30 preset=medium crf=18
Common Patterns
Gradient text + character split
The runtime detects color: transparent + background-clip: text parents and
copies the gradient onto each split span, so the effect survives. Use the
preset classes .text-aurora, .text-fire, .text-gold, .text-silver.
<h1 class="title text-fire"
data-clip data-start="0" data-duration="4"
data-text-animation="split-text" data-stagger="0.08">光阴似箭</h1>
Scene-wide entrance + per-element timing
Outer scene controls visibility; inner clips animate in staggered:
<div class="scene center" data-clip data-hide-mode="visibility"
data-start="10" data-duration="6">
<h1 data-clip data-start="10.2" data-duration="5.6"
data-text-animation="split-text">标题</h1>
<p data-clip data-start="11.0" data-duration="4.8"
data-animation="fadeInUp">副标题</p>
</div>
Animated counter (plain or odometer-flip)
<!-- plain (textContent updated per frame) -->
<span data-clip data-start="5" data-duration="6"
data-text-animation="count-up"
data-from="0" data-to="86400" data-anim-duration="3"
data-separator="," data-easing="easeOutExpo">0</span>
<!-- odometer: per-digit columns rotate mechanically as they carry -->
<span style="font-size:200px; font-weight:900;"
data-clip data-start="5" data-duration="6"
data-text-animation="count-up"
data-odometer="true"
data-from="0" data-to="86400" data-anim-duration="3"
data-separator="," data-easing="easeOutExpo">0</span>
Odometer caveat: the inner per-digit divs do not inherit
background-clip: textgradients. Use a solidcoloron the counter element instead of.text-aurora/.text-fire.
Cross-fading backgrounds
<div data-clip data-start="0" data-duration="12"
data-animation="fadeIn" data-animation-out="fadeOut"
data-background="aurora"></div>
<div data-clip data-start="11.5" data-duration="8"
data-animation="fadeIn" data-animation-out="fadeOut"
data-background="dot-grid"></div>
Chinese Typography
Twelve preinstalled families (assets/fonts/fonts.css):
Noto Sans SC400/700/900 — modern sansNoto Serif SC400/900 — classical serifLXGW WenKai400 — calligraphic wenkai (LXGW WenKai)ZCOOL XiaoWei/ZCOOL KuaiLe/ZCOOL QingKe HuangYou— displayMa Shan Zheng(Ma Shanzheng brush) — brush strokesLong Cang(Long Cang style) — calligraphic flowLiu Jian Mao Cao(Liu Jian Mao Cao) — running script
Default body family changed in v5 — now prefers Noto Serif SC
(Source Han Serif) → LXGW WenKai (LXGW WenKai) → STSong / Songti SC, falling
back to the modern Noto Sans family. This gives Chinese text a more
literary / elegant feel out of the box. When you need the impact-heavy
modern look (e.g. big digit displays, monospace eyebrows), opt in via:
| Class | Family stack | When to use |
|---|---|---|
.cn-serif |
Noto Serif SC → Source Han Serif → STSong | Default body / titles (elegant) |
.cn-wenkai (aliased to .wenkai) |
LXGW WenKai → Noto Serif SC | Poetic quotes, calligraphic body |
.cn-sans |
Noto Sans SC → PingFang → YaHei | Big digit numbers / poster labels |
.cn-mono (aliased to .mono) |
JetBrains Mono | Code / timestamps / eyebrows |
.cn-poster |
ZCOOL XiaoWei → Noto Serif SC | Decorative posters |
.cn-brush |
Ma Shan Zheng → Long Cang → LXGW WenKai | brush titles (≤ 8 chars) |
.cn-running |
Long Cang → Liu Jian Mao Cao | running-cursive script |
.title / .title-md / .title-sm / .huge now default to the serif
family with looser tracking (letter-spacing: 0.02em) which makes large
Chinese headlines breathe properly. Add .cn-sans to force sans:
<!-- elegant default — serif 思源宋体 Black -->
<h1 class="title">岁月静好</h1>
<!-- modern poster look -->
<h1 class="title cn-sans" style="letter-spacing: -0.02em;">2026 KEYNOTE</h1>
<!-- calligraphic poem -->
<p class="cn-wenkai" style="font-size: 60px;">「春水初生,春林初盛」</p>
<!-- brush 4-char title -->
<h1 class="cn-brush" style="font-size: 280px;">活在当下</h1>
Other helper classes: .subtitle, .caption, .text-gold,
.text-aurora, .text-fire, .text-silver, .text-stroke, .glow,
.glow-cool, plus the readability stack (.text-readable,
.text-stroke-dark, .text-plate, .text-card).
To add additional fonts, drop a TTF/OTF/WOFF2 into assets/fonts/ and append
an @font-face rule to assets/fonts/fonts.css, or extend the manifest in
scripts/install-fonts.mjs and re-run it.
Render Options
node scripts/render.mjs <input.html> [output.mp4] [key=value ...]
| Option | Default | Notes |
|---|---|---|
fps= |
from data-fps |
Override frame rate |
duration= |
from data-duration |
Override duration (s) |
width= / height= |
from data-* |
Override stage size |
crf= |
18 |
x264 quality (lower = better, 16 archival, 23 web) |
preset= |
slow |
ultrafast for previews, medium/slow for finals |
keepFrames=1 |
off | Keep PNG sequence beside the MP4 |
Debugging
If something doesn't appear:
node scripts/debug.mjs <input.html> <time> <selector>Dumps each matching element's computeddisplay/visibility/opacity/transform/ bounding rect at that instant.node scripts/snap.mjs <input.html> <time> <out.png>Saves a high-res single-frame screenshot for visual inspection.- Open
scripts/preview.mjsin a browser — the wall-clock loop runs and any JS error appears in DevTools.
Lineage — What this skill borrows from where
| Borrowed from | What we took |
|---|---|
| react-bits | Component taxonomy (text / animations / backgrounds / decorations); naming (split-text, blur-text, decrypted-text, iridescence, liquid-ether, prismatic-burst, lightning, plasma, beams, meta-balls, pixel-dissolve, electric-border, star-border, image-trail, magnet-lines, ribbons, odometer); the "shader background" idea (we wrote our own GLSL from scratch instead of porting OGL). |
| motion.dev / framer-motion | Closed-form spring physics math (damped harmonic oscillator) for springGentle/Soft/Snap/Smooth/Bouncy/Wobbly/Stiff. |
| hyperframes | HTML-native composition; data-* attribute API; single index.html per video; puppeteer-screenshot frame capture. |
| remotion | Frame-accurate deterministic seek; image2pipe → ffmpeg encoder; chrome launch flags; delayRender / continueRender / cancelRender API. |
| GSAP (greensock/gsap-skills) | The full animation engine — core tweens, timelines, easing, plus every plugin (SplitText, DrawSVGPlugin, MorphSVGPlugin, MotionPathPlugin, Physics2DPlugin, PhysicsPropsPlugin, Flip, ScrambleTextPlugin, TextPlugin, CustomEase, CustomWiggle, CustomBounce, EasePack). All free and bundled in runtime/gsap/; integrated through runtime/gsap-bridge.js which routes gsap.updateRoot through mvm-seek for byte-identical determinism. |
The shader backgrounds (liquid-ether, iridescence, meta-balls, etc.)
are NOT ports of react-bits' OGL implementations — they are hand-written
GLSL fragment shaders using FBM noise, polar warps, isosurface fields and
procedural primitives. Element-level effects (electric-border,
star-border, image-trail) and canvas decorations (magnet-lines,
ribbons) are likewise re-implemented from scratch on plain Canvas2D, so
the runtime stays zero-dependency.
Additional Resources
- reference/components.md — full per-component prop tables and gotchas.
- reference/workflow.md — step-by-step recipes (data video, ad bumper, social hook).
- examples/time-flies/index.html — annotated 30s reference composition.
- examples/raycast-deep-dive-v2/index.html — 40s deep-dive showing spring + shader + transition together.
- examples/raycast-deep-dive-v3/index.html — 40s v3 with electric-border on Hybrid card + magnet-lines architecture + image-trail rows + odometer memory counters.
- examples/raycast-deep-dive-v4/index.html — 40s v4: text-readability pass (
.scene-scrimon every scene, gradient-on-3D-transform fixes),data-hide-mode="visibility"default that eliminates layout jitter, Scene-2 layout overhauled to centered flex (no more right-edge overflow on "macOS + Windows" tag), and 12+ different animation presets across 7 scenes. - examples/gsap-showcase/index.html — 42s GSAP plugin sampler: SplitText (line-mask + char stagger, Scene 1) → DrawSVG (4-stroke "MVM" wordmark, Scene 2) → MorphSVG (square→triangle→star→circle→square, Scene 3) → MotionPath (rocket on a curved path, Scene 4) → Physics2D (60-piece confetti burst, Scene 5) → ScrambleText (Latin + CJK pools, Scene 6) → programmatic GSAP timeline + CustomEase + 4-axis grid stagger (Scene 7). Render with
npm run render:gsap. - examples/time-flies-v2/index.html — a 42s reflective piece on time. Showcases the elegant serif default (Source Han Serif Black for the "Time Flies" / "Live in the Moment" titles, LXGW WenKai for poetic quotes), 7 different shader backgrounds, 6 inter-scene transitions, 12+ entrance animations, and the full odometer chain (463 → 70 → 3,600 → 86,400 → 525,600). See
contact-sheet.jpg. - examples/optimization-v5-summary.jpg — 6-frame proof that both v5 fixes landed: typography upgrade + Scene 2 overflow fix.
- templates/showcase.html — 6-cell grid that renders every shader at once.
- templates/effects-showcase.html — 6-cell sampler for electric-border, star-border, image-trail, meta-balls, magnet-lines, ribbons.
- templates/readable-showcase.html — 12-cell grid that tests
.text-readable/data-scrimagainst busy shaders, plus a sampler for the 10 new outer-animation and 3 new text-animation presets.