--- name: motion-web-design description: >- Use after visual-identity is complete and a build prompt exists (from audience-site-brief). Scaffolds a Vite + GSAP + Lenis project, implements the design system, builds all page sections with choreographed animations, sources images from Unsplash, and documents video slots for Replicate. Targets Awwwards-quality execution. --- # 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](../ux-methodology-design/SKILL.md) determines *what* design rules apply. [visual-identity](../visual-identity/SKILL.md) 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 gate ``` --- ## Phase 1: Read the Build Prompt Before writing any code, read: 1. The build prompt from audience-site-brief (sections, copy, tone, preset) 2. `design-system.md` from visual-identity (keywords, typography, tokens, motion vocabulary) 3. The selected preset from [presets.md](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 ```bash npm create vite@latest . -- --template vanilla npm install gsap @studio-freight/lenis ``` **Project 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.css ``` --- ## Phase 3: Lenis + GSAP Setup **`src/main.js` — complete boilerplate:** ```js 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. ```css /* 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:** 1. Nav (sticky, backdrop blur on scroll) 2. Hero (most complex — do after all others feel right so you know the right energy) 3. Stats/proof bar 4. Problem/context section 5. How it works / mechanism 6. Product detail / ingredients 7. Social proof / testimonials 8. Product / shop 9. FAQ 10. Final CTA 11. Footer 12. 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`:** ```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):** ```html

Built for athletes who read the label.

``` **REVEAL variant (clip-path, for luxury/premium presets):** ```js // 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`:** ```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:** ```html 750mg ``` --- ## Phase 8: Micro-Interactions **`src/animations/micro.js`:** ```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=80 ``` - `auto=format` — Serves WebP where supported (automatic) - `fit=crop` — Maintains aspect ratio - `w={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:** ```html Athlete in training Marcus R., marathon runner ``` **Performance rules:** - Hero image: `loading="eager"` + `fetchpriority="high"` - Below-fold images: `loading="lazy"` - Always provide `width` and `height` attributes 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:** ```html
``` **Write `VIDEO_PROMPTS.md` in the project root:** ```markdown # 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:** ```js // 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:** ```css @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 `width` and `height` (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 build` bundle: 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 `alt` text, explicit dimensions, lazy/eager correctly set - [ ] VIDEO_PROMPTS.md written for every video slot - [ ] Mobile: check at 375px, 430px, 768px (portrait) - [ ] `prefers-reduced-motion` respected - [ ] Lighthouse: LCP <2.5s, CLS <0.1, TBT <200ms - [ ] Cross-browser: Chrome, Safari, Firefox (check `backdrop-filter` in 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 `offsetHeight` instead; `max-height` transitions 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: hidden` on body and then also try to use native scroll anchor links; route through `lenis.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](presets.md) — 8 visual archetypes with default token bundles - [archetypes.md](archetypes.md) — Persona → preset selection guide - [compose-prompt.md](compose-prompt.md) — How to assemble a self-contained build prompt - [modules/README.md](modules/README.md) — Module reference for overrides - [visual-identity/SKILL.md](../visual-identity/SKILL.md) — The design system this skill receives