Animate Text
- 1.9k installs
- 142 repo stars
- Updated April 24, 2026
- pixel-point/animate-text
animate-text provides a catalog of named text animation JSON specs translatable across motion stacks.
About
The animate-text skill is a curated catalog of 24 text animation specs with exact JSON contracts in assets/specs and assets/effects for portable motion and full reproduction including renderer algorithms and library adapters. Named effects include soft-blur-in, typewriter, shared-axis-y, line reveal, stagger, crossfade, and kinetic-center-build. Agents match user requests to effect IDs, load the JSON spec, and translate into WAAPI, Motion, Framer Motion, GSAP, CSS, Lottie, Rive, or similar renderers without relying on example components alone. Use when animating headings, labels, counters, editorial copy, or text swaps with reproducible motion contracts. 24 curated text animation specs with JSON contracts in assets/specs and assets/effects Named effects: soft-blur-in, typewriter, shared-axis-y, kinetic-center-build, and more Translates specs to WAAPI, Motion, GSAP, CSS, Lottie, or Rive adapters Portable motion contracts separate from host-specific example components Supports headings, labels, counters, and editorial text swap animations animate-text provides a catalog of named text animation JSON specs translatable across motion stacks Renderer-specific implementation matching the.
- 24 curated text animation specs with JSON contracts in assets/specs and assets/effects.
- Named effects: soft-blur-in, typewriter, shared-axis-y, kinetic-center-build, and more.
- Translates specs to WAAPI, Motion, GSAP, CSS, Lottie, or Rive adapters.
- Portable motion contracts separate from host-specific example components.
- Supports headings, labels, counters, and editorial text swap animations.
Animate Text by the numbers
- 1,921 all-time installs (skills.sh)
- +143 installs in the week ending Aug 4, 2026 (Skillselion tracking)
- Ranked #150 of 1,880 Design & UI/UX skills by installs in the Skillselion catalog
- Security screen: LOW risk (skills.sh audit)
- Data as of Aug 5, 2026 (Skillselion catalog sync)
animate-text capabilities & compatibility
- Capabilities
- effect catalog lookup · json spec translation · multi renderer adapters · typography motion · reproducible playback contracts
- Use cases
- frontend · ui design
npx skills add https://github.com/pixel-point/animate-text --skill animate-textAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1.9k |
|---|---|
| repo stars | ★ 142 |
| Security audit | 3 / 3 scanners passed |
| Last updated | April 24, 2026 |
| Repository | pixel-point/animate-text ↗ |
How do I implement a named text animation effect in my target motion library?
Pick and translate named text animation effects from JSON specs into WAAPI, Motion, GSAP, CSS, Lottie, or Rive stacks.
Who is it for?
Frontend developers reproducing catalog text animations across stacks.
Skip if: Full page layout animation or non-text motion without catalog effect IDs.
When should I use this skill?
User names soft-blur-in, typewriter, stagger text, or asks for text animation catalog.
What you get
Renderer-specific implementation matching the catalog spec and playback contract.
- Applied text animation preset
- Portable animation spec JSON
By the numbers
- Includes 20 visible text animation preset IDs
- Covers presets such as typewriter, mask-reveal-up, and kinetic-center-build
Files
Animate Text
Use this skill as a text animation catalog backed by generated JSON contracts.
Use assets/specs/<id>.json for a portable motion contract. Use assets/effects/<id>.json for exact animation reproduction, including content, renderer algorithm, playback, runtime, host requirements, rendering contract, and library adapters.
This skill ships 24 specs in total. The website currently showcases 20 of them.
When To Use
Use this skill when the request involves:
- animating headings, labels, counters, editorial copy, or text swaps
- matching a named effect id such as
soft-blur-in,typewriter,shared-axis-y, orkinetic-center-build - choosing a motion pattern from a curated catalog and translating it into a target stack
- reproducing the current animation behavior in another stack without relying on example components
- implementing the same spec with WAAPI, Motion / motion.dev, GSAP, CSS, Lottie, Rive, or another renderer
Workflow
1. Determine whether the user wants:
- the exact site version of a visible effect
- a portable translation of the motion contract
2. If the user names an effect id, read assets/specs/<id>.json or run node scripts/get-spec.mjs <id>. 3. Otherwise use references/catalog.md or optionally run:
node scripts/list-specs.mjsnode scripts/find-spec.mjs "<query>"
4. Use assets/specs/<id>.json when the user wants a portable translation of the motion intent. 5. Use assets/effects/<id>.json or node scripts/get-effect.mjs <id> when the user wants the exact generated animation behavior. 6. If the user names a target animation library, treat that as binding. Follow showcase.library_selection and use only the matching showcase.library_adapters.<library> block for that effect. 7. For exact reproduction, follow showcase.renderer, showcase.playback, showcase.timing, showcase.runtime, showcase.stage, showcase.rendering_contract, showcase.library_selection, and showcase.library_adapters over assumptions inferred from the portable spec alone. 8. Treat showcase.stage as animation host requirements only. Do not copy typography, color, padding, card chrome, or page layout from the source website unless the user explicitly asks for that UI. 9. When applying an effect to an existing section, preserve that section's text. Use showcase.content only as demo/fallback copy unless the user explicitly asks to reproduce the showcase copy.
Bundled Resources
references/catalog.md: compact summary of the bundled spec libraryreferences/schema.md: field-level schema for portable specs and exact showcase effect recipesreferences/selection-guide.md: heuristics for picking the right effect familyreferences/implementation-notes.md: translation notes for common animation stacksassets/specs/*.json: portable motion contractsassets/effects/*.json: exact generated animation recipesassets/catalog.json: visible website catalog order and renderer overridesassets/samples.json: sample copy used by the generated examplesassets/runtime-presets.json: runtime multipliers and loop timing presetsassets/stage-presets.json: animation host requirements, not presentation stylingassets/renderer-recipes.json: shared renderer algorithmsassets/library-adapters.json: WAAPI, Motion, and GSAP implementation mapping guidance
Optional Helper Scripts
The helper scripts are optional deterministic shortcuts. They require Node.js 20+.
node scripts/list-specs.mjsprints bundled spec metadata as JSONnode scripts/get-spec.mjs <id>prints one portable motion spec as JSONnode scripts/get-effect.mjs <id>prints one exact generated animation recipe as JSONnode scripts/find-spec.mjs "<query>"returns likely matches ranked by metadata
If Node is unavailable, the core skill still works through the Markdown references and JSON assets alone.
Translation Rules
- Preserve
targetexactly:whole,per-character,per-word, orper-line. - Map
enterandexitdurations, easing, and stagger directly into the target stack. - Preserve transform, opacity, blur, scale, rotation, and spacing fields when the target stack supports them.
- For layout-aware effects such as
kinetic-center-buildorshort-slide-down, use the exact effect recipe instead of flattening the effect into a generic stagger. - For exact animation reproduction, preserve the
showcase.renderer,showcase.playback,showcase.timing,showcase.runtime,showcase.stage,showcase.rendering_contract, andshowcase.library_adaptersfields fromassets/effects/<id>.json. - Do not substitute the requested animation library. If the user asks for GSAP, import and use GSAP; if the user asks for Motion, import and use Motion; if the user asks for WAAPI, use
Element.animate. - When targeting Motion or GSAP, use the matching
showcase.library_adapters.motionorshowcase.library_adapters.gsapblock for imports, time-unit conversion, easing conversion, keyframe shape, completion, and renderer-specific notes. - Read
showcase.engine_notesandshowcase.reproduction_notescarefully. They describe stack-specific details required for visual parity. - For exact site behavior, implement the full
showcase.playbackloop. Do not stop at the first enter animation unless the user explicitly asks for a one-shot reveal. - If a renderer recipe says to wait for a phase to complete, either await the animation/tween promise or sleep the computed phase total, not both.
Notes
- The public website uses a curated subset of the bundled library. The skill can still use additional bundled specs that are not currently visible on the website.
assets/specs/*.jsonare the authoritative portable motion contracts.assets/effects/*.jsonare the authoritative exact animation reproduction contracts.- Hidden effects have
"showcase": nullin the exact effect recipe. - If a prose note conflicts with a JSON field, prefer the JSON.
{
"visible_ids": [
"soft-blur-in",
"per-character-rise",
"per-word-crossfade",
"spring-scale-in",
"mask-reveal-up",
"line-by-line-slide",
"typewriter",
"micro-scale-fade",
"shimmer-sweep",
"fade-through",
"shared-axis-y",
"shared-axis-z",
"blur-out-up",
"scale-down-fade",
"focus-blur-resolve",
"bottom-up-letters",
"top-down-letters",
"kinetic-center-build",
"short-slide-right",
"short-slide-down"
],
"renderer_overrides": {
"kinetic-center-build": "kinetic-center-build"
}
}
{
"id": "blur-out-up",
"visibility": "visible",
"portable_spec": {
"id": "blur-out-up",
"display_name": "Blur Out Up",
"description": "Words arrive clean and depart upward with increasing blur for airy exits.",
"inspiration": "Apple-style light typography where exit has more character than entry.",
"target": "per-word",
"signature_easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"enter": {
"duration_ms": 560,
"stagger_ms": 28,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"from": {
"opacity": 0,
"y_px": 10,
"blur_px": 6
},
"to": {
"opacity": 1,
"y_px": 0,
"blur_px": 0
}
},
"exit": {
"duration_ms": 480,
"stagger_ms": 24,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)",
"from": {
"opacity": 1,
"y_px": 0,
"blur_px": 0
},
"to": {
"opacity": 0,
"y_px": -14,
"blur_px": 8
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 170,
"micro_delay_ms": 35
},
"usage_notes": "Works best on short phrases; avoid very long lines to keep swap time tight."
},
"showcase": {
"content": {
"sample": "Clear in, airy out.",
"samples": ["Clear in, airy out.", "Lightweight typography.", "Exit with grace."]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "blur-out-up"
},
"renderer": {
"id": "generic-stagger",
"source": "default",
"params": {},
"recipe": {
"id": "generic-stagger",
"summary": "Split text by target, animate each animated unit from enter.from to enter.to, hold, animate current units from exit.from to exit.to, then replace content.",
"required_dom": [
"one h3.text-animation-title per phrase",
"one span.text-animation-unit per split part",
"animate only non-space parts for per-word targets",
"span.text-animation-unit.line uses display:block for per-line targets"
],
"split_rules": {
"whole": "single animated unit containing the full text",
"per-character": "Array.from(text), preserving punctuation and spaces as animated visual units",
"per-word": "regex /(\\S+|\\s+)/g; create spans for words and whitespace, but animate only non-whitespace spans",
"per-line": "split on explicit \"\\n\"; each line is an animated block span"
},
"stagger_rank_algorithms": {
"normal": "rank equals DOM unit index",
"reverse": "rank 0 starts at last animated unit and proceeds backward",
"center-out": "sort animated indices by absolute distance from center, ties by lower index",
"edges-in": "alternate left edge, right edge, then move inward"
},
"frame_materialization": {
"transform_order": "translate3d(x_px, y_px * runtime.y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"filter": "blur(blur_px)",
"opacity_default": 1,
"scale_default": 1,
"letter_spacing": "for per-character targets, split letter_spacing_em across marginLeft/marginRight halves on glyphs; otherwise assign letterSpacing directly",
"fill": "final frame must remain applied after each phase completes"
},
"loop_algorithm": [
"Wait initial_delay_ms before starting the first enter.",
"Create current phrase, apply enter.from to every animated unit, append it, then animate enter.",
"After the first enter completes, wait hold_ms.",
"Loop from the visible phrase: animate current units through exit.",
"Create next phrase off-DOM and apply enter.from.",
"After the exit completes, wait micro_delay_ms.",
"Replace the stage contents with the next phrase and animate enter.",
"After the next enter completes, wait gap_ms.",
"Continue the loop by exiting the currently visible phrase; do not run another enter for a phrase that is already visible."
],
"canonical_loop_pseudocode": [
"current = createPhrase(firstText); append(current); await enter(current);",
"while active:",
" await sleep(hold_ms);",
" await exit(current);",
" next = createPhrase(nextText); applyEnterFrom(next);",
" await sleep(micro_delay_ms);",
" replaceStage(next);",
" current = next;",
" await enter(current);",
" await sleep(gap_ms);",
"Do not put await enter(current) at the top of the while loop; that double-enters the phrase that just entered before gap_ms."
],
"loop_invariants": [
"The initial phrase enters exactly once before the loop body.",
"Every later phrase enters exactly once immediately after replacement.",
"If implementation awaits an animation or tween promise, do not also sleep for that phase total; use either await completion or sleep(total), not both.",
"Do not implement an enter-only demo when exact playback is requested; preserve exit, replacement, micro-delay, gap, cancellation, and final-frame snapping."
],
"current_site_swap_support": {
"uses_micro_delay_ms": true,
"uses_overlap_ms": false,
"branches_on_swap_mode": false,
"note": "The portable swap block may describe broader intent; the current site showcase uses the playback recipe here as the exact behavior."
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["enter", "hold", "exit", "micro-delay", "gap"],
"replacement_behavior": "exit-before-enter",
"hold_ms": 550,
"micro_delay_ms": 35,
"gap_ms": 320
},
"timing": {
"enter": {
"source_duration_ms": 560,
"source_stagger_ms": 28,
"scaled_duration_ms": 403,
"scaled_stagger_ms": 20,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)"
},
"exit": {
"source_duration_ms": 480,
"source_stagger_ms": 24,
"scaled_duration_ms": 346,
"scaled_stagger_ms": 17,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)"
},
"total_formulas": {
"enter_total_ms": "enter.scaled_duration_ms + max(0, animated_unit_count - 1) * enter.scaled_stagger_ms",
"exit_total_ms": "exit.scaled_duration_ms + max(0, animated_unit_count - 1) * exit.scaled_stagger_ms"
}
},
"stage": {
"preset": "default-text-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "generic-stagger",
"target": "per-word",
"stagger_mode": "normal",
"y_travel_multiplier": 0.58,
"transform_order": "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "current phrase is cleared and replaced only after exit_total_ms + micro_delay_ms"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "CSS",
"notes": [
"CSS keyframes are viable for simple generic-stagger effects if every unit gets the same keyframes and computed delay.",
"CSS alone is usually not sufficient for the site loop unless JavaScript handles content replacement timing."
]
}
],
"reproduction_notes": [
"On the site this effect uses the generic stagger renderer. Apply the portable enter and exit frames per animated unit, preserving the declared target split and stagger ordering.",
"For site parity, scale duration and stagger timing by 0.72 and scale vertical travel by 0.58. These runtime transforms materially affect the perceived pace and distance.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "bottom-up-letters",
"visibility": "visible",
"portable_spec": {
"id": "bottom-up-letters",
"display_name": "Bottom-Up Letters",
"description": "Letters rise from below in a pronounced staircase, one symbol at a time, with zero blur.",
"inspiration": "Apple-style keynote typography, sharp lower-thirds, and clean editorial word swaps.",
"target": "per-character",
"signature_easing": "cubic-bezier(0.18, 1, 0.32, 1)",
"enter": {
"duration_ms": 400,
"stagger_ms": 88,
"easing": "cubic-bezier(0.18, 1, 0.32, 1)",
"from": {
"opacity": 0,
"y_px": 46
},
"to": {
"opacity": 1,
"y_px": 0
}
},
"exit": {
"duration_ms": 280,
"stagger_ms": 28,
"easing": "cubic-bezier(0.7, 0, 0.84, 0)",
"from": {
"opacity": 1,
"y_px": 0
},
"to": {
"opacity": 0,
"y_px": -14
}
},
"swap": {
"mode": "sequential",
"overlap_ms": 0,
"micro_delay_ms": 35,
"scenario_spec": {
"entry_condition": "Use when short words or compact headlines should build upward letter by letter with completely crisp glyph edges.",
"switch_order": [
"Run old text exit first so the slot clears cleanly.",
"Wait micro_delay_ms after exit.",
"Start new text enter from below with per-character stagger."
],
"verification": [
"Letters never blur during enter or exit.",
"The reveal clearly reads bottom-up rather than typewriter-left-to-right.",
"Spacing remains stable while characters settle."
],
"fallback": {
"if_motion_feels_too_tall": "Reduce enter from.y_px from 46 to 36.",
"if_readability_drops": "Increase stagger_ms from 88 to 100 for even more separation."
}
}
},
"usage_notes": "Best for short single words, labels, or compact headline swaps at 40px+. This version is intentionally more staged than per-character-rise: very large per-symbol delay, fewer simultaneous letters on screen, and a taller lift from below."
},
"showcase": {
"content": {
"sample": "Shift",
"samples": ["Shift", "Stage", "Letter"]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "bottom-up-letters"
},
"renderer": {
"id": "generic-stagger",
"source": "default",
"params": {},
"recipe": {
"id": "generic-stagger",
"summary": "Split text by target, animate each animated unit from enter.from to enter.to, hold, animate current units from exit.from to exit.to, then replace content.",
"required_dom": [
"one h3.text-animation-title per phrase",
"one span.text-animation-unit per split part",
"animate only non-space parts for per-word targets",
"span.text-animation-unit.line uses display:block for per-line targets"
],
"split_rules": {
"whole": "single animated unit containing the full text",
"per-character": "Array.from(text), preserving punctuation and spaces as animated visual units",
"per-word": "regex /(\\S+|\\s+)/g; create spans for words and whitespace, but animate only non-whitespace spans",
"per-line": "split on explicit \"\\n\"; each line is an animated block span"
},
"stagger_rank_algorithms": {
"normal": "rank equals DOM unit index",
"reverse": "rank 0 starts at last animated unit and proceeds backward",
"center-out": "sort animated indices by absolute distance from center, ties by lower index",
"edges-in": "alternate left edge, right edge, then move inward"
},
"frame_materialization": {
"transform_order": "translate3d(x_px, y_px * runtime.y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"filter": "blur(blur_px)",
"opacity_default": 1,
"scale_default": 1,
"letter_spacing": "for per-character targets, split letter_spacing_em across marginLeft/marginRight halves on glyphs; otherwise assign letterSpacing directly",
"fill": "final frame must remain applied after each phase completes"
},
"loop_algorithm": [
"Wait initial_delay_ms before starting the first enter.",
"Create current phrase, apply enter.from to every animated unit, append it, then animate enter.",
"After the first enter completes, wait hold_ms.",
"Loop from the visible phrase: animate current units through exit.",
"Create next phrase off-DOM and apply enter.from.",
"After the exit completes, wait micro_delay_ms.",
"Replace the stage contents with the next phrase and animate enter.",
"After the next enter completes, wait gap_ms.",
"Continue the loop by exiting the currently visible phrase; do not run another enter for a phrase that is already visible."
],
"canonical_loop_pseudocode": [
"current = createPhrase(firstText); append(current); await enter(current);",
"while active:",
" await sleep(hold_ms);",
" await exit(current);",
" next = createPhrase(nextText); applyEnterFrom(next);",
" await sleep(micro_delay_ms);",
" replaceStage(next);",
" current = next;",
" await enter(current);",
" await sleep(gap_ms);",
"Do not put await enter(current) at the top of the while loop; that double-enters the phrase that just entered before gap_ms."
],
"loop_invariants": [
"The initial phrase enters exactly once before the loop body.",
"Every later phrase enters exactly once immediately after replacement.",
"If implementation awaits an animation or tween promise, do not also sleep for that phase total; use either await completion or sleep(total), not both.",
"Do not implement an enter-only demo when exact playback is requested; preserve exit, replacement, micro-delay, gap, cancellation, and final-frame snapping."
],
"current_site_swap_support": {
"uses_micro_delay_ms": true,
"uses_overlap_ms": false,
"branches_on_swap_mode": false,
"note": "The portable swap block may describe broader intent; the current site showcase uses the playback recipe here as the exact behavior."
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["enter", "hold", "exit", "micro-delay", "gap"],
"replacement_behavior": "exit-before-enter",
"hold_ms": 550,
"micro_delay_ms": 35,
"gap_ms": 320
},
"timing": {
"enter": {
"source_duration_ms": 400,
"source_stagger_ms": 88,
"scaled_duration_ms": 288,
"scaled_stagger_ms": 63,
"easing": "cubic-bezier(0.18, 1, 0.32, 1)"
},
"exit": {
"source_duration_ms": 280,
"source_stagger_ms": 28,
"scaled_duration_ms": 202,
"scaled_stagger_ms": 20,
"easing": "cubic-bezier(0.7, 0, 0.84, 0)"
},
"total_formulas": {
"enter_total_ms": "enter.scaled_duration_ms + max(0, animated_unit_count - 1) * enter.scaled_stagger_ms",
"exit_total_ms": "exit.scaled_duration_ms + max(0, animated_unit_count - 1) * exit.scaled_stagger_ms"
}
},
"stage": {
"preset": "default-text-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "generic-stagger",
"target": "per-character",
"stagger_mode": "normal",
"y_travel_multiplier": 0.58,
"transform_order": "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "current phrase is cleared and replaced only after exit_total_ms + micro_delay_ms"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "CSS",
"notes": [
"CSS keyframes are viable for simple generic-stagger effects if every unit gets the same keyframes and computed delay.",
"CSS alone is usually not sufficient for the site loop unless JavaScript handles content replacement timing."
]
}
],
"reproduction_notes": [
"On the site this effect uses the generic stagger renderer. Apply the portable enter and exit frames per animated unit, preserving the declared target split and stagger ordering.",
"For site parity, scale duration and stagger timing by 0.72 and scale vertical travel by 0.58. These runtime transforms materially affect the perceived pace and distance.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "depth-parallax-words",
"visibility": "hidden",
"portable_spec": {
"id": "depth-parallax-words",
"display_name": "Depth Parallax Words",
"description": "Per-word depth motion with scale and vertical drift for layered readability.",
"inspiration": "Product landing pages combining depth cues with clean typography.",
"target": "per-word",
"signature_easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"enter": {
"duration_ms": 700,
"stagger_ms": 70,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"from": {
"opacity": 0,
"y_px": 18,
"scale": 0.92,
"blur_px": 3
},
"to": {
"opacity": 1,
"y_px": 0,
"scale": 1,
"blur_px": 0
}
},
"exit": {
"duration_ms": 500,
"stagger_ms": 45,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)",
"from": {
"opacity": 1,
"y_px": 0,
"scale": 1,
"blur_px": 0
},
"to": {
"opacity": 0,
"y_px": -10,
"scale": 1.05,
"blur_px": 2
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 180,
"micro_delay_ms": 30
},
"usage_notes": "Use short copy blocks and moderate stagger to avoid visual overload."
},
"showcase": null
}
{
"id": "fade-through",
"visibility": "visible",
"portable_spec": {
"id": "fade-through",
"display_name": "Fade Through",
"description": "A Material-style content transition: old fades out, new fades in with a soft delay.",
"inspiration": "Google Material fade through transitions for same-level UI changes.",
"target": "whole",
"signature_easing": "cubic-bezier(0.2, 0, 0, 1)",
"enter": {
"duration_ms": 420,
"stagger_ms": 0,
"easing": "cubic-bezier(0.2, 0, 0, 1)",
"from": {
"opacity": 0,
"y_px": 6,
"scale": 0.99,
"blur_px": 2
},
"to": {
"opacity": 1,
"y_px": 0,
"scale": 1,
"blur_px": 0
}
},
"exit": {
"duration_ms": 260,
"stagger_ms": 0,
"easing": "cubic-bezier(0.4, 0, 1, 1)",
"from": {
"opacity": 1,
"y_px": 0,
"scale": 1,
"blur_px": 0
},
"to": {
"opacity": 0,
"y_px": -4,
"scale": 1,
"blur_px": 0
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 20,
"micro_delay_ms": 60
},
"usage_notes": "Best for replacing content in the same layout slot without directional meaning."
},
"showcase": {
"content": {
"sample": "Calm transitions.",
"samples": ["Calm transitions.", "Fade through content.", "Focus shifts smoothly."]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "fade-through"
},
"renderer": {
"id": "generic-stagger",
"source": "default",
"params": {},
"recipe": {
"id": "generic-stagger",
"summary": "Split text by target, animate each animated unit from enter.from to enter.to, hold, animate current units from exit.from to exit.to, then replace content.",
"required_dom": [
"one h3.text-animation-title per phrase",
"one span.text-animation-unit per split part",
"animate only non-space parts for per-word targets",
"span.text-animation-unit.line uses display:block for per-line targets"
],
"split_rules": {
"whole": "single animated unit containing the full text",
"per-character": "Array.from(text), preserving punctuation and spaces as animated visual units",
"per-word": "regex /(\\S+|\\s+)/g; create spans for words and whitespace, but animate only non-whitespace spans",
"per-line": "split on explicit \"\\n\"; each line is an animated block span"
},
"stagger_rank_algorithms": {
"normal": "rank equals DOM unit index",
"reverse": "rank 0 starts at last animated unit and proceeds backward",
"center-out": "sort animated indices by absolute distance from center, ties by lower index",
"edges-in": "alternate left edge, right edge, then move inward"
},
"frame_materialization": {
"transform_order": "translate3d(x_px, y_px * runtime.y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"filter": "blur(blur_px)",
"opacity_default": 1,
"scale_default": 1,
"letter_spacing": "for per-character targets, split letter_spacing_em across marginLeft/marginRight halves on glyphs; otherwise assign letterSpacing directly",
"fill": "final frame must remain applied after each phase completes"
},
"loop_algorithm": [
"Wait initial_delay_ms before starting the first enter.",
"Create current phrase, apply enter.from to every animated unit, append it, then animate enter.",
"After the first enter completes, wait hold_ms.",
"Loop from the visible phrase: animate current units through exit.",
"Create next phrase off-DOM and apply enter.from.",
"After the exit completes, wait micro_delay_ms.",
"Replace the stage contents with the next phrase and animate enter.",
"After the next enter completes, wait gap_ms.",
"Continue the loop by exiting the currently visible phrase; do not run another enter for a phrase that is already visible."
],
"canonical_loop_pseudocode": [
"current = createPhrase(firstText); append(current); await enter(current);",
"while active:",
" await sleep(hold_ms);",
" await exit(current);",
" next = createPhrase(nextText); applyEnterFrom(next);",
" await sleep(micro_delay_ms);",
" replaceStage(next);",
" current = next;",
" await enter(current);",
" await sleep(gap_ms);",
"Do not put await enter(current) at the top of the while loop; that double-enters the phrase that just entered before gap_ms."
],
"loop_invariants": [
"The initial phrase enters exactly once before the loop body.",
"Every later phrase enters exactly once immediately after replacement.",
"If implementation awaits an animation or tween promise, do not also sleep for that phase total; use either await completion or sleep(total), not both.",
"Do not implement an enter-only demo when exact playback is requested; preserve exit, replacement, micro-delay, gap, cancellation, and final-frame snapping."
],
"current_site_swap_support": {
"uses_micro_delay_ms": true,
"uses_overlap_ms": false,
"branches_on_swap_mode": false,
"note": "The portable swap block may describe broader intent; the current site showcase uses the playback recipe here as the exact behavior."
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["enter", "hold", "exit", "micro-delay", "gap"],
"replacement_behavior": "exit-before-enter",
"hold_ms": 550,
"micro_delay_ms": 60,
"gap_ms": 320
},
"timing": {
"enter": {
"source_duration_ms": 420,
"source_stagger_ms": 0,
"scaled_duration_ms": 302,
"scaled_stagger_ms": 0,
"easing": "cubic-bezier(0.2, 0, 0, 1)"
},
"exit": {
"source_duration_ms": 260,
"source_stagger_ms": 0,
"scaled_duration_ms": 187,
"scaled_stagger_ms": 0,
"easing": "cubic-bezier(0.4, 0, 1, 1)"
},
"total_formulas": {
"enter_total_ms": "enter.scaled_duration_ms + max(0, animated_unit_count - 1) * enter.scaled_stagger_ms",
"exit_total_ms": "exit.scaled_duration_ms + max(0, animated_unit_count - 1) * exit.scaled_stagger_ms"
}
},
"stage": {
"preset": "default-text-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "generic-stagger",
"target": "whole",
"stagger_mode": "normal",
"y_travel_multiplier": 0.58,
"transform_order": "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "current phrase is cleared and replaced only after exit_total_ms + micro_delay_ms"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "CSS",
"notes": [
"CSS keyframes are viable for simple generic-stagger effects if every unit gets the same keyframes and computed delay.",
"CSS alone is usually not sufficient for the site loop unless JavaScript handles content replacement timing."
]
}
],
"reproduction_notes": [
"On the site this effect uses the generic stagger renderer. Apply the portable enter and exit frames per animated unit, preserving the declared target split and stagger ordering.",
"For site parity, scale duration and stagger timing by 0.72 and scale vertical travel by 0.58. These runtime transforms materially affect the perceived pace and distance.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "focus-blur-resolve",
"visibility": "visible",
"portable_spec": {
"id": "focus-blur-resolve",
"display_name": "Focus Blur Resolve",
"description": "A premium focus pull from heavy blur to crisp text, then a soft blur-out exit.",
"inspiration": "Apple-style hero transitions that resolve detail with cinematic restraint.",
"target": "whole",
"signature_easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"enter": {
"duration_ms": 760,
"stagger_ms": 0,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"from": {
"opacity": 0,
"y_px": 14,
"blur_px": 14,
"scale": 1.01
},
"to": {
"opacity": 1,
"y_px": 0,
"blur_px": 0,
"scale": 1
}
},
"exit": {
"duration_ms": 520,
"stagger_ms": 0,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)",
"from": {
"opacity": 1,
"y_px": 0,
"blur_px": 0,
"scale": 1
},
"to": {
"opacity": 0,
"y_px": -10,
"blur_px": 10,
"scale": 1
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 160,
"micro_delay_ms": 35
},
"usage_notes": "Best on large headlines where blur distance reads as intentional and premium."
},
"showcase": {
"content": {
"sample": "Focus resolves clearly.",
"samples": ["Focus resolves clearly.", "Detail emerges.", "Then softly recedes."]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "focus-blur-resolve"
},
"renderer": {
"id": "generic-stagger",
"source": "default",
"params": {},
"recipe": {
"id": "generic-stagger",
"summary": "Split text by target, animate each animated unit from enter.from to enter.to, hold, animate current units from exit.from to exit.to, then replace content.",
"required_dom": [
"one h3.text-animation-title per phrase",
"one span.text-animation-unit per split part",
"animate only non-space parts for per-word targets",
"span.text-animation-unit.line uses display:block for per-line targets"
],
"split_rules": {
"whole": "single animated unit containing the full text",
"per-character": "Array.from(text), preserving punctuation and spaces as animated visual units",
"per-word": "regex /(\\S+|\\s+)/g; create spans for words and whitespace, but animate only non-whitespace spans",
"per-line": "split on explicit \"\\n\"; each line is an animated block span"
},
"stagger_rank_algorithms": {
"normal": "rank equals DOM unit index",
"reverse": "rank 0 starts at last animated unit and proceeds backward",
"center-out": "sort animated indices by absolute distance from center, ties by lower index",
"edges-in": "alternate left edge, right edge, then move inward"
},
"frame_materialization": {
"transform_order": "translate3d(x_px, y_px * runtime.y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"filter": "blur(blur_px)",
"opacity_default": 1,
"scale_default": 1,
"letter_spacing": "for per-character targets, split letter_spacing_em across marginLeft/marginRight halves on glyphs; otherwise assign letterSpacing directly",
"fill": "final frame must remain applied after each phase completes"
},
"loop_algorithm": [
"Wait initial_delay_ms before starting the first enter.",
"Create current phrase, apply enter.from to every animated unit, append it, then animate enter.",
"After the first enter completes, wait hold_ms.",
"Loop from the visible phrase: animate current units through exit.",
"Create next phrase off-DOM and apply enter.from.",
"After the exit completes, wait micro_delay_ms.",
"Replace the stage contents with the next phrase and animate enter.",
"After the next enter completes, wait gap_ms.",
"Continue the loop by exiting the currently visible phrase; do not run another enter for a phrase that is already visible."
],
"canonical_loop_pseudocode": [
"current = createPhrase(firstText); append(current); await enter(current);",
"while active:",
" await sleep(hold_ms);",
" await exit(current);",
" next = createPhrase(nextText); applyEnterFrom(next);",
" await sleep(micro_delay_ms);",
" replaceStage(next);",
" current = next;",
" await enter(current);",
" await sleep(gap_ms);",
"Do not put await enter(current) at the top of the while loop; that double-enters the phrase that just entered before gap_ms."
],
"loop_invariants": [
"The initial phrase enters exactly once before the loop body.",
"Every later phrase enters exactly once immediately after replacement.",
"If implementation awaits an animation or tween promise, do not also sleep for that phase total; use either await completion or sleep(total), not both.",
"Do not implement an enter-only demo when exact playback is requested; preserve exit, replacement, micro-delay, gap, cancellation, and final-frame snapping."
],
"current_site_swap_support": {
"uses_micro_delay_ms": true,
"uses_overlap_ms": false,
"branches_on_swap_mode": false,
"note": "The portable swap block may describe broader intent; the current site showcase uses the playback recipe here as the exact behavior."
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["enter", "hold", "exit", "micro-delay", "gap"],
"replacement_behavior": "exit-before-enter",
"hold_ms": 550,
"micro_delay_ms": 35,
"gap_ms": 320
},
"timing": {
"enter": {
"source_duration_ms": 760,
"source_stagger_ms": 0,
"scaled_duration_ms": 547,
"scaled_stagger_ms": 0,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)"
},
"exit": {
"source_duration_ms": 520,
"source_stagger_ms": 0,
"scaled_duration_ms": 374,
"scaled_stagger_ms": 0,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)"
},
"total_formulas": {
"enter_total_ms": "enter.scaled_duration_ms + max(0, animated_unit_count - 1) * enter.scaled_stagger_ms",
"exit_total_ms": "exit.scaled_duration_ms + max(0, animated_unit_count - 1) * exit.scaled_stagger_ms"
}
},
"stage": {
"preset": "default-text-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "generic-stagger",
"target": "whole",
"stagger_mode": "normal",
"y_travel_multiplier": 0.58,
"transform_order": "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "current phrase is cleared and replaced only after exit_total_ms + micro_delay_ms"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "CSS",
"notes": [
"CSS keyframes are viable for simple generic-stagger effects if every unit gets the same keyframes and computed delay.",
"CSS alone is usually not sufficient for the site loop unless JavaScript handles content replacement timing."
]
}
],
"reproduction_notes": [
"On the site this effect uses the generic stagger renderer. Apply the portable enter and exit frames per animated unit, preserving the declared target split and stagger ordering.",
"For site parity, scale duration and stagger timing by 0.72 and scale vertical travel by 0.58. These runtime transforms materially affect the perceived pace and distance.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "kinetic-center-build",
"visibility": "visible",
"portable_spec": {
"id": "kinetic-center-build",
"display_name": "Kinetic Center Build",
"description": "A word appears in the center; each new word enters from right to left with a soft blur and pushes the existing line until the full phrase locks centered.",
"inspiration": "Apple keynote kinetic editorial typography and sequential phrase builds.",
"target": "per-word",
"signature_easing": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"enter": {
"duration_ms": 360,
"stagger_ms": 0,
"easing": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"from": {
"opacity": 0,
"y_px": 6,
"scale": 0.992,
"blur_px": 3.5
},
"to": {
"opacity": 1,
"y_px": 0,
"scale": 1,
"blur_px": 0
}
},
"exit": {
"duration_ms": 260,
"stagger_ms": 0,
"easing": "cubic-bezier(0.4, 0, 0.2, 1)",
"from": {
"opacity": 1,
"y_px": 0,
"blur_px": 0
},
"to": {
"opacity": 0,
"y_px": -6,
"blur_px": 2.5
}
},
"swap": {
"mode": "sequential",
"overlap_ms": 0,
"micro_delay_ms": 220,
"scenario_spec": {
"entry_condition": "Use when a short phrase should be built word-by-word, with each new word entering from the right and physically re-centering the existing line.",
"switch_order": [
"Show the first word in the center.",
"Bring the second word in from right to left while shifting the first word left.",
"Bring the third word in from right to left while shifting the first two words so the final phrase stays centered."
],
"verification": [
"Each new word visibly pushes the existing words rather than simply fading in.",
"The completed phrase ends centered and evenly spaced.",
"The motion reads as one kinetic line build, not as three isolated reveals."
],
"fallback": {
"if_push_is_too_subtle": "Increase build.entry_offset_px from 96 to 120.",
"if_phrase_feels_too_slow": "Reduce build.push_duration_ms from 480 to 420."
}
}
},
"build": {
"entry_direction": "from-right",
"line_alignment": "center",
"first_word_duration_ms": 340,
"push_duration_ms": 430,
"entry_offset_px": 88,
"word_gap_px": 10,
"first_word_y_px": 6,
"entry_scale": 0.992,
"entry_blur_px": 3.5,
"reflow_blur_px": 0.8,
"exit_y_px": -6,
"exit_blur_px": 2.5,
"easing": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"exit_easing": "cubic-bezier(0.4, 0, 0.2, 1)",
"phrase_samples": [
["Words", "push", "left"],
["Type", "locks", "center"],
["Build", "the", "line"]
]
},
"usage_notes": "Layout-aware effect: each incoming word changes the target x-position of the whole line. Best for short three-word phrases; implementation requires measuring word widths and animating existing words to new positions. A small entry and reflow blur helps the push feel smoother without extending the timing."
},
"showcase": {
"content": {
"sample": "Words push left.",
"phrases": [
["Words", "push", "left"],
["Type", "locks", "center"],
["Build", "the", "line"]
]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "kinetic-center-build"
},
"renderer": {
"id": "kinetic-center-build",
"source": "catalog-override",
"params": {
"entry_direction": "from-right",
"line_alignment": "center",
"first_word_duration_ms": 340,
"push_duration_ms": 430,
"entry_offset_px": 88,
"word_gap_px": 10,
"first_word_y_px": 6,
"entry_scale": 0.992,
"entry_blur_px": 3.5,
"reflow_blur_px": 0.8,
"exit_y_px": -6,
"exit_blur_px": 2.5,
"easing": "cubic-bezier(0.2, 0.8, 0.2, 1)",
"exit_easing": "cubic-bezier(0.4, 0, 0.2, 1)",
"phrase_samples": [
["Words", "push", "left"],
["Type", "locks", "center"],
["Build", "the", "line"]
]
},
"recipe": {
"id": "kinetic-center-build",
"summary": "Build a centered horizontal phrase word by word; each incoming word enters from the right and pushes existing words into newly centered positions.",
"required_measurements": ["offsetWidth for every word after appending the incoming word"],
"algorithm": [
"Create a relative kinetic line container using the kinetic-line-host stage preset.",
"For each phrase word, append an absolutely centered word span.",
"Measure all child widths and compute centered x positions: totalWidth = sum(widths) + word_gap_px * (count - 1); cursor starts at -totalWidth / 2; each word position is cursor + width / 2.",
"First word enters at x=0 with first_word_y_px, entry_scale, entry_blur_px, and opacity 0, then settles to x=0/y=0/scale=1/blur=0/opacity=1.",
"For later words, animate existing words from previous x positions to next centered x positions while the incoming word starts at targetX + entry_offset_px and lands at targetX.",
"Use an intermediate keyframe around offset 0.52 for existing-word reflow blur and 0.6 for incoming-word settle blur.",
"After every push, snap all words to exact final poses to avoid accumulated engine drift.",
"Exit all words together from current centered x positions with exit_y_px and exit_blur_px, then clear the line."
],
"frame_materialization": {
"coordinate_space": "x/y values are renderer pixel coordinates and are not multiplied by runtime.y_travel_multiplier.",
"transform": "translate(-50%, -50%) translate3d(x, y, 0) scale(scale)",
"filter": "blur(blur)",
"opacity": "unit opacity"
},
"keyframe_recipe": {
"first_word": [
{
"offset": 0,
"x": 0,
"y": "build.first_word_y_px",
"scale": "build.entry_scale",
"blur": "build.entry_blur_px",
"opacity": 0
},
{
"offset": 0.58,
"x": 0,
"y": "build.first_word_y_px * 0.35",
"scale": 0.998,
"blur": "build.entry_blur_px * 0.45",
"opacity": 0.78
},
{
"offset": 1,
"x": 0,
"y": 0,
"scale": 1,
"blur": 0,
"opacity": 1
}
],
"existing_word_push": [
{
"offset": 0,
"x": "currentX",
"y": 0,
"scale": 1,
"blur": 0,
"opacity": 1
},
{
"offset": 0.52,
"x": "mix(currentX, nextX, 0.58)",
"y": 0,
"scale": 1,
"blur": "build.reflow_blur_px",
"opacity": 1
},
{
"offset": 1,
"x": "nextX",
"y": 0,
"scale": 1,
"blur": 0,
"opacity": 1
}
],
"incoming_word_push": [
{
"offset": 0,
"x": "targetX + build.entry_offset_px",
"y": 0,
"scale": "build.entry_scale",
"blur": "build.entry_blur_px",
"opacity": 0
},
{
"offset": 0.6,
"x": "mix(targetX + build.entry_offset_px, targetX, 0.72)",
"y": 0,
"scale": 0.998,
"blur": "build.entry_blur_px * 0.38",
"opacity": 0.84
},
{
"offset": 1,
"x": "targetX",
"y": 0,
"scale": 1,
"blur": 0,
"opacity": 1
}
],
"exit_word": [
{
"offset": 0,
"x": "position",
"y": 0,
"scale": 1,
"blur": 0,
"opacity": 1
},
{
"offset": 0.52,
"x": "position",
"y": "build.exit_y_px * 0.45",
"scale": 1,
"blur": "build.exit_blur_px * 0.55",
"opacity": 0.62
},
{
"offset": 1,
"x": "position",
"y": "build.exit_y_px",
"scale": 1,
"blur": "build.exit_blur_px",
"opacity": 0
}
]
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["build-phrase", "hold", "exit-phrase", "gap"],
"replacement_behavior": "phrase-loop",
"hold_ms": 706,
"micro_delay_ms": 0,
"gap_ms": 158
},
"timing": {
"first_word": {
"source_duration_ms": 340,
"scaled_duration_ms": 245,
"easing": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"push": {
"source_duration_ms": 430,
"scaled_duration_ms": 310,
"easing": "cubic-bezier(0.2, 0.8, 0.2, 1)"
},
"exit": {
"source_duration_ms": 260,
"scaled_duration_ms": 187,
"easing": "cubic-bezier(0.4, 0, 0.2, 1)"
},
"hold_ms": 706,
"gap_ms": 158
},
"stage": {
"preset": "kinetic-line-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
},
"kinetic_container": {
"requirement": "Use a relative-positioned inline host large enough for the phrase; exact dimensions belong to the consuming UI.",
"position": "relative",
"coordinate_origin": "center"
},
"kinetic_word": {
"backface_visibility": "hidden",
"left": "50%",
"position": "absolute",
"top": "50%",
"white_space": "nowrap",
"absolute_centered": true,
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "kinetic-center-build",
"target": "per-word",
"stagger_mode": "normal",
"coordinate_space": "renderer-pixels",
"y_travel_multiplier": 1,
"y_travel_multiplier_note": "runtime.y_travel_multiplier is not applied to kinetic build coordinates; x/y values in build params are final transform pixels.",
"transform_order": "translate(-50%, -50%) translate3d(x_px, y_px, 0) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "follow renderer recipe algorithm"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Measure word widths after appending each incoming word.",
"Compute centered x positions from measured widths and word_gap_px.",
"Use raw renderer-pixel build x/y values; do not apply y_travel_multiplier to kinetic coordinates.",
"Use renderer.recipe.keyframe_recipe exactly: existing-word reflow x is mix(currentX, nextX, 0.58) at offset 0.52; incoming-word settle x is mix(startX, targetX, 0.72) at offset 0.6.",
"Exit uses a three-keyframe path with offset 0.52 at y = exit_y_px * 0.45 and opacity 0.62, not a two-keyframe fade."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Measure word widths after appending each incoming word.",
"Compute centered x positions from measured widths and word_gap_px.",
"Use raw renderer-pixel build x/y values; do not apply y_travel_multiplier to kinetic coordinates.",
"Use renderer.recipe.keyframe_recipe exactly: existing-word reflow x is mix(currentX, nextX, 0.58) at offset 0.52; incoming-word settle x is mix(startX, targetX, 0.72) at offset 0.6.",
"Exit uses a three-keyframe path with offset 0.52 at y = exit_y_px * 0.45 and opacity 0.62, not a two-keyframe fade."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Measure word widths after appending each incoming word.",
"Compute centered x positions from measured widths and word_gap_px.",
"Use raw renderer-pixel build x/y values; do not apply y_travel_multiplier to kinetic coordinates.",
"Use renderer.recipe.keyframe_recipe exactly: existing-word reflow x is mix(currentX, nextX, 0.58) at offset 0.52; incoming-word settle x is mix(startX, targetX, 0.72) at offset 0.6.",
"Exit uses a three-keyframe path with offset 0.52 at y = exit_y_px * 0.45 and opacity 0.62, not a two-keyframe fade."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "All engines",
"notes": [
"Do not apply runtime.y_travel_multiplier to kinetic build x/y coordinates; buildKineticFrame uses the build params as final transform pixels.",
"Use explicit offset keyframes for the intermediate reflow frames, then snap final styles after each push to avoid layout drift."
]
}
],
"reproduction_notes": [
"On the site this effect is layout-aware. Measure word widths, compute centered x positions for the whole phrase, and animate existing words to their next positions while the incoming word enters from the right.",
"For site parity, scale duration and stagger timing by 0.72. Keep kinetic build x/y params as raw renderer pixel coordinates; runtime.y_travel_multiplier applies to generic/title frame conversion, not to buildKineticFrame coordinates.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "line-by-line-slide",
"visibility": "visible",
"portable_spec": {
"id": "line-by-line-slide",
"display_name": "Line-by-Line Slide",
"description": "Each line enters from the left with a staggered slide and exits to the right for a flowing paragraph reveal.",
"inspiration": "Apple landing page subheads and section headers that breathe line by line.",
"target": "per-line",
"signature_easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"enter": {
"duration_ms": 900,
"stagger_ms": 120,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)",
"from": {
"opacity": 0,
"x_px": -48
},
"to": {
"opacity": 1,
"x_px": 0
}
},
"exit": {
"duration_ms": 600,
"stagger_ms": 80,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)",
"from": {
"opacity": 1,
"x_px": 0
},
"to": {
"opacity": 0,
"x_px": 48
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 0,
"micro_delay_ms": 20
},
"usage_notes": "Great for 2-line or 3-line headings. This variant keeps swap non-overlapping to avoid content intersections. Reduce x-distance for narrow layouts to keep motion tight on mobile."
},
"showcase": {
"content": {
"sample": "Think different.\nDo more.",
"samples": [
"Think different.\nDo more.",
"Built for speed.\nMade to last.",
"Clear ideas.\nClean motion."
]
},
"content_usage": {
"default_policy": "When applying an effect to an existing heading or text section, preserve the section text. Do not replace user/application copy with showcase sample text unless the user explicitly asks to reproduce the demo copy.",
"showcase_samples": "showcase.content.sample and samples are reference/demo copy used by the generated website examples and useful fallback copy for isolated demos.",
"loop_policy": "If the existing section supplies multiple phrases, loop those phrases. If it supplies one phrase, animate that phrase with the same enter/exit playback or use explicitly provided alternate phrases."
},
"sample_source": {
"asset": "assets/samples.json",
"key": "line-by-line-slide"
},
"renderer": {
"id": "generic-stagger",
"source": "default",
"params": {},
"recipe": {
"id": "generic-stagger",
"summary": "Split text by target, animate each animated unit from enter.from to enter.to, hold, animate current units from exit.from to exit.to, then replace content.",
"required_dom": [
"one h3.text-animation-title per phrase",
"one span.text-animation-unit per split part",
"animate only non-space parts for per-word targets",
"span.text-animation-unit.line uses display:block for per-line targets"
],
"split_rules": {
"whole": "single animated unit containing the full text",
"per-character": "Array.from(text), preserving punctuation and spaces as animated visual units",
"per-word": "regex /(\\S+|\\s+)/g; create spans for words and whitespace, but animate only non-whitespace spans",
"per-line": "split on explicit \"\\n\"; each line is an animated block span"
},
"stagger_rank_algorithms": {
"normal": "rank equals DOM unit index",
"reverse": "rank 0 starts at last animated unit and proceeds backward",
"center-out": "sort animated indices by absolute distance from center, ties by lower index",
"edges-in": "alternate left edge, right edge, then move inward"
},
"frame_materialization": {
"transform_order": "translate3d(x_px, y_px * runtime.y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"filter": "blur(blur_px)",
"opacity_default": 1,
"scale_default": 1,
"letter_spacing": "for per-character targets, split letter_spacing_em across marginLeft/marginRight halves on glyphs; otherwise assign letterSpacing directly",
"fill": "final frame must remain applied after each phase completes"
},
"loop_algorithm": [
"Wait initial_delay_ms before starting the first enter.",
"Create current phrase, apply enter.from to every animated unit, append it, then animate enter.",
"After the first enter completes, wait hold_ms.",
"Loop from the visible phrase: animate current units through exit.",
"Create next phrase off-DOM and apply enter.from.",
"After the exit completes, wait micro_delay_ms.",
"Replace the stage contents with the next phrase and animate enter.",
"After the next enter completes, wait gap_ms.",
"Continue the loop by exiting the currently visible phrase; do not run another enter for a phrase that is already visible."
],
"canonical_loop_pseudocode": [
"current = createPhrase(firstText); append(current); await enter(current);",
"while active:",
" await sleep(hold_ms);",
" await exit(current);",
" next = createPhrase(nextText); applyEnterFrom(next);",
" await sleep(micro_delay_ms);",
" replaceStage(next);",
" current = next;",
" await enter(current);",
" await sleep(gap_ms);",
"Do not put await enter(current) at the top of the while loop; that double-enters the phrase that just entered before gap_ms."
],
"loop_invariants": [
"The initial phrase enters exactly once before the loop body.",
"Every later phrase enters exactly once immediately after replacement.",
"If implementation awaits an animation or tween promise, do not also sleep for that phase total; use either await completion or sleep(total), not both.",
"Do not implement an enter-only demo when exact playback is requested; preserve exit, replacement, micro-delay, gap, cancellation, and final-frame snapping."
],
"current_site_swap_support": {
"uses_micro_delay_ms": true,
"uses_overlap_ms": false,
"branches_on_swap_mode": false,
"note": "The portable swap block may describe broader intent; the current site showcase uses the playback recipe here as the exact behavior."
}
}
},
"runtime": {
"preset": "website-default",
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
},
"playback": {
"kind": "loop",
"cycle": ["enter", "hold", "exit", "micro-delay", "gap"],
"replacement_behavior": "exit-before-enter",
"hold_ms": 550,
"micro_delay_ms": 20,
"gap_ms": 320
},
"timing": {
"enter": {
"source_duration_ms": 900,
"source_stagger_ms": 120,
"scaled_duration_ms": 648,
"scaled_stagger_ms": 86,
"easing": "cubic-bezier(0.22, 1, 0.36, 1)"
},
"exit": {
"source_duration_ms": 600,
"source_stagger_ms": 80,
"scaled_duration_ms": 432,
"scaled_stagger_ms": 58,
"easing": "cubic-bezier(0.64, 0, 0.78, 0)"
},
"total_formulas": {
"enter_total_ms": "enter.scaled_duration_ms + max(0, animated_unit_count - 1) * enter.scaled_stagger_ms",
"exit_total_ms": "exit.scaled_duration_ms + max(0, animated_unit_count - 1) * exit.scaled_stagger_ms"
}
},
"stage": {
"preset": "default-text-host",
"purpose": "Animation-only host requirements. Typography, color, card chrome, padding, and responsive sizing are intentionally excluded so the skill stays portable.",
"container": {
"requirement": "Provide a host element for the animated title.",
"perspective_px": 900,
"perspective_note": "Needed when effects use z_px, rotate_x_deg, or rotate_y_deg. Host layout and size are application-owned."
},
"title": {
"requirement": "Animate the phrase container when the renderer recipe uses title frames.",
"display": "inline-block",
"transform_style": "preserve-3d",
"layout_note": "Do not force flex-direction: column on the title globally; line breaks come from span.text-animation-unit.line using display:block."
},
"unit": {
"backface_visibility": "hidden",
"display": "inline-block",
"line_display": "block",
"transform_origin": "50% 55%",
"white_space": "pre",
"will_change": ["transform", "opacity", "filter"]
}
},
"rendering_contract": {
"renderer": "generic-stagger",
"target": "per-line",
"stagger_mode": "normal",
"y_travel_multiplier": 0.58,
"transform_order": "translate3d(x_px, y_px * y_travel_multiplier, z_px) rotateX(rotate_x_deg) rotateY(rotate_y_deg) rotate(rotate_deg) scale(scale)",
"fill_behavior": "retain final frame after each phase",
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
},
"content_replacement": "current phrase is cleared and replaced only after exit_total_ms + micro_delay_ms"
},
"library_selection": {
"supported_adapters": ["waapi", "motion", "gsap"],
"aliases": {
"web animations api": "waapi",
"waapi": "waapi",
"motion": "motion",
"motion.dev": "motion",
"motion react": "motion",
"framer motion": "motion",
"gsap": "gsap",
"greensock": "gsap"
},
"rule": "If the user names a target animation library, use only the matching adapter for that effect. Do not silently substitute Motion for GSAP, GSAP for Motion, or WAAPI for either library. If a requested library is unsupported, state that limitation before implementing.",
"verification": "For generated code, verify imports and animation calls match the selected adapter: Motion should import/use animate from motion/react and not Element.animate/gsap, GSAP should import/use gsap and CustomEase and not Motion/Element.animate, and WAAPI should use Element.animate without a third-party animation import."
},
"library_adapters": {
"waapi": {
"target_library": "Web Animations API",
"install": "none; native browser Element.animate",
"import_statement": null,
"time_unit": "milliseconds",
"start_animation": "element.animate(keyframes, { delay: delay_ms, duration: duration_ms, easing, fill: \"forwards\" })",
"keyframe_shape": "Use CSS-style Keyframe[] objects with transform, filter, opacity, letterSpacing, and optional offset fields.",
"easing": "Pass CSS easing strings directly, including cubic-bezier(...) and steps(...).",
"completion": "await animation.finished, then assign the final keyframe styles before replacing content.",
"cancellation": "cancel active Animation objects and clear pending timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"motion": {
"target_library": "Motion for React / motion.dev",
"install": "pnpm add motion",
"import_statement": "import { animate, cubicBezier, steps } from \"motion/react\";",
"time_unit": "seconds for delay and duration options",
"start_animation": "animate(element, propertyKeyframes, { delay: delay_ms / 1000, duration: duration_ms / 1000, ease, times })",
"keyframe_shape": "Convert Keyframe[] into property arrays, for example { opacity: [0, 1], transform: [\"...\", \"...\"], filter: [\"...\", \"...\"] }. Convert keyframe offset values into the times array.",
"verification": [
"When offsets are present, pass times in the Motion options object, not inside the propertyKeyframes object.",
"The Motion times array length must match each animated property array length for that tween.",
"Motion TypeScript may reject CSS transform/filter property arrays; use a local typed helper/cast at the animate boundary instead of changing the keyframe shape.",
"Exact reproduction must include exit/replacement playback, not only initial enter tweens."
],
"easing": "Convert cubic-bezier(a,b,c,d) to cubicBezier(a,b,c,d). Convert steps(n,start|end) to steps(n, \"start\"|\"end\"). Map CSS ease-in/ease-out/ease-in-out to Motion easeIn/easeOut/easeInOut.",
"completion": "Use controls.then(...) or await the returned controls in an async loop, then assign final styles before content replacement.",
"cancellation": "call controls.stop?.() and controls.cancel?.() for active Motion animations when available, and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
},
"gsap": {
"target_library": "GSAP",
"install": "pnpm add gsap",
"import_statement": "import { gsap } from \"gsap\"; import { CustomEase } from \"gsap/CustomEase\"; gsap.registerPlugin(CustomEase);",
"time_unit": "seconds for delay and duration options",
"start_animation": "gsap.set(element, firstKeyframe); gsap.to(element, { keyframes: remainingKeyframesWithSegmentDurations, delay: delay_ms / 1000, ease, overwrite: \"auto\" })",
"keyframe_shape": "Use GSAP property objects with transform, filter, opacity, letterSpacing. For offset keyframes, convert adjacent offset gaps into absolute per-keyframe segment durations in seconds.",
"verification": [
"Initialize first-frame styles with gsap.set before starting a tween.",
"Do not pass both per-keyframe segment durations and a top-level gsap.to duration; that retimes the tween and makes the GSAP reproduction feel slower than the spec.",
"For renderer keyframe_recipe offsets, use GSAP keyframes with equivalent segment durations or a timeline that preserves the same absolute offsets.",
"For generic-stagger loops, do not enter the same visible phrase twice; after gap, the next action is exit of the current phrase."
],
"easing": "Convert cubic-bezier(a,b,c,d) with CustomEase.create(...). Use \"none\" for linear. Convert steps(n,end) to GSAP steps(n).",
"completion": "Wrap tweens/timelines in a Promise resolved by onComplete, then assign final styles before replacing content.",
"cancellation": "kill active tweens/timelines and clear timers on teardown.",
"renderer_notes": [
"Create split units from target and animate only the animated units.",
"Delay each unit by stagger rank * scaled_stagger_ms.",
"Use materialized transform/filter/opacity keyframes from rendering_contract.transform_order.",
"Implement the complete playback loop from renderer.recipe.loop_algorithm: initial enter once, hold, exit current, micro-delay, replace next, enter next, gap, then exit that visible phrase.",
"Do not restart enter on a phrase that is already visible after gap; the next cycle starts with exit for the current phrase.",
"When awaiting animation completion promises, wait hold_ms/micro_delay_ms/gap_ms only; do not also sleep enter_total_ms or exit_total_ms.",
"Reject the code shape `while (...) { await enter(current); ... await enter(next); await sleep(gap); }`; it double-enters the visible phrase. Use renderer.recipe.canonical_loop_pseudocode instead."
]
}
},
"engine_notes": [
{
"engine": "WAAPI",
"notes": [
"Use Element.animate(keyframes, { delay, duration, easing, fill: \"forwards\" }).",
"For multi-keyframe effects, keep offsets on the keyframes and apply easing at the animation options level to match the site runtime."
]
},
{
"engine": "Motion",
"notes": [
"Use imperative animate(element, keyframes, options) when reproducing the site loops.",
"Convert CSS cubic-bezier strings to cubicBezier(x1, y1, x2, y2), convert steps(n, start|end) to steps(n, direction), and pass explicit times for keyframe offsets."
]
},
{
"engine": "GSAP",
"notes": [
"Register CustomEase for CSS cubic-bezier curves; map linear to ease \"none\" and steps(n, end) to GSAP steps(n).",
"For multi-keyframe effects, convert offset gaps into per-keyframe segment durations in seconds and keep one tween-level ease. Do not also pass a top-level duration when segment durations are present."
]
},
{
"engine": "CSS",
"notes": [
"CSS keyframes are viable for simple generic-stagger effects if every unit gets the same keyframes and computed delay.",
"CSS alone is usually not sufficient for the site loop unless JavaScript handles content replacement timing."
]
}
],
"reproduction_notes": [
"On the site this effect uses the generic stagger renderer. Apply the portable enter and exit frames per animated unit, preserving the declared target split and stagger ordering.",
"For site parity, scale duration and stagger timing by 0.72 and scale vertical travel by 0.58. These runtime transforms materially affect the perceived pace and distance.",
"For exact animation reproduction, follow `showcase.playback`, `showcase.timing`, `showcase.rendering_contract`, and `showcase.stage` over assumptions inferred from the portable contract alone. Presentation styling such as font size, font weight, color, padding, and card chrome is intentionally application-owned."
]
}
}
{
"id": "shared-axis-x",
"visibility": "hidden",
"portable_spec": {
"id": "shared-axis-x",
"display_name": "Shared Axis X",
"description": "Horizontal shared-axis transition for sibling destinations with continuity.",
"inspiration": "Google Material shared axis (X) transitions.",
"target": "whole",
"signature_easing": "cubic-bezier(0.2, 0, 0, 1)",
"enter": {
"duration_ms": 500,
"stagger_ms": 0,
"easing": "cubic-bezier(0.2, 0, 0, 1)",
"from": {
"opacity": 0,
"x_px": 24,
"scale": 0.98
},
"to": {
"opacity": 1,
"x_px": 0,
"scale": 1
}
},
"exit": {
"duration_ms": 360,
"stagger_ms": 0,
"easing": "cubic-bezier(0.4, 0, 1, 1)",
"from": {
"opacity": 1,
"x_px": 0,
"scale": 1
},
"to": {
"opacity": 0,
"x_px": -20,
"scale": 0.98
}
},
"swap": {
"mode": "crossfade",
"overlap_ms": 120,
"micro_delay_ms": 20
},
"usage_notes": "Use when moving between same-level views where horizontal direction conveys progress."
},
"showcase": null
}
{
"website-default": {
"speed_multiplier": 0.72,
"hold_ms": 550,
"gap_ms": 320,
"y_travel_multiplier": 0.58,
"initial_delay_ms": {
"mode": "random-range",
"min": 0,
"max": 400
}
}
}
Related skills
FAQ
Where are animation specs stored?
assets/specs/<id>.json for contracts and assets/effects/<id>.json for full reproduction.
Which renderers are supported?
WAAPI, Motion, Framer Motion, GSAP, CSS, Lottie, Rive, and similar adapters.
How many effects ship?
24 total specs; 20 are showcased on the project website.
Is Animate Text safe to install?
skills.sh reports 3 of 3 security scanners passed. Review the Security Audits panel on this page before installing in production.