Contenuto dal repository con titoli, esempi, codice, tabelle, link e immagini preservati.
Motion Web Design
Build the site. This skill takes a complete build prompt (generated by audience-site-brief Phase 10, grounded in visual-identity output) and produces a coded, running landing page with choreographed scroll animations, micro-interactions, and polished visual design.
Stack: Vite + GSAP + ScrollTrigger + Lenis. This is the industry standard used by Locomotive, Active Theory, and the studios that win Awwwards SOTD. It provides full animation control, excellent performance, and no framework overhead for single landing pages.
Distinction: ux-methodology-design determines what design rules apply. visual-identity defines the visual language. This skill implements both into working code.
When to Apply
- After audience-site-brief Phase 10 has produced a build prompt
- After visual-identity has produced a design-system.md
- When you need a coded, running page — not a mockup or spec
- When updating an existing site to add choreographed motion
Workflow
Copy this checklist:
Motion Web Design:
- [ ] 1. Read build prompt + design-system.md
- [ ] 2. Scaffold Vite project
- [ ] 3. Install GSAP + Lenis, configure plugins
- [ ] 4. Write design tokens (src/styles/tokens.css)
- [ ] 5. Typography system (src/styles/typography.css)
- [ ] 6. Base layout (containers, spacing, grid)
- [ ] 7. Build sections: HTML → CSS → animate (one at a time)
- [ ] 8. Hero entrance sequence (src/animations/hero.js)
- [ ] 9. Scroll choreography (src/animations/sections.js)
- [ ] 10. Micro-interactions (src/animations/micro.js)
- [ ] 11. Source images from Unsplash
- [ ] 12. Document video slots for Replicate
- [ ] 13. Mobile/performance pass
- [ ] 14. Quality gatePhase 1: Read the Build Prompt
Before writing any code, read:
- The build prompt from audience-site-brief (sections, copy, tone, preset)
design-system.mdfrom visual-identity (keywords, typography, tokens, motion vocabulary)- The selected preset from presets.md
Confirm you have:
- Section list with copy for each
- Primary persona and tone
- Preset name + any module overrides
- 4 motion vocabulary moves with timing
Phase 2: Project Scaffold
npm create vite@latest . -- --template vanilla
npm install gsap @studio-freight/lenisProject structure:
project/
├── index.html
├── package.json
├── vite.config.js
└── src/
├── main.js ← Lenis + GSAP init, import all modules
├── animations/
│ ├── hero.js ← Hero entrance timeline
│ ├── sections.js ← Per-section ScrollTrigger timelines
│ └── micro.js ← Buttons, cards, nav scroll behavior
└── styles/
├── tokens.css ← All CSS custom properties
├── base.css ← Reset, body, grain texture
├── typography.css ← Type scale
├── layout.css ← Grid, containers, spacing
└── components/
├── nav.css
├── hero.css
├── [section].css ← One file per section
└── footer.cssPhase 3: Lenis + GSAP Setup
`src/main.js` — complete boilerplate:
import Lenis from '@studio-freight/lenis';
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
// Import animation modules
import { initHero } from './animations/hero.js';
import { initSections } from './animations/sections.js';
import { initMicro } from './animations/micro.js';
// Register GSAP plugins
gsap.registerPlugin(ScrollTrigger);
// Lenis smooth scroll (integrates with GSAP ticker)
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smooth: true,
});
lenis.on('scroll', ScrollTrigger.update);
gsap.ticker.add((time) => { lenis.raf(time * 1000); });
gsap.ticker.lagSmoothing(0);
// Initialize animations after DOM ready
window.addEventListener('DOMContentLoaded', () => {
initHero();
initSections();
initMicro();
});Phase 4: Design Tokens
Write all CSS custom properties in src/styles/tokens.css before any component CSS. Derive from design-system.md.
/* src/styles/tokens.css */
:root {
/* Colors — from design-system.md */
--accent-bright: [hex];
--accent: [hex];
--accent-muted: [hex];
--accent-dim: [hex];
--dark: [hex];
--dark-surface: [hex];
--dark-card: [hex];
--dark-border: [hex];
--white: #FFFFFF;
--gray-light: [hex];
--gray: [hex];
/* Typography */
--font-display: '[Display Font]', system-ui, sans-serif;
--font-body: '[Body Font]', system-ui, sans-serif;
/* Spacing */
--space-xs: 0.5rem;
--space-sm: 1rem;
--space-md: 2rem;
--space-lg: 4rem;
--space-xl: 7rem;
--space-2xl: 10rem;
/* Layout */
--max-width: 1200px;
--gutter: 5%;
--radius: 8px;
--radius-lg: 16px;
/* Motion — from design-system.md motion vocabulary */
--ease-out: cubic-bezier(0.33, 1, 0.68, 1);
--ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
--spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--dur-fast: 150ms;
--dur-mid: 300ms;
--dur-slow: 600ms;
--dur-xslow: 1000ms;
--stagger-char: 30ms;
--stagger-card: 80ms;
--stagger-item: 150ms;
}Phase 5: Section Build Order
Build one section at a time: HTML → CSS → animation. Don't build all HTML first.
Order:
- Nav (sticky, backdrop blur on scroll)
- Hero (most complex — do after all others feel right so you know the right energy)
- Stats/proof bar
- Problem/context section
- How it works / mechanism
- Product detail / ingredients
- Social proof / testimonials
- Product / shop
- FAQ
- Final CTA
- Footer
- Hero last (animation timing depends on page feeling right overall)
Phase 6: Hero Entrance Sequence
The hero entrance is the most important animation on the page. It sets the energy for everything below.
`src/animations/hero.js`:
import { gsap } from 'gsap';
export function initHero() {
// All hero elements start invisible
gsap.set('.hero-content > *', { opacity: 0 });
gsap.set('.hero-visual', { opacity: 0 });
const tl = gsap.timeline({ delay: 0.15 });
tl
// Eyebrow label
.to('.hero-eyebrow', {
y: 0, opacity: 1, duration: 0.4, ease: 'power2.out',
from: { y: 16 }
})
// H1 lines — use REVEAL (clip-path) or LIFT depending on motion vocabulary
.from('.hero-h1 .line', {
y: 70, opacity: 0, duration: 0.7, stagger: 0.12, ease: 'power3.out'
}, '-=0.2')
// Subheadline
.from('.hero-sub', {
y: 24, opacity: 0, duration: 0.5, ease: 'power2.out'
}, '-=0.4')
// Primary CTA — PULSE entry (spring overshoot)
.from('.hero-cta-primary', {
scale: 0.88, opacity: 0, duration: 0.5, ease: 'back.out(1.7)'
}, '-=0.3')
// Secondary CTA + risk text
.from('.hero-cta-secondary, .hero-risk', {
y: 12, opacity: 0, duration: 0.35, stagger: 0.08
}, '-=0.2')
// Visual (product image/illustration) — delayed for drama
.from('.hero-visual', {
y: 60, opacity: 0, duration: 0.9, ease: 'power3.out'
}, '-=0.7');
}H1 line splitting (HTML pattern):
<h1 class="hero-h1">
<span class="line">Built for athletes</span>
<span class="line">who read</span>
<span class="line accent">the label.</span>
</h1>REVEAL variant (clip-path, for luxury/premium presets):
// Replace the H1 from() with:
.from('.hero-h1 .line', {
clipPath: 'inset(0 100% 0 0)',
duration: 0.8,
stagger: 0.15,
ease: 'power3.inOut'
})Phase 7: Section ScrollTrigger Timelines
Each section gets its own ScrollTrigger timeline. Use data-section attributes as targets.
`src/animations/sections.js`:
import { gsap } from 'gsap';
import { ScrollTrigger } from 'gsap/ScrollTrigger';
export function initSections() {
// Generic section reveal — call for each section
revealSection('[data-section="problem"]', { stagger: 0 });
revealSection('[data-section="how-it-works"]', { stagger: 0.1 });
revealSection('[data-section="ingredients"]', { stagger: 0.08, alternate: true });
revealSection('[data-section="reviews"]', { stagger: 0.12 });
revealSection('[data-section="shop"]', { stagger: 0.1 });
// COUNT animation for stats bar
initCounters();
// Parallax for hero visual (subtle depth on scroll)
gsap.to('.hero-visual', {
y: -60,
ease: 'none',
scrollTrigger: {
trigger: '.hero-section',
start: 'top top',
end: 'bottom top',
scrub: 1.5
}
});
// Final CTA background color shift
gsap.to('[data-section="final-cta"]', {
backgroundColor: 'var(--accent)',
ease: 'none',
scrollTrigger: {
trigger: '[data-section="final-cta"]',
start: 'top 80%',
end: 'top 20%',
scrub: 1
}
});
}
function revealSection(selector, options = {}) {
const { stagger = 0.08, y = 40, duration = 0.6, start = 'top 75%', alternate = false } = options;
const section = document.querySelector(selector);
if (!section) return;
const tl = gsap.timeline({
scrollTrigger: { trigger: section, start, toggleActions: 'play none none reverse' }
});
const eyebrow = section.querySelector('.eyebrow');
const headings = section.querySelectorAll('.section-h2 .line, .section-h2');
const body = section.querySelector('.section-lead');
const cards = section.querySelectorAll('.card');
if (eyebrow) {
tl.from(eyebrow, { y: 16, opacity: 0, duration: 0.4, ease: 'power2.out' });
}
if (headings.length) {
tl.from(headings, { y, opacity: 0, duration, stagger: 0.1, ease: 'power3.out' }, '-=0.2');
}
if (body) {
tl.from(body, { y: 24, opacity: 0, duration: 0.5, ease: 'power2.out' }, '-=0.35');
}
if (cards.length && !alternate) {
tl.from(cards, { y, opacity: 0, stagger, duration, ease: 'power2.out' }, '-=0.3');
}
if (cards.length && alternate) {
// Odd cards from left, even from right — creates depth
cards.forEach((card, i) => {
tl.from(card, {
x: i % 2 === 0 ? -30 : 30, y: 20, opacity: 0,
duration: 0.55, ease: 'power2.out'
}, `-=${i === 0 ? 0.3 : 0.4}`);
});
}
}
function initCounters() {
document.querySelectorAll('[data-count]').forEach(el => {
const target = parseFloat(el.dataset.count);
const suffix = el.dataset.suffix || '';
const prefix = el.dataset.prefix || '';
const decimals = el.dataset.decimals ? parseInt(el.dataset.decimals) : 0;
ScrollTrigger.create({
trigger: el,
start: 'top 85%',
once: true,
onEnter: () => {
const obj = { val: 0 };
gsap.to(obj, {
val: target,
duration: 1.6,
ease: 'power2.out',
onUpdate() {
el.textContent = prefix + obj.val.toFixed(decimals) + suffix;
}
});
}
});
});
}HTML pattern for COUNT:
<span class="stat-number" data-count="750" data-suffix="mg">750mg</span>Phase 8: Micro-Interactions
`src/animations/micro.js`:
import { gsap } from 'gsap';
export function initMicro() {
initButtons();
initCards();
initNav();
initFAQ();
}
function initButtons() {
document.querySelectorAll('.btn-primary').forEach(btn => {
btn.addEventListener('mouseenter', () => {
gsap.to(btn, { scale: 1.03, duration: 0.2, ease: 'back.out(1.7)' });
});
btn.addEventListener('mouseleave', () => {
gsap.to(btn, { scale: 1, duration: 0.2, ease: 'power2.out' });
});
btn.addEventListener('mousedown', () => {
gsap.to(btn, { scale: 0.97, duration: 0.08, ease: 'power3.in' });
});
btn.addEventListener('mouseup', () => {
gsap.to(btn, { scale: 1.02, duration: 0.15, ease: 'back.out(2)' });
});
});
}
function initCards() {
document.querySelectorAll('.card[data-hover]').forEach(card => {
card.addEventListener('mouseenter', () => {
gsap.to(card, { y: -8, duration: 0.3, ease: 'back.out(1.4)' });
});
card.addEventListener('mouseleave', () => {
gsap.to(card, { y: 0, duration: 0.3, ease: 'power2.out' });
});
});
}
function initNav() {
const nav = document.querySelector('.nav');
if (!nav) return;
// Nav transparency → solid on scroll
ScrollTrigger.create({
start: 'top+=60 top',
onEnter: () => nav.classList.add('nav--scrolled'),
onLeaveBack: () => nav.classList.remove('nav--scrolled')
});
}
function initFAQ() {
document.querySelectorAll('.faq-item').forEach(item => {
const btn = item.querySelector('.faq-q');
const answer = item.querySelector('.faq-a');
btn.addEventListener('click', () => {
const isOpen = item.classList.contains('open');
// Close all
document.querySelectorAll('.faq-item.open').forEach(open => {
open.classList.remove('open');
gsap.to(open.querySelector('.faq-a'), {
height: 0, duration: 0.3, ease: 'power2.inOut'
});
});
// Open clicked
if (!isOpen) {
item.classList.add('open');
gsap.set(answer, { height: 'auto' });
const fullHeight = answer.offsetHeight;
gsap.fromTo(answer,
{ height: 0 },
{ height: fullHeight, duration: 0.35, ease: 'power2.out' }
);
}
});
});
}Phase 9: Image Sourcing (Unsplash)
Use Unsplash for all photography. Free, high-quality, and covers all categories needed.
URL format:
https://images.unsplash.com/photo-{PHOTO_ID}?auto=format&fit=crop&w={WIDTH}&q=80auto=format— Serves WebP where supported (automatic)fit=crop— Maintains aspect ratiow={WIDTH}— Set to the actual rendered width (not 2x; the CDN handles resolution)q=80— Quality vs. file size balance
Search strategy by content type:
| Content | Unsplash search terms |
|---|---|
| Athlete running | athlete running marathon trail |
| Strength training | gym workout strength barbell |
| Cycling performance | cyclist road bike performance |
| Recovery/wellness | athlete recovery stretch yoga |
| Product lifestyle | sports drink water bottle gym |
| Team/social proof | fitness group training class |
Implementation:
<!-- Hero background athlete -->
<img
src="https://images.unsplash.com/photo-{ID}?auto=format&fit=crop&w=1400&q=80"
alt="Athlete in training"
class="hero-bg-image"
loading="eager"
fetchpriority="high"
/>
<!-- Testimonial avatar (fallback if no photo) -->
<img
src="https://images.unsplash.com/photo-{ID}?auto=format&fit=crop&w=80&q=80"
alt="Marcus R., marathon runner"
class="author-avatar-img"
loading="lazy"
/>Performance rules:
- Hero image:
loading="eager"+fetchpriority="high" - Below-fold images:
loading="lazy" - Always provide
widthandheightattributes to prevent CLS
Phase 10: Video Slots (Replicate)
Video generation requires a separate Replicate integration. Document video slots in the build so they can be filled when available.
Pattern — video slot with gradient placeholder:
<div class="video-slot" data-video-slot="hero-background">
<!-- Replicate video fills here when generated -->
<!-- Prompt for generation documented in VIDEO_PROMPTS.md -->
<div class="video-placeholder" aria-hidden="true"></div>
</div>Write `VIDEO_PROMPTS.md` in the project root:
# Video Generation Prompts (Replicate)
## Slot: hero-background
**Dimensions:** 1920×1080, 10-15s loop, no audio
**Model:** Use a video generation model on Replicate
**Prompt:**
Athletic runner in slow motion on a misty trail, early morning light,
cinematic 4K, shot on ARRI, lens flare, depth of field, performance energy.
Color grade: desaturated with green accent highlights. Loop-ready.
## Slot: product-lifestyle
**Dimensions:** 800×600, 6-8s loop
**Prompt:**
Sports drink can on a wet gym surface, condensation dripping in slow motion,
dark moody lighting with one key light, lime/electric green color cast.Phase 11: Mobile & Performance Pass
Mobile animation rules:
// Disable complex animations on small screens or reduced motion preference
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const isMobile = window.innerWidth < 768;
if (prefersReducedMotion || isMobile) {
// Replace all GSAP animations with instant opacity reveals
gsap.set('[data-animate]', { opacity: 1, y: 0, x: 0, clipPath: 'none' });
ScrollTrigger.disable();
}CSS simplified animations for mobile:
@media (prefers-reduced-motion: reduce) {
.fade-up { opacity: 1 !important; transform: none !important; }
}
@media (max-width: 767px) {
/* Simplify: keep fade-in, remove translate/rotate */
}Performance checklist:
- All images have explicit
widthandheight(prevents CLS) - Hero image uses
fetchpriority="high"(improves LCP) - GSAP only used for animations — no DOM querying in scroll handlers
- Lenis does not conflict with native scroll on iOS (test on real device)
npm run buildbundle: target <120KB JS total
Quality Gate
Before calling the build complete:
- [ ] Hero entrance: each element enters distinctly (not simultaneously)
- [ ] Scroll reveal: stagger is perceptible but not slow (test at normal scroll pace)
- [ ] COUNT animations fire correctly and don't lag on slow scroll
- [ ] FAQ accordion uses GSAP height animation (not max-height hack)
- [ ] Buttons have enter, leave, mousedown, mouseup states
- [ ] Nav changes state correctly at scroll threshold
- [ ] All Unsplash images have
alttext, explicit dimensions, lazy/eager correctly set - [ ] VIDEO_PROMPTS.md written for every video slot
- [ ] Mobile: check at 375px, 430px, 768px (portrait)
- [ ]
prefers-reduced-motionrespected - [ ] Lighthouse: LCP <2.5s, CLS <0.1, TBT <200ms
- [ ] Cross-browser: Chrome, Safari, Firefox (check
backdrop-filterin Firefox)
Anti-Patterns
- All sections animate the same way — every section should use a distinct variation of the vocabulary moves
- Animating on timer, not on scroll — Resn's rule: user-triggered motion feels personal, auto-play feels generic
- Animating CSS `height` — use GSAP with computed
offsetHeightinstead;max-heighttransitions are choppy - No mobile animation fallback — complex GSAP timelines on low-end Android will drop frames and break the experience
- Lenis + native scroll — don't add
overflow: hiddenon body and then also try to use native scroll anchor links; route throughlenis.scrollTo(target) - All images eagerly loaded — only the hero image should be eager; everything else lazy
- Forgetting VIDEO_PROMPTS.md — video slots without documented prompts can never be filled
Additional Resources
- presets.md — 8 visual archetypes with default token bundles
- archetypes.md — Persona → preset selection guide
- compose-prompt.md — How to assemble a self-contained build prompt
- modules/README.md — Module reference for overrides
- visual-identity/SKILL.md — The design system this skill receives
