motionharvest/agent-skills

motion-web-design

- Use after visual-identity is complete and a build prompt exists (from audience-site-brief).

ソースを見る
リポジトリの原文

見出し、例、コード、表、リンク、参照画像を含む原文を表示しています。

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 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

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
<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):

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
<span class="stat-number" data-count="750" data-suffix="mg">750mg</span>

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:

ContentUnsplash search terms
Athlete runningathlete running marathon trail
Strength traininggym workout strength barbell
Cycling performancecyclist road bike performance
Recovery/wellnessathlete recovery stretch yoga
Product lifestylesports drink water bottle gym
Team/social prooffitness group training class

Implementation:

html
<!-- 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 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
<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:

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