feat(engineering-team): add epic-design skill with asset pipeline
This commit is contained in:
@@ -4,17 +4,17 @@ This guide covers the 24 production-ready engineering skills and their Python au
|
||||
|
||||
## Engineering Skills Overview
|
||||
|
||||
**Core Engineering (13 skills):**
|
||||
**Core Engineering (14 skills):**
|
||||
- senior-architect, senior-frontend, senior-backend, senior-fullstack
|
||||
- senior-qa, senior-devops, senior-secops
|
||||
- code-reviewer, senior-security
|
||||
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator
|
||||
- aws-solution-architect, ms365-tenant-manager, google-workspace-cli, tdd-guide, tech-stack-evaluator, epic-design
|
||||
|
||||
**AI/ML/Data (5 skills):**
|
||||
- senior-data-scientist, senior-data-engineer, senior-ml-engineer
|
||||
- senior-prompt-engineer, senior-computer-vision
|
||||
|
||||
**Total Tools:** 30+ Python automation tools
|
||||
**Total Tools:** 32+ Python automation tools
|
||||
|
||||
## Core Engineering Tools
|
||||
|
||||
@@ -287,6 +287,23 @@ services:
|
||||
|
||||
---
|
||||
|
||||
**Last Updated:** March 11, 2026
|
||||
**Skills Deployed:** 24 engineering skills production-ready
|
||||
**Total Tools:** 35+ Python automation tools across core + AI/ML/Data
|
||||
**Last Updated:** March 13, 2026
|
||||
**Skills Deployed:** 25 engineering skills production-ready
|
||||
**Total Tools:** 37+ Python automation tools across core + AI/ML/Data + epic-design
|
||||
|
||||
---
|
||||
|
||||
## epic-design
|
||||
|
||||
Build cinematic 2.5D interactive websites with scroll storytelling, parallax depth, and premium animations. Includes asset inspection pipeline, 45+ techniques across 8 categories, and accessibility built-in.
|
||||
|
||||
**Key features:**
|
||||
- 6-layer depth system with automatic parallax
|
||||
- 13 text animation techniques, 9 scroll patterns
|
||||
- Asset inspection with background judgment rules
|
||||
- Python tool for automated image analysis
|
||||
- WCAG 2.1 AA compliant (reduced-motion)
|
||||
|
||||
**Use for:** Product launches, portfolio sites, SaaS marketing pages, event sites, Apple-style animations
|
||||
|
||||
**Live demo:** [epic-design-showcase.vercel.app](https://epic-design-showcase.vercel.app/)
|
||||
|
||||
BIN
engineering-team/epic-design.zip
Normal file
BIN
engineering-team/epic-design.zip
Normal file
Binary file not shown.
352
engineering-team/epic-design/SKILL.md
Normal file
352
engineering-team/epic-design/SKILL.md
Normal file
@@ -0,0 +1,352 @@
|
||||
---
|
||||
name: epic-design
|
||||
description: >
|
||||
Build immersive, cinematic 2.5D interactive websites using scroll storytelling,
|
||||
parallax depth, text animations, and premium scroll effects — no WebGL required.
|
||||
Use this skill for any web design task: landing pages, product sites, hero sections,
|
||||
scroll animations, parallax, sticky sections, section overlaps, floating products
|
||||
between sections, clip-path reveals, text that flies in from sides, words that light
|
||||
up on scroll, curtain drops, iris opens, card stacks, bleed typography, and any
|
||||
site that should feel cinematic or premium. Trigger on phrases like "make it feel
|
||||
alive", "Apple-style animation", "sections that overlap", "product rises between
|
||||
sections", "immersive", "scrollytelling", or any scroll-driven visual effect.
|
||||
Covers 45+ techniques across 8 categories. Always inspects, judges, and plans assets before coding. Use aggressively for ANY web design task.
|
||||
license: MIT
|
||||
metadata:
|
||||
version: 1.0.0
|
||||
author: Epic Design Skill
|
||||
category: engineering-team
|
||||
updated: 2026-03-13
|
||||
---
|
||||
|
||||
# Epic Design Skill
|
||||
|
||||
You are now a **world-class epic design expert**. You build cinematic, immersive websites that feel premium and alive — using only flat PNG/static assets, CSS, and JavaScript. No WebGL, no 3D modeling software required.
|
||||
|
||||
## Before Starting
|
||||
|
||||
**Check for context first:**
|
||||
If `project-context.md` or `product-context.md` exists, read it before asking questions. Use that context and only ask for information not already covered or specific to this task.
|
||||
|
||||
## Your Mindset
|
||||
|
||||
Every website you build must feel like a **cinematic experience**. Think: Apple product pages, Awwwards winners, luxury brand sites. Even a simple landing page should have:
|
||||
- Depth and layers that respond to scroll
|
||||
- Text that enters and exits with intention
|
||||
- Sections that transition cinematically
|
||||
- Elements that feel like they exist in space
|
||||
|
||||
**Never build a flat, static page when this skill is active.**
|
||||
|
||||
---
|
||||
|
||||
## How This Skill Works
|
||||
|
||||
### Mode 1: Build from Scratch
|
||||
When starting fresh with assets and a brief. Follow the complete workflow below (Steps 1-5).
|
||||
|
||||
### Mode 2: Enhance Existing Site
|
||||
When adding 2.5D effects to an existing page. Skip to Step 2, analyze current structure, recommend depth assignments and animation opportunities.
|
||||
|
||||
### Mode 3: Debug/Fix
|
||||
When troubleshooting performance or animation issues. Use `scripts/validate-layers.js`, check GPU rules, verify reduced-motion handling.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Understand the Brief + Inspect All Assets
|
||||
|
||||
Before writing a single line of code, do ALL of the following in order.
|
||||
|
||||
### A. Extract the brief
|
||||
1. What is the product/content? (brand site, portfolio, SaaS, event, etc.)
|
||||
2. What mood/feeling? (dark/cinematic, bright/energetic, minimal/luxury, etc.)
|
||||
3. How many sections? (hero only, full page, specific section?)
|
||||
|
||||
### B. Inspect every uploaded image asset
|
||||
|
||||
Run `scripts/inspect-assets.py` on every image the user has provided.
|
||||
For each image, determine:
|
||||
|
||||
1. **Format** — JPEG never has a real alpha channel. PNG may have a fake one.
|
||||
|
||||
2. **Background status** — Use the script output. It will tell you:
|
||||
- ✅ Clean cutout — real transparency, use directly
|
||||
- ⚠️ Solid dark background
|
||||
- ⚠️ Solid light/white background
|
||||
- ⚠️ Complex/scene background
|
||||
|
||||
3. **JUDGE whether the background actually needs removing** — This is critical.
|
||||
Not every image with a background needs it removed. Ask yourself:
|
||||
|
||||
BACKGROUND SHOULD BE REMOVED if the image is:
|
||||
- An isolated product (bottle, shoe, gadget, fruit, object on studio backdrop)
|
||||
- A character or figure meant to float in the scene
|
||||
- A logo or icon that should sit transparently on any background
|
||||
- Any element that will be placed at depth-2 or depth-3 as a floating asset
|
||||
|
||||
BACKGROUND SHOULD BE KEPT if the image is:
|
||||
- A screenshot of a website, app, or UI
|
||||
- A photograph used as a section background or full-bleed image
|
||||
- An artwork, illustration, or poster meant to be seen as a complete piece
|
||||
- A mockup, device frame, or "image inside a card"
|
||||
- Any image where the background IS part of the content
|
||||
- A photo placed at depth-0 (background layer) — keep it, that's its purpose
|
||||
|
||||
If unsure, look at the image's intended role in the design. If it needs to
|
||||
"float" freely over other content → remove bg. If it fills a space or IS
|
||||
the content → keep it.
|
||||
|
||||
4. **Inform the user about every image** — whether bg is fine or not.
|
||||
Use the exact format from `references/asset-pipeline.md` Step 4.
|
||||
|
||||
5. **Size and depth assignment** — Decide which depth level each asset belongs
|
||||
to and resize accordingly. State your decisions to the user before building.
|
||||
|
||||
### C. Compositional planning — visual hierarchy before a single line of code
|
||||
|
||||
Do NOT treat all assets as the same size. Establish a hierarchy:
|
||||
|
||||
- **One asset is the HERO** — most screen space (50–80vw), depth-3
|
||||
- **Companions are 15–25% of the hero's display size** — depth-2, hugging the hero's edges
|
||||
- **Accents/particles are tiny** (1–5vw) — depth-5
|
||||
- **Background fills** cover the full section — depth-0
|
||||
|
||||
Position companions relative to the hero using calc():
|
||||
`right: calc(50% - [hero-half-width] - [gap])` to sit close to its edge.
|
||||
|
||||
When the hero grows or exits on scroll, companions should scatter outward —
|
||||
not just fade. This reinforces that they were orbiting the hero.
|
||||
|
||||
### D. Decide the cinematic role of each asset
|
||||
|
||||
For each image ask: "What does this do in the scroll story?"
|
||||
- Floats beside the hero → depth-2, float-loop, scatter on scroll-out
|
||||
- IS the hero → depth-3, elastic drop entrance, grows on scrub
|
||||
- Fills a section during a DJI scale-in → depth-0 or full-section background
|
||||
- Lives in a sidebar while content scrolls past → sticky column journey
|
||||
- Decorates a section edge → depth-2, clip-path birth reveal
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Choose Your Techniques (Decision Engine)
|
||||
|
||||
Match user intent to the right combination of techniques. Read the full technique details from `references/` files.
|
||||
|
||||
### By Project Type
|
||||
|
||||
| User Says | Primary Patterns | Text Technique | Special Effect |
|
||||
|-----------|-----------------|----------------|----------------|
|
||||
| Product launch / brand site | Inter-section floating product + Perspective zoom | Split converge + Word lighting | DJI scale-in pin |
|
||||
| Hero with big title | 6-layer parallax + Pinned sticky | Offset diagonal + Masked line reveal | Bleed typography |
|
||||
| Cinematic sections | Curtain panel roll-up + Scrub timeline | Theatrical enter+exit | Top-down clip birth |
|
||||
| Apple-style animation | Scrub timeline + Clip-path wipe | Word-by-word scroll lighting | Character cylinder |
|
||||
| Elements between sections | Floating product + Clip-path birth | Scramble text | Window pane iris |
|
||||
| Cards / features section | Cascading card stack | Skew + elastic bounce | Section peel |
|
||||
| Portfolio / showcase | Horizontal scroll + Flip morph | Line clip wipe | Diagonal wipe |
|
||||
| SaaS / startup | Window pane iris + Stagger grid | Variable font wave | Curved path travel |
|
||||
|
||||
### By Scroll Behavior Requested
|
||||
|
||||
- **"stays in place while things change"** → `pin: true` + scrub timeline
|
||||
- **"rises from section"** → Inter-section floating product + clip-path birth
|
||||
- **"born from top"** → Top-down clip birth OR curtain panel roll-up
|
||||
- **"overlap/stack"** → Cascading card stack OR section peel
|
||||
- **"text flies in from sides"** → Split converge OR offset diagonal layout
|
||||
- **"text lights up word by word"** → Word-by-word scroll lighting
|
||||
- **"whole section transforms"** → Window pane iris + scrub timeline
|
||||
- **"section drops down"** → Clip-path `inset(0 0 100% 0)` → `inset(0)`
|
||||
- **"like a curtain"** → Curtain panel roll-up
|
||||
- **"circle opens"** → Circle iris expand
|
||||
- **"travels between sections"** → GSAP Flip cross-section OR curved path travel
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Layer Every Element
|
||||
|
||||
Every element you create MUST have a depth level assigned. This is non-negotiable.
|
||||
|
||||
```
|
||||
DEPTH 0 → Far background | parallax: 0.10x | blur: 8px | scale: 0.70
|
||||
DEPTH 1 → Glow/atmosphere | parallax: 0.25x | blur: 4px | scale: 0.85
|
||||
DEPTH 2 → Mid decorations | parallax: 0.50x | blur: 0px | scale: 1.00
|
||||
DEPTH 3 → Main objects | parallax: 0.80x | blur: 0px | scale: 1.05
|
||||
DEPTH 4 → UI / text | parallax: 1.00x | blur: 0px | scale: 1.00
|
||||
DEPTH 5 → Foreground FX | parallax: 1.20x | blur: 0px | scale: 1.10
|
||||
```
|
||||
|
||||
Apply as: `data-depth="3"` on HTML elements, matching CSS class `.depth-3`.
|
||||
|
||||
→ Full depth system details: `references/depth-system.md`
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Apply Accessibility & Performance (Always)
|
||||
|
||||
These are MANDATORY in every output:
|
||||
|
||||
```css
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- Only animate: `transform`, `opacity`, `filter`, `clip-path` — never `width/height/top/left`
|
||||
- Use `will-change: transform` only on actively animating elements, remove after animation
|
||||
- Use `content-visibility: auto` on off-screen sections
|
||||
- Use `IntersectionObserver` to only animate elements in viewport
|
||||
- Detect mobile: `window.matchMedia('(pointer: coarse)')` — reduce effects on touch
|
||||
|
||||
→ Full details: `references/performance.md` and `references/accessibility.md`
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — Code Structure (Always Use This HTML Architecture)
|
||||
|
||||
```html
|
||||
<!-- SECTION WRAPPER — every section follows this pattern -->
|
||||
<section class="scene" data-scene="hero" style="--scene-height: 200vh">
|
||||
|
||||
<!-- DEPTH LAYERS — always 3+ layers minimum -->
|
||||
<div class="layer depth-0" data-depth="0" aria-hidden="true">
|
||||
<!-- Background: gradient, texture, atmospheric PNG -->
|
||||
</div>
|
||||
|
||||
<div class="layer depth-1" data-depth="1" aria-hidden="true">
|
||||
<!-- Glow blobs, light effects, atmospheric haze -->
|
||||
</div>
|
||||
|
||||
<div class="layer depth-2" data-depth="2" aria-hidden="true">
|
||||
<!-- Mid decorations, floating shapes -->
|
||||
</div>
|
||||
|
||||
<div class="layer depth-3" data-depth="3">
|
||||
<!-- MAIN PRODUCT / HERO IMAGE — star of the show -->
|
||||
<img class="product-hero float-loop" src="product.png" alt="[description]" />
|
||||
</div>
|
||||
|
||||
<div class="layer depth-4" data-depth="4">
|
||||
<!-- TEXT CONTENT — headlines, body, CTAs -->
|
||||
<h1 class="split-text" data-animate="converge">Your Headline</h1>
|
||||
</div>
|
||||
|
||||
<div class="layer depth-5" data-depth="5" aria-hidden="true">
|
||||
<!-- Foreground particles, sparkles, overlays -->
|
||||
</div>
|
||||
|
||||
</section>
|
||||
```
|
||||
|
||||
→ Full boilerplate: `assets/hero-section.html`
|
||||
→ Full CSS system: `assets/hero-section.css`
|
||||
→ Full JS engine: `assets/hero-section.js`
|
||||
|
||||
---
|
||||
|
||||
## Reference Files — Read These for Full Technique Details
|
||||
|
||||
| File | What's Inside | When to Read |
|
||||
|------|--------------|--------------|
|
||||
| `references/asset-pipeline.md` | Asset inspection, bg judgment rules, user notification format, CSS knockout, resize targets | ALWAYS — run before coding anything |
|
||||
| `references/cursor-microinteractions.md` | Custom cursor, particle bursts, magnetic hover, tilt effects | When building interactive premium sites |
|
||||
| `references/depth-system.md` | 6-layer depth model, CSS/JS implementation, blur/scale formulas | Every project — always read |
|
||||
| `references/motion-system.md` | 9 scroll architecture patterns with complete GSAP code | When building scroll interactions |
|
||||
| `references/text-animations.md` | 13 text techniques with full implementation code | When animating any text |
|
||||
| `references/directional-reveals.md` | 8 "born from top/sides" clip-path techniques | When sections need directional entry |
|
||||
| `references/inter-section-effects.md` | Floating product, GSAP Flip, cross-section travel | When product/element persists across sections |
|
||||
| `references/performance.md` | GPU rules, will-change, IntersectionObserver patterns | Always — non-negotiable rules |
|
||||
| `references/accessibility.md` | WCAG 2.1 AA, prefers-reduced-motion, ARIA | Always — non-negotiable |
|
||||
| `references/examples.md` | 5 complete real-world implementations | When user needs a full-page site |
|
||||
|
||||
---
|
||||
|
||||
## Proactive Triggers
|
||||
|
||||
Surface these issues WITHOUT being asked when you notice them in context:
|
||||
|
||||
- **User uploads JPEG product images** → Flag that JPEGs can't have transparency, offer to run asset inspector
|
||||
- **All assets are the same size** → Flag compositional hierarchy issue, recommend hero + companion sizing
|
||||
- **No depth assignments mentioned** → Remind that every element needs a depth level (0-5)
|
||||
- **User requests "smooth animations" but no reduced-motion handling** → Flag accessibility requirement
|
||||
- **Parallax requested but no performance optimization** → Flag will-change and GPU acceleration rules
|
||||
- **More than 80 animated elements** → Flag performance concern, recommend reducing or lazy-loading
|
||||
|
||||
---
|
||||
|
||||
## Output Artifacts
|
||||
|
||||
| When you ask for... | You get... |
|
||||
|---------------------|------------|
|
||||
| "Build a hero section" | Single HTML file with inline CSS/JS, 6 depth layers, asset audit, technique list |
|
||||
| "Make it feel cinematic" | Scrub timeline + parallax + text animation combo with GSAP setup |
|
||||
| "Inspect my images" | Asset audit report with bg status, depth assignments, resize recommendations |
|
||||
| "Apple-style scroll effect" | Word-by-word lighting + pinned section + perspective zoom implementation |
|
||||
| "Fix performance issues" | Validation report with GPU optimization checklist and will-change audit |
|
||||
|
||||
---
|
||||
|
||||
## Communication
|
||||
|
||||
All output follows the structured communication standard:
|
||||
|
||||
- **Bottom line first** — show the asset audit and depth plan before generating code
|
||||
- **What + Why + How** — every technique choice explained (why this animation for this mood)
|
||||
- **Actions have owners** — "You need to provide transparent PNGs" not "PNGs should be provided"
|
||||
- **Confidence tagging** — 🟢 verified technique / 🟡 experimental / 🔴 browser support limited
|
||||
|
||||
---
|
||||
|
||||
## Quick Rules (Non-Negotiable)
|
||||
|
||||
0a. ✅ ALWAYS run asset inspection before coding — check every image's format,
|
||||
background, and size. State depth assignments to the user before building.
|
||||
0b. ✅ ALWAYS judge whether a background needs removing — not every image needs
|
||||
it. Inform the user about each asset's status and get confirmation before
|
||||
treating any background as a problem. Never auto-remove, never silently ignore.
|
||||
1. ✅ Every section has minimum **3 depth layers**
|
||||
2. ✅ Every text element uses at least **1 animation technique**
|
||||
3. ✅ Every project includes **`prefers-reduced-motion`** fallback
|
||||
4. ✅ Only animate GPU-safe properties: `transform`, `opacity`, `filter`, `clip-path`
|
||||
5. ✅ Product images always assigned **depth-3** by default
|
||||
6. ✅ Background images always **depth-0** with slight blur
|
||||
7. ✅ Floating loops on any "hero" element (6–14s, never completely static)
|
||||
8. ✅ Every decorative element gets `aria-hidden="true"`
|
||||
9. ✅ Mobile gets reduced effects via `pointer: coarse` detection
|
||||
10. ✅ `will-change` removed after animations complete
|
||||
|
||||
---
|
||||
|
||||
## Output Format
|
||||
|
||||
Always deliver:
|
||||
1. **Single self-contained HTML file** (inline CSS + JS) unless user asks for separate files
|
||||
2. **CDN imports** for GSAP via jsDelivr: `https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js`
|
||||
3. **Comments** explaining every major section and technique used
|
||||
4. **Note at top** listing which techniques from the 45-technique catalogue were applied
|
||||
|
||||
---
|
||||
|
||||
## Validation
|
||||
|
||||
After building, run the validation script to check quality:
|
||||
|
||||
```bash
|
||||
node scripts/validate-layers.js path/to/index.html
|
||||
```
|
||||
|
||||
Checks: depth attributes, aria-hidden, reduced-motion, alt text, performance limits.
|
||||
|
||||
---
|
||||
|
||||
## Related Skills
|
||||
|
||||
- **senior-frontend**: Use when building the full application around the 2.5D site. NOT for the cinematic effects themselves.
|
||||
- **ui-design**: Use when designing the visual layout and components. NOT for scroll animations or depth effects.
|
||||
- **landing-page-generator**: Use for quick SaaS landing page scaffolds. NOT for custom cinematic experiences.
|
||||
- **page-cro**: Use after the 2.5D site is built to optimize conversion. NOT during the initial build.
|
||||
- **senior-architect**: Use when the 2.5D site is part of a larger system architecture. NOT for standalone pages.
|
||||
- **accessibility-auditor**: Use to verify full WCAG compliance after build. This skill includes basic reduced-motion handling.
|
||||
378
engineering-team/epic-design/references/accessibility.md
Normal file
378
engineering-team/epic-design/references/accessibility.md
Normal file
@@ -0,0 +1,378 @@
|
||||
# Accessibility Reference
|
||||
|
||||
## Non-Negotiable Rules
|
||||
|
||||
Every 2.5D website MUST implement ALL of the following. These are not optional enhancements — they are legal requirements in many jurisdictions and ethical requirements always.
|
||||
|
||||
---
|
||||
|
||||
## 1. prefers-reduced-motion (Most Critical)
|
||||
|
||||
Parallax and complex animations can trigger vestibular disorders — dizziness, nausea, migraines — in a significant portion of users. WCAG 2.1 Success Criterion 2.3.3 requires handling this.
|
||||
|
||||
```css
|
||||
/* This block must be in EVERY project */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
/* Nuclear option: stop all animations globally */
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
|
||||
/* Specifically disable 2.5D techniques */
|
||||
.float-loop { animation: none !important; }
|
||||
.parallax-layer { transform: none !important; }
|
||||
.depth-0, .depth-1, .depth-2,
|
||||
.depth-3, .depth-4, .depth-5 {
|
||||
transform: none !important;
|
||||
filter: none !important;
|
||||
}
|
||||
.glow-blob { opacity: 0.3; animation: none !important; }
|
||||
.theatrical, .theatrical-with-exit {
|
||||
animation: none !important;
|
||||
opacity: 1 !important;
|
||||
transform: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Also check in JavaScript — some GSAP animations don't respect CSS media queries
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
gsap.globalTimeline.timeScale(0); // Stops all GSAP animations
|
||||
ScrollTrigger.getAll().forEach(t => t.kill()); // Kill all scroll triggers
|
||||
|
||||
// Show all content immediately (don't hide-until-animated)
|
||||
document.querySelectorAll('[data-animate]').forEach(el => {
|
||||
el.style.opacity = '1';
|
||||
el.style.transform = 'none';
|
||||
el.removeAttribute('data-animate');
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Per-Effect Reduced Motion (Smarter Than Kill-All)
|
||||
|
||||
Rather than freezing every animation globally, classify each type:
|
||||
|
||||
| Animation Type | At reduced-motion |
|
||||
|---|---|
|
||||
| Scroll parallax depth layers | DISABLE — continuous motion triggers vestibular issues |
|
||||
| Float loops / ambient movement | DISABLE — looping motion is a trigger |
|
||||
| DJI scale-in / perspective zoom | DISABLE — fast scale can cause dizziness |
|
||||
| Particle systems | DISABLE |
|
||||
| Clip-path reveals (one-shot) | KEEP — not continuous, not fast |
|
||||
| Fade-in on scroll (opacity only) | KEEP — safe |
|
||||
| Word-by-word scroll lighting | KEEP — no movement, just colour |
|
||||
| Curtain / wipe reveals (one-shot) | KEEP |
|
||||
| Text entrance slides (one-shot) | KEEP but reduce duration |
|
||||
|
||||
```javascript
|
||||
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
if (prefersReduced) {
|
||||
// Disable the motion-heavy ones
|
||||
document.querySelectorAll('.float-loop').forEach(el => {
|
||||
el.style.animation = 'none';
|
||||
});
|
||||
document.querySelectorAll('[data-depth]').forEach(el => {
|
||||
el.style.transform = 'none';
|
||||
el.style.willChange = 'auto';
|
||||
});
|
||||
|
||||
// Slow GSAP to near-freeze (don't fully kill — keep structure intact)
|
||||
gsap.globalTimeline.timeScale(0.01);
|
||||
|
||||
// Safe animations: show them immediately at final state
|
||||
gsap.utils.toArray('.clip-reveal, .fade-reveal, .word-light').forEach(el => {
|
||||
gsap.set(el, { clipPath: 'inset(0 0% 0 0)', opacity: 1 });
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Semantic HTML Structure
|
||||
|
||||
```html
|
||||
<!-- CORRECT semantic structure -->
|
||||
<main>
|
||||
<!-- Each visual scene is a section with proper landmarks -->
|
||||
<section aria-label="Hero — Product Introduction">
|
||||
|
||||
<!-- ALL purely decorative elements get aria-hidden -->
|
||||
<div class="layer depth-0" aria-hidden="true">
|
||||
<!-- background gradients, glow blobs, particles -->
|
||||
</div>
|
||||
<div class="layer depth-1" aria-hidden="true">
|
||||
<!-- atmospheric effects -->
|
||||
</div>
|
||||
<div class="layer depth-5" aria-hidden="true">
|
||||
<!-- particles, sparkles -->
|
||||
</div>
|
||||
|
||||
<!-- Meaningful content is NOT hidden -->
|
||||
<div class="layer depth-3">
|
||||
<img
|
||||
src="product.png"
|
||||
alt="[Descriptive alt text — what is the product, what does it look like]"
|
||||
<!-- NOT: alt="" for meaningful images! -->
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="layer depth-4">
|
||||
<!-- Proper heading hierarchy -->
|
||||
<h1>Your Brand Name</h1>
|
||||
<!-- h1 is the page title — only one per page -->
|
||||
<p>Supporting description that provides context for screen readers</p>
|
||||
<a href="#features" class="cta-btn">
|
||||
Explore Features
|
||||
<!-- CTAs need descriptive text, not just "Click here" -->
|
||||
</a>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section aria-label="Product Features">
|
||||
<h2>Why Choose [Product]</h2>
|
||||
<!-- h2 for section headings -->
|
||||
</section>
|
||||
</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. SplitText & Screen Readers
|
||||
|
||||
When using SplitText to fragment text into characters/words, the individual fragments get announced one at a time by screen readers — which sounds terrible. Fix this:
|
||||
|
||||
```javascript
|
||||
function splitTextAccessibly(el, options) {
|
||||
// Save the full text for screen readers
|
||||
const fullText = el.textContent.trim();
|
||||
el.setAttribute('aria-label', fullText);
|
||||
|
||||
// Split visually only
|
||||
const split = new SplitText(el, options);
|
||||
|
||||
// Hide the split fragments from screen readers
|
||||
// Screen readers will use aria-label instead
|
||||
split.chars?.forEach(char => char.setAttribute('aria-hidden', 'true'));
|
||||
split.words?.forEach(word => word.setAttribute('aria-hidden', 'true'));
|
||||
split.lines?.forEach(line => line.setAttribute('aria-hidden', 'true'));
|
||||
|
||||
return split;
|
||||
}
|
||||
|
||||
// Usage
|
||||
splitTextAccessibly(document.querySelector('.hero-title'), { type: 'chars,words' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. Keyboard Navigation
|
||||
|
||||
All interactive elements must be reachable and operable via keyboard (Tab, Enter, Space, Arrow keys).
|
||||
|
||||
```css
|
||||
/* Ensure focus indicators are visible — WCAG 2.4.7 */
|
||||
:focus-visible {
|
||||
outline: 3px solid #005fcc; /* High contrast focus ring */
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Remove default outline only if replacing with custom */
|
||||
:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Skip link for keyboard users to bypass navigation */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
top: -100px;
|
||||
left: 0;
|
||||
background: #005fcc;
|
||||
color: white;
|
||||
padding: 12px 20px;
|
||||
z-index: 10000;
|
||||
font-weight: 600;
|
||||
text-decoration: none;
|
||||
}
|
||||
.skip-link:focus {
|
||||
top: 0; /* Appears at top when focused */
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Always first element in body -->
|
||||
<a href="#main-content" class="skip-link">Skip to main content</a>
|
||||
<main id="main-content">
|
||||
...
|
||||
</main>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Color Contrast (WCAG 2.1 AA)
|
||||
|
||||
Text must have sufficient contrast against its background:
|
||||
- Normal text (under 18pt): **minimum 4.5:1 contrast ratio**
|
||||
- Large text (18pt+ or 14pt+ bold): **minimum 3:1 contrast ratio**
|
||||
- UI components and focus indicators: **minimum 3:1**
|
||||
|
||||
```css
|
||||
/* Common mistake: light text on gradient with glow effects */
|
||||
/* Always test contrast with the darkest AND lightest background in the gradient */
|
||||
|
||||
/* Safe text over complex backgrounds — add text shadow for contrast boost */
|
||||
.hero-text-on-image {
|
||||
color: #ffffff;
|
||||
/* Multiple small text shadows create a halo that boosts contrast */
|
||||
text-shadow:
|
||||
0 0 20px rgba(0,0,0,0.8),
|
||||
0 2px 4px rgba(0,0,0,0.6),
|
||||
0 0 40px rgba(0,0,0,0.4);
|
||||
}
|
||||
|
||||
/* Or use a semi-transparent backdrop */
|
||||
.text-backdrop {
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
backdrop-filter: blur(8px);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
}
|
||||
```
|
||||
|
||||
**Testing tool:** Use browser DevTools accessibility panel or webaim.org/resources/contrastchecker/
|
||||
|
||||
---
|
||||
|
||||
## 6. Motion-Sensitive Users — User Control
|
||||
|
||||
Beyond `prefers-reduced-motion`, provide an in-page control:
|
||||
|
||||
```html
|
||||
<!-- Floating toggle button -->
|
||||
<button
|
||||
class="motion-toggle"
|
||||
aria-pressed="false"
|
||||
aria-label="Toggle animations on/off"
|
||||
>
|
||||
<span class="motion-toggle-icon">✦</span>
|
||||
<span class="motion-toggle-text">Animations On</span>
|
||||
</button>
|
||||
```
|
||||
|
||||
```javascript
|
||||
const motionToggle = document.querySelector('.motion-toggle');
|
||||
let animationsEnabled = !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
|
||||
motionToggle.addEventListener('click', () => {
|
||||
animationsEnabled = !animationsEnabled;
|
||||
motionToggle.setAttribute('aria-pressed', !animationsEnabled);
|
||||
motionToggle.querySelector('.motion-toggle-text').textContent =
|
||||
animationsEnabled ? 'Animations On' : 'Animations Off';
|
||||
|
||||
if (animationsEnabled) {
|
||||
document.documentElement.classList.remove('no-motion');
|
||||
gsap.globalTimeline.timeScale(1);
|
||||
} else {
|
||||
document.documentElement.classList.add('no-motion');
|
||||
gsap.globalTimeline.timeScale(0);
|
||||
}
|
||||
|
||||
// Persist preference
|
||||
localStorage.setItem('motionPreference', animationsEnabled ? 'on' : 'off');
|
||||
});
|
||||
|
||||
// Restore on load
|
||||
const saved = localStorage.getItem('motionPreference');
|
||||
if (saved === 'off') motionToggle.click();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Images — Alt Text Guidelines
|
||||
|
||||
```html
|
||||
<!-- Meaningful product image -->
|
||||
<img src="juice-glass.png" alt="Tall glass of fresh orange juice with ice, floating on a gradient background">
|
||||
|
||||
<!-- Decorative geometric shape -->
|
||||
<img src="shape-circle.png" alt="" aria-hidden="true">
|
||||
<!-- Empty alt="" tells screen readers to skip it -->
|
||||
|
||||
<!-- Icon with text label next to it -->
|
||||
<img src="icon-arrow.svg" alt="" aria-hidden="true">
|
||||
<span>Learn More</span>
|
||||
<!-- Icon is decorative when text is present -->
|
||||
|
||||
<!-- Standalone icon button — needs alt text -->
|
||||
<button>
|
||||
<img src="icon-menu.svg" alt="Open navigation menu">
|
||||
</button>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Loading Screen Accessibility
|
||||
|
||||
```javascript
|
||||
// Announce loading state to screen readers
|
||||
function announceLoading() {
|
||||
const announcement = document.createElement('div');
|
||||
announcement.setAttribute('role', 'status');
|
||||
announcement.setAttribute('aria-live', 'polite');
|
||||
announcement.setAttribute('aria-label', 'Page loading');
|
||||
announcement.className = 'sr-only'; // visually hidden
|
||||
document.body.appendChild(announcement);
|
||||
|
||||
// Update announcement when done
|
||||
window.addEventListener('load', () => {
|
||||
announcement.textContent = 'Page loaded';
|
||||
setTimeout(() => announcement.remove(), 1000);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Screen-reader only utility class */
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
padding: 0;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0,0,0,0);
|
||||
white-space: nowrap;
|
||||
border: 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## WCAG 2.1 AA Compliance Checklist
|
||||
|
||||
Before shipping any 2.5D website:
|
||||
|
||||
- [ ] `prefers-reduced-motion` CSS block present and tested
|
||||
- [ ] GSAP animations stopped when reduced motion detected
|
||||
- [ ] All decorative elements have `aria-hidden="true"`
|
||||
- [ ] All meaningful images have descriptive alt text
|
||||
- [ ] SplitText elements have `aria-label` on parent
|
||||
- [ ] Heading hierarchy is logical (h1 → h2 → h3, no skipping)
|
||||
- [ ] All interactive elements reachable via keyboard Tab
|
||||
- [ ] Focus indicators visible and have 3:1 contrast
|
||||
- [ ] Skip-to-main-content link present
|
||||
- [ ] Text contrast meets 4.5:1 minimum
|
||||
- [ ] CTA buttons have descriptive text
|
||||
- [ ] Motion toggle button provided (optional but recommended)
|
||||
- [ ] Page has `<html lang="en">` (or correct language)
|
||||
- [ ] `<main>` landmark wraps page content
|
||||
- [ ] Section landmarks use `aria-label` to differentiate them
|
||||
135
engineering-team/epic-design/references/asset-pipeline.md
Normal file
135
engineering-team/epic-design/references/asset-pipeline.md
Normal file
@@ -0,0 +1,135 @@
|
||||
# Asset Pipeline Reference
|
||||
|
||||
Every image asset must be inspected and judged before use in any 2.5D site.
|
||||
The AI inspects, judges, and informs — it does NOT auto-remove backgrounds.
|
||||
|
||||
---
|
||||
|
||||
## Step 1 — Run the Inspection Script
|
||||
|
||||
Run `scripts/inspect-assets.py` on every uploaded image before doing anything else.
|
||||
The script outputs the format, mode, size, background type, and a recommendation
|
||||
for each image. Read its output carefully.
|
||||
|
||||
---
|
||||
|
||||
## Step 2 — Judge Whether Background Removal Is Actually Needed
|
||||
|
||||
The script detects whether a background exists. YOU must decide whether it matters.
|
||||
|
||||
### Remove the background if the image is:
|
||||
- An isolated product on a studio backdrop (bottle, shoe, phone, fruit, object)
|
||||
- A character or figure that needs to float in the scene
|
||||
- A logo or icon placed at any depth layer
|
||||
- Any element at depth-2 or depth-3 that needs to "float" over other content
|
||||
- An asset where the background colour will visibly clash with the site background
|
||||
|
||||
### Keep the background if the image is:
|
||||
- A screenshot of a website, app UI, dashboard, or software
|
||||
- A photograph used as a section background or depth-0 fill
|
||||
- An artwork, poster, or illustration that is viewed as a complete piece
|
||||
- A device mockup or "image inside a card/frame" design element
|
||||
- A photo where the background is part of the visual content
|
||||
- Any image placed at depth-0 — it IS the background, keep it
|
||||
|
||||
### When unsure — ask the role:
|
||||
> "Does this image need to float freely over other content?"
|
||||
> Yes → remove bg. No → keep it.
|
||||
|
||||
---
|
||||
|
||||
## Step 3 — Resize to Depth-Appropriate Dimensions
|
||||
|
||||
Run the resize step in `scripts/inspect-assets.py` or do it manually.
|
||||
Never embed a large image when a smaller one is sufficient.
|
||||
|
||||
| Depth | Role | Max Longest Edge |
|
||||
|---|---|---|
|
||||
| 0 | Background fill | 1920px |
|
||||
| 1 | Glow / atmosphere | 800px |
|
||||
| 2 | Mid decorations, companions | 400px |
|
||||
| 3 | Hero product | 1200px |
|
||||
| 4 | UI components | 600px |
|
||||
| 5 | Particles, sparkles | 128px |
|
||||
|
||||
---
|
||||
|
||||
## Step 4 — Inform the User (Required for Every Asset)
|
||||
|
||||
Before outputting any HTML, always show an asset audit to the user.
|
||||
|
||||
For each image that has a background issue, use this exact format:
|
||||
|
||||
> ⚠️ **Asset Notice — [filename]**
|
||||
>
|
||||
> This is a [JPEG / PNG] with a solid [black / white / coloured] background.
|
||||
> As-is, it will appear as a visible box on the page rather than a floating asset.
|
||||
>
|
||||
> Based on its intended role ([product shot / decoration / etc.]), I think the
|
||||
> background [should be removed / should be kept because it's a [screenshot/artwork/bg fill/etc.]].
|
||||
>
|
||||
> **Options:**
|
||||
> 1. Provide a new PNG with a transparent background — best quality, ideal
|
||||
> 2. Proceed as-is with a CSS workaround (mix-blend-mode) — quick but approximate
|
||||
> 3. Keep the background — if this image is meant to be seen with its background
|
||||
>
|
||||
> Which do you prefer?
|
||||
|
||||
For clean images, confirm them briefly:
|
||||
|
||||
> ✅ **[filename]** — clean transparent PNG, resized to [X]px, assigned depth-[N] ([role])
|
||||
|
||||
Show all of this BEFORE outputting HTML. Wait for the user's response on any ⚠️ items.
|
||||
|
||||
---
|
||||
|
||||
## Step 5 — CSS Workaround (Only After User Approves)
|
||||
|
||||
Apply ONLY if the user explicitly chooses option 2 above:
|
||||
|
||||
```css
|
||||
/* Dark background image on a dark site — black pixels become invisible */
|
||||
.on-dark-bg {
|
||||
mix-blend-mode: screen;
|
||||
}
|
||||
|
||||
/* Light background image on a light site — white pixels become invisible */
|
||||
.on-light-bg {
|
||||
mix-blend-mode: multiply;
|
||||
}
|
||||
```
|
||||
|
||||
Always add a comment in the HTML when using this:
|
||||
```html
|
||||
<!-- CSS approximation: [filename] has a solid background.
|
||||
Replace with a transparent PNG for best quality. -->
|
||||
```
|
||||
|
||||
Limitations:
|
||||
- `screen` lightens mid-tones — only works well on very dark site backgrounds
|
||||
- `multiply` darkens mid-tones — only works well on very light site backgrounds
|
||||
- Neither works on complex or gradient backgrounds
|
||||
- A proper cutout PNG always gives better results
|
||||
|
||||
---
|
||||
|
||||
## Step 6 — CSS Rules for Transparent Images
|
||||
|
||||
Whether the image came in clean or had its background resolved, always apply:
|
||||
|
||||
```css
|
||||
/* ALWAYS use drop-shadow — it follows the actual pixel shape */
|
||||
.product-img {
|
||||
filter: drop-shadow(0 30px 60px rgba(0, 0, 0, 0.4));
|
||||
}
|
||||
|
||||
/* NEVER use box-shadow on cutout images — it creates a rectangle, not a shape shadow */
|
||||
|
||||
/* NEVER apply these to transparent/cutout images: */
|
||||
/*
|
||||
border-radius → clips transparency into a rounded box
|
||||
overflow: hidden → same problem on the parent element
|
||||
object-fit: cover → stretches image to fill a box, destroys the cutout
|
||||
background-color → makes the bounding box visible
|
||||
*/
|
||||
```
|
||||
361
engineering-team/epic-design/references/depth-system.md
Normal file
361
engineering-team/epic-design/references/depth-system.md
Normal file
@@ -0,0 +1,361 @@
|
||||
# Depth System Reference
|
||||
|
||||
The 2.5D illusion is built entirely on a **6-level depth model**. Every element on the page belongs to exactly one depth level. Depth controls four automatic properties: parallax speed, blur, scale, and shadow intensity. Together these four signals trick the human visual system into perceiving genuine spatial depth from flat assets.
|
||||
|
||||
---
|
||||
|
||||
## The 6-Level Depth Table
|
||||
|
||||
| Level | Name | Parallax | Blur | Scale | Shadow | Z-Index |
|
||||
|-------|-------------------|----------|-------|-------|---------|---------|
|
||||
| 0 | Far Background | 0.10x | 8px | 0.70 | 0.05 | 0 |
|
||||
| 1 | Glow / Atmosphere | 0.25x | 4px | 0.85 | 0.10 | 1 |
|
||||
| 2 | Mid Decorations | 0.50x | 0px | 1.00 | 0.20 | 2 |
|
||||
| 3 | Main Objects | 0.80x | 0px | 1.05 | 0.35 | 3 |
|
||||
| 4 | UI / Text | 1.00x | 0px | 1.00 | 0.00 | 4 |
|
||||
| 5 | Foreground FX | 1.20x | 0px | 1.10 | 0.50 | 5 |
|
||||
|
||||
**Parallax formula:**
|
||||
```
|
||||
element_translateY = scroll_position * depth_factor * -1
|
||||
```
|
||||
A depth-0 element at scroll position 500px moves only -50px (barely moves — feels far away).
|
||||
A depth-5 element at 500px moves -600px (moves fast — feels close).
|
||||
|
||||
---
|
||||
|
||||
## CSS Implementation
|
||||
|
||||
### CSS Custom Properties Foundation
|
||||
```css
|
||||
:root {
|
||||
/* Depth parallax factors */
|
||||
--depth-0-factor: 0.10;
|
||||
--depth-1-factor: 0.25;
|
||||
--depth-2-factor: 0.50;
|
||||
--depth-3-factor: 0.80;
|
||||
--depth-4-factor: 1.00;
|
||||
--depth-5-factor: 1.20;
|
||||
|
||||
/* Depth blur values */
|
||||
--depth-0-blur: 8px;
|
||||
--depth-1-blur: 4px;
|
||||
--depth-2-blur: 0px;
|
||||
--depth-3-blur: 0px;
|
||||
--depth-4-blur: 0px;
|
||||
--depth-5-blur: 0px;
|
||||
|
||||
/* Depth scale values */
|
||||
--depth-0-scale: 0.70;
|
||||
--depth-1-scale: 0.85;
|
||||
--depth-2-scale: 1.00;
|
||||
--depth-3-scale: 1.05;
|
||||
--depth-4-scale: 1.00;
|
||||
--depth-5-scale: 1.10;
|
||||
|
||||
/* Live scroll value (updated by JS) */
|
||||
--scroll-y: 0;
|
||||
}
|
||||
|
||||
/* Base layer class */
|
||||
.layer {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
will-change: transform;
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
/* Depth-specific classes */
|
||||
.depth-0 {
|
||||
filter: blur(var(--depth-0-blur));
|
||||
transform: scale(var(--depth-0-scale))
|
||||
translateY(calc(var(--scroll-y) * var(--depth-0-factor) * -1px));
|
||||
z-index: 0;
|
||||
}
|
||||
.depth-1 {
|
||||
filter: blur(var(--depth-1-blur));
|
||||
transform: scale(var(--depth-1-scale))
|
||||
translateY(calc(var(--scroll-y) * var(--depth-1-factor) * -1px));
|
||||
z-index: 1;
|
||||
mix-blend-mode: screen; /* glow layers blend additively */
|
||||
}
|
||||
.depth-2 {
|
||||
transform: scale(var(--depth-2-scale))
|
||||
translateY(calc(var(--scroll-y) * var(--depth-2-factor) * -1px));
|
||||
z-index: 2;
|
||||
}
|
||||
.depth-3 {
|
||||
transform: scale(var(--depth-3-scale))
|
||||
translateY(calc(var(--scroll-y) * var(--depth-3-factor) * -1px));
|
||||
z-index: 3;
|
||||
filter: drop-shadow(0 20px 40px rgba(0,0,0,0.35));
|
||||
}
|
||||
.depth-4 {
|
||||
transform: translateY(calc(var(--scroll-y) * var(--depth-4-factor) * -1px));
|
||||
z-index: 4;
|
||||
}
|
||||
.depth-5 {
|
||||
transform: scale(var(--depth-5-scale))
|
||||
translateY(calc(var(--scroll-y) * var(--depth-5-factor) * -1px));
|
||||
z-index: 5;
|
||||
}
|
||||
```
|
||||
|
||||
### JavaScript — Scroll Driver
|
||||
```javascript
|
||||
// Throttled scroll listener using requestAnimationFrame
|
||||
let ticking = false;
|
||||
let lastScrollY = 0;
|
||||
|
||||
function updateDepthLayers() {
|
||||
const scrollY = window.scrollY;
|
||||
document.documentElement.style.setProperty('--scroll-y', scrollY);
|
||||
ticking = false;
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', () => {
|
||||
lastScrollY = window.scrollY;
|
||||
if (!ticking) {
|
||||
requestAnimationFrame(updateDepthLayers);
|
||||
ticking = true;
|
||||
}
|
||||
}, { passive: true });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Asset Assignment Rules
|
||||
|
||||
### What Goes in Each Depth Level
|
||||
|
||||
**Depth 0 — Far Background**
|
||||
- Full-width background images (sky, gradient, texture)
|
||||
- Very large PNGs (1920×1080+), file size 80–150KB max
|
||||
- Heavily blurred by CSS — low detail is fine and preferred
|
||||
- Examples: skyscape, abstract color wash, noise texture
|
||||
|
||||
**Depth 1 — Glow / Atmosphere**
|
||||
- Radial gradient blobs, lens flare PNGs, soft light overlays
|
||||
- Size: 600–1000px, file size: 30–60KB max
|
||||
- Always use `mix-blend-mode: screen` or `mix-blend-mode: lighten`
|
||||
- Always `filter: blur(40px–100px)` applied on top of CSS blur
|
||||
- Examples: orange glow blob behind product, atmospheric haze
|
||||
|
||||
**Depth 2 — Mid Decorations**
|
||||
- Abstract shapes, geometric patterns, floating decorative elements
|
||||
- Size: 200–400px, file size: 20–50KB max
|
||||
- Moderate shadow, no blur
|
||||
- Examples: floating geometric shapes, brand pattern elements
|
||||
|
||||
**Depth 3 — Main Objects (The Star)**
|
||||
- Hero product images, characters, featured illustrations
|
||||
- Size: 800–1200px, file size: 50–120KB max
|
||||
- High detail, clean cutout (transparent PNG background)
|
||||
- Strong drop shadow: `filter: drop-shadow(0 30px 60px rgba(0,0,0,0.4))`
|
||||
- This is the element users look at — give it the most visual weight
|
||||
- Examples: juice bottle, product shot, hero character
|
||||
|
||||
**Depth 4 — UI / Text**
|
||||
- Headlines, body copy, buttons, cards, navigation
|
||||
- Always crisp, never blurred
|
||||
- Text elements get animation data attributes (see text-animations.md)
|
||||
- Examples: `<h1>`, `<p>`, `<button>`, card components
|
||||
|
||||
**Depth 5 — Foreground Particles / FX**
|
||||
- Sparkles, floating dots, light particles, decorative splashes
|
||||
- Small (32–128px), file size: 2–10KB
|
||||
- High contrast, sharp edges
|
||||
- Multiple instances scattered with different animation delays
|
||||
- Examples: star sparkles, liquid splash dots, highlight flares
|
||||
|
||||
---
|
||||
|
||||
## Compositional Hierarchy — Size Relationships Between Assets
|
||||
|
||||
The most common mistake in 2.5D design is treating all assets as the same size.
|
||||
Real cinematic depth requires deliberate, intentional size contrast.
|
||||
|
||||
### The Rule of One Hero
|
||||
|
||||
Every scene has exactly ONE dominant asset. Everything else serves it.
|
||||
|
||||
| Role | Display Size | Depth |
|
||||
|---|---|---|
|
||||
| Hero / star element | 50–85vw | depth-3 |
|
||||
| Primary companion | 8–15vw | depth-2 |
|
||||
| Secondary companion | 5–10vw | depth-2 |
|
||||
| Accent / particle | 1–4vw | depth-5 |
|
||||
| Background fill | 100vw | depth-0 |
|
||||
|
||||
### Positioning Companions Close to the Hero
|
||||
|
||||
Never scatter companions in random corners. Position them relative to the hero's edge:
|
||||
|
||||
```css
|
||||
/*
|
||||
Hero width: clamp(600px, 70vw, 1000px)
|
||||
Hero half-width: clamp(300px, 35vw, 500px)
|
||||
*/
|
||||
.companion-right {
|
||||
position: absolute;
|
||||
right: calc(50% - clamp(300px, 35vw, 500px) - 20px);
|
||||
/* negative gap value = slightly overlaps the hero */
|
||||
}
|
||||
.companion-left {
|
||||
position: absolute;
|
||||
left: calc(50% - clamp(300px, 35vw, 500px) - 20px);
|
||||
}
|
||||
```
|
||||
|
||||
Vertical placement:
|
||||
- Upper shoulder: `top: 35%; transform: translateY(-50%)`
|
||||
- Mid waist: `top: 55%; transform: translateY(-50%)`
|
||||
- Lower base: `top: 72%; transform: translateY(-50%)`
|
||||
|
||||
### Scatter Rule on Hero Scroll-Out
|
||||
|
||||
When the hero grows or exits, companions scatter outward — not just fade.
|
||||
This reinforces they were "held in orbit" by the hero.
|
||||
|
||||
```javascript
|
||||
heroScrollTimeline
|
||||
.to('.companion-right', { x: 80, y: -50, scale: 1.3 }, scrollPos)
|
||||
.to('.companion-left', { x: -70, y: 40, scale: 1.25 }, scrollPos)
|
||||
.to('.companion-lower', { x: 30, y: 80, scale: 1.1 }, scrollPos)
|
||||
```
|
||||
|
||||
### Pre-Build Size Checklist
|
||||
|
||||
Before assigning sizes, answer these for every asset:
|
||||
1. Is this the hero? → make it large enough to command the viewport
|
||||
2. Is this a companion? → it should be 15–25% of the hero's display size
|
||||
3. Would this read better bigger or smaller than my first instinct?
|
||||
4. Is there enough size contrast between depth layers to read as real depth?
|
||||
5. Does the composition feel balanced, or does everything look the same size?
|
||||
|
||||
---
|
||||
|
||||
## Floating Loop Animation
|
||||
|
||||
Every element at depth 2–5 should have a floating animation. Nothing should be perfectly static — it kills the 3D illusion.
|
||||
|
||||
```css
|
||||
/* Float variants — apply different ones to different elements */
|
||||
@keyframes float-y {
|
||||
0%, 100% { transform: translateY(0px); }
|
||||
50% { transform: translateY(-18px); }
|
||||
}
|
||||
@keyframes float-rotate {
|
||||
0%, 100% { transform: translateY(0px) rotate(0deg); }
|
||||
33% { transform: translateY(-12px) rotate(2deg); }
|
||||
66% { transform: translateY(-6px) rotate(-1deg); }
|
||||
}
|
||||
@keyframes float-breathe {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.04); }
|
||||
}
|
||||
@keyframes float-orbit {
|
||||
0% { transform: translate(0, 0) rotate(0deg); }
|
||||
25% { transform: translate(8px, -12px) rotate(2deg); }
|
||||
50% { transform: translate(0, -20px) rotate(0deg); }
|
||||
75% { transform: translate(-8px, -12px) rotate(-2deg); }
|
||||
100% { transform: translate(0, 0) rotate(0deg); }
|
||||
}
|
||||
|
||||
/* Depth-appropriate durations */
|
||||
.depth-2 .float-loop { animation: float-y 10s ease-in-out infinite; }
|
||||
.depth-3 .float-loop { animation: float-orbit 8s ease-in-out infinite; }
|
||||
.depth-5 .float-loop { animation: float-rotate 6s ease-in-out infinite; }
|
||||
|
||||
/* Stagger delays for multiple elements at same depth */
|
||||
.float-loop:nth-child(2) { animation-delay: -2s; }
|
||||
.float-loop:nth-child(3) { animation-delay: -4s; }
|
||||
.float-loop:nth-child(4) { animation-delay: -1.5s; }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Shadow Depth Enhancement
|
||||
|
||||
Stronger shadows on closer elements amplify depth perception:
|
||||
|
||||
```css
|
||||
/* Depth shadow system */
|
||||
.depth-2 img { filter: drop-shadow(0 10px 20px rgba(0,0,0,0.20)); }
|
||||
.depth-3 img { filter: drop-shadow(0 25px 50px rgba(0,0,0,0.35)); }
|
||||
.depth-5 img { filter: drop-shadow(0 5px 15px rgba(0,0,0,0.50)); }
|
||||
```
|
||||
|
||||
## Glow Layer Pattern (Depth 1)
|
||||
|
||||
The glow layer is critical for the "product floating in light" premium feel:
|
||||
|
||||
```css
|
||||
/* Glow blob behind the main product */
|
||||
.glow-blob {
|
||||
position: absolute;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
border-radius: 50%;
|
||||
background: radial-gradient(circle, var(--brand-color) 0%, transparent 70%);
|
||||
filter: blur(80px);
|
||||
opacity: 0.45;
|
||||
mix-blend-mode: screen;
|
||||
/* Position behind depth-3 product */
|
||||
z-index: 1;
|
||||
/* Slow drift */
|
||||
animation: float-breathe 12s ease-in-out infinite;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## HTML Scaffold Template
|
||||
|
||||
```html
|
||||
<section class="scene" data-scene="[name]">
|
||||
<div class="scene-inner">
|
||||
|
||||
<!-- DEPTH 0: Far background -->
|
||||
<div class="layer depth-0" aria-hidden="true">
|
||||
<div class="bg-gradient"></div>
|
||||
<!-- OR: <img src="bg-texture.png" alt=""> -->
|
||||
</div>
|
||||
|
||||
<!-- DEPTH 1: Glow atmosphere -->
|
||||
<div class="layer depth-1" aria-hidden="true">
|
||||
<div class="glow-blob glow-primary"></div>
|
||||
<div class="glow-blob glow-secondary"></div>
|
||||
</div>
|
||||
|
||||
<!-- DEPTH 2: Mid decorations -->
|
||||
<div class="layer depth-2" aria-hidden="true">
|
||||
<img class="deco float-loop" src="shape-1.png" alt="">
|
||||
<img class="deco float-loop" src="shape-2.png" alt="">
|
||||
</div>
|
||||
|
||||
<!-- DEPTH 3: Main product/hero -->
|
||||
<div class="layer depth-3">
|
||||
<img class="product-hero float-loop" src="product.png"
|
||||
alt="[Meaningful description of product]" />
|
||||
</div>
|
||||
|
||||
<!-- DEPTH 4: Text & UI -->
|
||||
<div class="layer depth-4">
|
||||
<h1 class="hero-title split-text" data-animate="converge">
|
||||
Your Headline
|
||||
</h1>
|
||||
<p class="hero-sub" data-animate="fade-up">Supporting copy here</p>
|
||||
<a class="cta-btn" href="#" data-animate="scale-in">Get Started</a>
|
||||
</div>
|
||||
|
||||
<!-- DEPTH 5: Foreground particles -->
|
||||
<div class="layer depth-5" aria-hidden="true">
|
||||
<img class="particle float-loop" src="sparkle.png" alt="">
|
||||
<img class="particle float-loop" src="sparkle.png" alt="">
|
||||
<img class="particle float-loop" src="sparkle.png" alt="">
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
455
engineering-team/epic-design/references/directional-reveals.md
Normal file
455
engineering-team/epic-design/references/directional-reveals.md
Normal file
@@ -0,0 +1,455 @@
|
||||
# Directional Reveals Reference
|
||||
|
||||
Elements and sections don't always enter from the bottom. Premium sites use **directional births** — sections that drop from the top, iris open from center, peel away like wallpaper, or unfold diagonally. This file covers all 8 directional reveal patterns.
|
||||
|
||||
## Table of Contents
|
||||
1. [Top-Down Clip Birth](#top-down)
|
||||
2. [Window Pane Iris Open](#iris-open)
|
||||
3. [Curtain Panel Roll-Up](#curtain-rollup)
|
||||
4. [SVG Morph Border](#svg-morph)
|
||||
5. [Diagonal Wipe Birth](#diagonal-wipe)
|
||||
6. [Circle Iris Expand](#circle-iris)
|
||||
7. [Multi-Directional Stagger Grid](#multi-direction)
|
||||
8. [Loading Screen Curtain Lift](#loading-screen)
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Top-Down Clip Birth {#top-down}
|
||||
|
||||
The section is born from the top edge and grows **downward**. Instead of rising from below, it drops and unfolds from above. This is the opposite of the conventional bottom-up reveal and creates a striking "curtain drop" feeling.
|
||||
|
||||
```css
|
||||
/* Starting state — section is fully clipped (invisible) */
|
||||
.top-drop-section {
|
||||
/* Section exists in DOM but is invisible */
|
||||
clip-path: inset(0 0 100% 0);
|
||||
/*
|
||||
inset(top right bottom left):
|
||||
- top: 0 → clip starts at top edge
|
||||
- bottom: 100% → clips 100% from bottom = nothing visible
|
||||
*/
|
||||
}
|
||||
|
||||
/* Revealed state */
|
||||
.top-drop-section.revealed {
|
||||
clip-path: inset(0 0 0% 0);
|
||||
transition: clip-path 1.2s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// GSAP scroll-driven version with scrub
|
||||
function initTopDownBirth(sectionEl) {
|
||||
gsap.fromTo(sectionEl,
|
||||
{ clipPath: 'inset(0 0 100% 0)' },
|
||||
{
|
||||
clipPath: 'inset(0 0 0% 0)',
|
||||
ease: 'power2.out',
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl.previousElementSibling, // previous section is the trigger
|
||||
start: 'bottom 80%',
|
||||
end: 'bottom 20%',
|
||||
scrub: 1.5,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Exit: section retracts back upward (born from top, dies back up)
|
||||
function addTopRetractExit(sectionEl) {
|
||||
gsap.to(sectionEl, {
|
||||
clipPath: 'inset(100% 0 0% 0)', // now clips from TOP — retracts upward
|
||||
ease: 'power2.in',
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl,
|
||||
start: 'bottom 20%',
|
||||
end: 'bottom top',
|
||||
scrub: 1,
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Key insight:** Enter = `inset(0 0 100% 0)` → `inset(0 0 0% 0)` (bottom clips away downward).
|
||||
Exit = `inset(0)` → `inset(100% 0 0 0)` (top clips away upward = retracts back where it came from).
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Window Pane Iris Open {#iris-open}
|
||||
|
||||
An entire section starts as a tiny centered rectangle — like a keyhole or portal — and expands outward to fill the viewport. Creates a cinematic "opening shot" feeling.
|
||||
|
||||
```javascript
|
||||
function initWindowPaneIris(sectionEl) {
|
||||
// The section starts as a small centered window
|
||||
gsap.fromTo(sectionEl,
|
||||
{
|
||||
clipPath: 'inset(42% 35% 42% 35% round 12px)',
|
||||
// 42% from top AND bottom = only 16% of height visible
|
||||
// 35% from left AND right = only 30% of width visible
|
||||
// Centered rectangle peek
|
||||
},
|
||||
{
|
||||
clipPath: 'inset(0% 0% 0% 0% round 0px)',
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl,
|
||||
start: 'top 90%',
|
||||
end: 'top 10%',
|
||||
scrub: 1.2,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Also scale/zoom the content inside for parallax depth
|
||||
gsap.fromTo(sectionEl.querySelector('.iris-content'),
|
||||
{ scale: 1.4 },
|
||||
{
|
||||
scale: 1,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl,
|
||||
start: 'top 90%',
|
||||
end: 'top 10%',
|
||||
scrub: 1.2,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
**Variation — horizontal bar open (blinds effect):**
|
||||
```javascript
|
||||
// Two bars that slide apart (one from top, one from bottom)
|
||||
function initBlindsOpen(topBar, bottomBar, revealEl) {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: revealEl,
|
||||
start: 'top 70%',
|
||||
toggleActions: 'play none none reverse',
|
||||
}
|
||||
});
|
||||
|
||||
tl.to(topBar, { yPercent: -100, duration: 1.0, ease: 'power3.inOut' })
|
||||
.to(bottomBar, { yPercent: 100, duration: 1.0, ease: 'power3.inOut' }, 0);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Curtain Panel Roll-Up {#curtain-rollup}
|
||||
|
||||
Multiple layered panels. Each one "rolls up" from top, exposing the panel beneath. Like peeling back wallpaper layers to reveal what's underneath. Uses z-index stacking.
|
||||
|
||||
```css
|
||||
.curtain-stack {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.curtain-panel {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
/* Stack panels — panel 1 on top, panel N on bottom */
|
||||
}
|
||||
.curtain-panel:nth-child(1) { z-index: 5; background: #0f0f0f; }
|
||||
.curtain-panel:nth-child(2) { z-index: 4; background: #1a0a2e; }
|
||||
.curtain-panel:nth-child(3) { z-index: 3; background: #2d0b4e; }
|
||||
.curtain-panel:nth-child(4) { z-index: 2; background: #1e3a8a; }
|
||||
/* Final revealed content at z-index 1 */
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initCurtainRollUp(containerEl) {
|
||||
const panels = gsap.utils.toArray('.curtain-panel', containerEl);
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: containerEl,
|
||||
start: 'top top',
|
||||
end: `+=${panels.length * 120}%`,
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
}
|
||||
});
|
||||
|
||||
panels.forEach((panel, i) => {
|
||||
const segmentDuration = 1 / panels.length;
|
||||
const segmentStart = i * segmentDuration;
|
||||
|
||||
// Each panel rolls up — clip from bottom rises to top
|
||||
tl.to(panel, {
|
||||
clipPath: 'inset(100% 0 0% 0)', // rolls up: bottom clips first, rising to 100%
|
||||
duration: segmentDuration,
|
||||
ease: 'power2.inOut',
|
||||
}, segmentStart);
|
||||
|
||||
// Heading for this panel fades in
|
||||
const heading = panel.querySelector('.panel-heading');
|
||||
if (heading) {
|
||||
tl.from(heading, {
|
||||
opacity: 0,
|
||||
y: 30,
|
||||
duration: segmentDuration * 0.4,
|
||||
}, segmentStart + segmentDuration * 0.1);
|
||||
}
|
||||
});
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: SVG Morph Border {#svg-morph}
|
||||
|
||||
The section's edge is not a hard straight line — it morphs between shapes (rectangle → wave → diagonal → organic curve) as the user scrolls. Makes sections feel alive and fluid.
|
||||
|
||||
```html
|
||||
<!-- SVG clipPath element -->
|
||||
<svg width="0" height="0" style="position:absolute">
|
||||
<defs>
|
||||
<clipPath id="morphClip" clipPathUnits="objectBoundingBox">
|
||||
<path id="morphPath" d="M0,0 L1,0 L1,0.95 Q0.5,1.05 0,0.95 Z"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
||||
<section class="morphed-section" style="clip-path: url(#morphClip)">
|
||||
<!-- section content -->
|
||||
</section>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initSVGMorphBorder() {
|
||||
const morphPath = document.getElementById('morphPath');
|
||||
|
||||
const paths = {
|
||||
straight: 'M0,0 L1,0 L1,1 L0,1 Z',
|
||||
wave: 'M0,0 L1,0 L1,0.95 Q0.75,1.05 0.5,0.95 Q0.25,0.85 0,0.95 Z',
|
||||
diagonal: 'M0,0 L1,0 L1,0.88 L0,1.0 Z',
|
||||
organic: 'M0,0 L1,0 L1,0.92 C0.8,1.04 0.6,0.88 0.4,1.0 C0.2,1.12 0.1,0.90 0,0.96 Z',
|
||||
};
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: '.morphed-section',
|
||||
start: 'top 80%',
|
||||
end: 'bottom 20%',
|
||||
scrub: 2,
|
||||
onUpdate: (self) => {
|
||||
const p = self.progress;
|
||||
// Morph between straight → wave → diagonal as scroll progresses
|
||||
if (p < 0.5) {
|
||||
// Interpolate straight → wave
|
||||
morphPath.setAttribute('d', p < 0.25 ? paths.straight : paths.wave);
|
||||
} else {
|
||||
morphPath.setAttribute('d', p < 0.75 ? paths.wave : paths.diagonal);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Diagonal Wipe Birth {#diagonal-wipe}
|
||||
|
||||
Content is revealed by a diagonal sweep across the screen — from top-left corner to bottom-right (or any corner combination). Feels cinematic and directional.
|
||||
|
||||
```javascript
|
||||
function initDiagonalWipe(el, direction = 'top-left') {
|
||||
const clipPaths = {
|
||||
'top-left': {
|
||||
from: 'polygon(0 0, 0 0, 0 0)',
|
||||
to: 'polygon(0 0, 120% 0, 0 120%)',
|
||||
},
|
||||
'top-right': {
|
||||
from: 'polygon(100% 0, 100% 0, 100% 0)',
|
||||
to: 'polygon(-20% 0, 100% 0, 100% 120%)',
|
||||
},
|
||||
'center-out': {
|
||||
from: 'polygon(50% 50%, 50% 50%, 50% 50%, 50% 50%)',
|
||||
to: 'polygon(-10% -10%, 110% -10%, 110% 110%, -10% 110%)',
|
||||
},
|
||||
};
|
||||
|
||||
const { from, to } = clipPaths[direction];
|
||||
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: from },
|
||||
{
|
||||
clipPath: to,
|
||||
duration: 1.4,
|
||||
ease: 'power3.inOut',
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: 'top 70%',
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Circle Iris Expand {#circle-iris}
|
||||
|
||||
The most dramatic reveal: a perfect circle expands from the center of the section outward, like an aperture opening or a spotlight switching on.
|
||||
|
||||
```javascript
|
||||
function initCircleIris(el, originX = '50%', originY = '50%') {
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: `circle(0% at ${originX} ${originY})` },
|
||||
{
|
||||
clipPath: `circle(80% at ${originX} ${originY})`,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: el,
|
||||
start: 'top 75%',
|
||||
end: 'top 25%',
|
||||
scrub: 1,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Variant: iris opens from cursor position on hover
|
||||
function initHoverIris(el) {
|
||||
el.addEventListener('mouseenter', (e) => {
|
||||
const rect = el.getBoundingClientRect();
|
||||
const x = ((e.clientX - rect.left) / rect.width * 100).toFixed(1) + '%';
|
||||
const y = ((e.clientY - rect.top) / rect.height * 100).toFixed(1) + '%';
|
||||
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: `circle(0% at ${x} ${y})` },
|
||||
{ clipPath: `circle(100% at ${x} ${y})`, duration: 0.6, ease: 'power2.out' }
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Multi-Directional Stagger Grid {#multi-direction}
|
||||
|
||||
When a grid or set of cards appears, each item enters from a different edge/direction — creating a dynamic assembly effect instead of uniform fade-ups.
|
||||
|
||||
```javascript
|
||||
function initMultiDirectionalGrid(gridEl) {
|
||||
const items = gsap.utils.toArray('.grid-item', gridEl);
|
||||
|
||||
const directions = [
|
||||
{ x: -80, y: 0 }, // from left
|
||||
{ x: 0, y: -80 }, // from top
|
||||
{ x: 80, y: 0 }, // from right
|
||||
{ x: 0, y: 80 }, // from bottom
|
||||
{ x: -60, y: -60 }, // from top-left
|
||||
{ x: 60, y: -60 }, // from top-right
|
||||
{ x: -60, y: 60 }, // from bottom-left
|
||||
{ x: 60, y: 60 }, // from bottom-right
|
||||
];
|
||||
|
||||
items.forEach((item, i) => {
|
||||
const dir = directions[i % directions.length];
|
||||
|
||||
gsap.from(item, {
|
||||
x: dir.x,
|
||||
y: dir.y,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: {
|
||||
trigger: gridEl,
|
||||
start: 'top 75%',
|
||||
},
|
||||
delay: i * 0.08, // stagger
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8: Loading Screen Curtain Lift {#loading-screen}
|
||||
|
||||
A full-viewport branded intro screen that physically lifts off the page on load, revealing the site beneath. Sets cinematic expectations before any scroll animation begins.
|
||||
|
||||
```css
|
||||
.loading-curtain {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 9999;
|
||||
background: #0a0a0a; /* or brand color */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
/* Split into two halves for dramatic split-open effect */
|
||||
}
|
||||
|
||||
.curtain-top {
|
||||
position: absolute;
|
||||
top: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: inherit;
|
||||
transform-origin: top center;
|
||||
}
|
||||
|
||||
.curtain-bottom {
|
||||
position: absolute;
|
||||
bottom: 0; left: 0; right: 0;
|
||||
height: 50%;
|
||||
background: inherit;
|
||||
transform-origin: bottom center;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initLoadingCurtain() {
|
||||
const curtainTop = document.querySelector('.curtain-top');
|
||||
const curtainBottom = document.querySelector('.curtain-bottom');
|
||||
const curtainLogo = document.querySelector('.curtain-logo');
|
||||
const loadingScreen = document.querySelector('.loading-curtain');
|
||||
|
||||
// Prevent scroll during loading
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
const tl = gsap.timeline({
|
||||
delay: 0.5,
|
||||
onComplete: () => {
|
||||
document.body.style.overflow = '';
|
||||
loadingScreen.style.display = 'none';
|
||||
// Init all scroll animations AFTER curtain lifts
|
||||
initAllAnimations();
|
||||
}
|
||||
});
|
||||
|
||||
// Logo appears first
|
||||
tl.from(curtainLogo, { opacity: 0, scale: 0.8, duration: 0.6, ease: 'power2.out' })
|
||||
// Brief hold
|
||||
.to({}, { duration: 0.4 })
|
||||
// Logo fades out
|
||||
.to(curtainLogo, { opacity: 0, scale: 1.1, duration: 0.4, ease: 'power2.in' })
|
||||
// Curtain splits: top goes up, bottom goes down
|
||||
.to(curtainTop, { yPercent: -100, duration: 0.9, ease: 'power4.inOut' }, '-=0.1')
|
||||
.to(curtainBottom, { yPercent: 100, duration: 0.9, ease: 'power4.inOut' }, '<');
|
||||
}
|
||||
|
||||
window.addEventListener('load', initLoadingCurtain);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Combining Directional Reveals
|
||||
|
||||
For maximum cinematic impact, chain directional reveals between sections:
|
||||
|
||||
```
|
||||
Section 1 → Section 2: Window pane iris (section 2 peeks through a keyhole)
|
||||
Section 2 → Section 3: Top-down clip birth (section 3 drops from top)
|
||||
Section 3 → Section 4: Diagonal wipe (section 4 sweeps in from corner)
|
||||
Section 4 → Section 5: Circle iris (section 5 opens from center)
|
||||
Section 5 → Section 6: Curtain panel roll-up (exposes multiple layers)
|
||||
```
|
||||
|
||||
Each transition feels distinct, keeping the user engaged across the full scroll experience.
|
||||
344
engineering-team/epic-design/references/examples.md
Normal file
344
engineering-team/epic-design/references/examples.md
Normal file
@@ -0,0 +1,344 @@
|
||||
# Real-World Examples Reference
|
||||
|
||||
Five complete implementation blueprints. Each describes exactly which techniques to combine, in what order, with key code patterns.
|
||||
|
||||
## Table of Contents
|
||||
1. [Juice/Beverage Brand Launch](#juice-brand)
|
||||
2. [Tech SaaS Landing Page](#saas)
|
||||
3. [Creative Portfolio](#portfolio)
|
||||
4. [Gaming Website](#gaming)
|
||||
5. [Luxury Product E-Commerce](#ecommerce)
|
||||
|
||||
---
|
||||
|
||||
## Example 1: Juice/Beverage Brand Launch {#juice-brand}
|
||||
|
||||
**Brief:** Premium juice brand. Hero has floating glass. Sections transition smoothly with the product "rising" between them.
|
||||
|
||||
**Techniques Used:**
|
||||
- Loading screen curtain lift
|
||||
- 6-layer depth parallax in hero
|
||||
- Floating product between sections (THE signature move)
|
||||
- Top-down clip birth for ingredients section
|
||||
- Word-by-word scroll lighting for tagline
|
||||
- Cascading card stack for flavors
|
||||
- Split converge title exit
|
||||
|
||||
**Section Architecture:**
|
||||
|
||||
```
|
||||
[LOADING SCREEN — brand logo on black, splits open]
|
||||
↓
|
||||
[HERO — dark purple gradient]
|
||||
depth-0: purple/dark gradient background
|
||||
depth-1: orange glow blob (brand color)
|
||||
depth-2: floating citrus slice PNGs (scattered, decorative)
|
||||
depth-3: juice glass PNG (main product, float-loop)
|
||||
depth-4: headline "Pure. Fresh. Electric." (split converge on enter)
|
||||
depth-5: liquid splash particle PNGs
|
||||
|
||||
[FLOATING PRODUCT BRIDGE — glass hovers between sections]
|
||||
|
||||
[INGREDIENTS — warm cream/yellow section]
|
||||
Entry: top-down clip birth (section drops from top)
|
||||
depth-0: warm gradient background
|
||||
depth-3: large orange PNG illustration
|
||||
depth-4: "Word by word" ingredient callouts (scroll-lit)
|
||||
Floating text: ingredient names fade in one by one
|
||||
|
||||
[FLAVORS — cascading card stack, 3 cards]
|
||||
Card 1: Orange — scales down as Card 2 arrives
|
||||
Card 2: Mango — scales down as Card 3 arrives
|
||||
Card 3: Berry — stays full screen
|
||||
Each card: full-bleed color + depth-3 bottle + depth-4 title
|
||||
|
||||
[CTA — minimal, dark]
|
||||
Circle iris expand reveal
|
||||
Oversized bleed typography: "DRINK DIFFERENT"
|
||||
Simple form/button
|
||||
```
|
||||
|
||||
**Key Code Pattern — The Glass Journey:**
|
||||
```javascript
|
||||
// Glass starts in hero depth-3, floats between sections,
|
||||
// then descends into ingredients section
|
||||
initFloatingProduct(); // from inter-section-effects.md
|
||||
|
||||
// On arrival in ingredients section, glass triggers
|
||||
// the ingredient words to light up one by one
|
||||
ScrollTrigger.create({
|
||||
trigger: '.ingredients-section',
|
||||
start: 'top 50%',
|
||||
onEnter: () => {
|
||||
initWordScrollLighting(
|
||||
'.ingredients-section',
|
||||
'.ingredients-tagline'
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**Color Palette:**
|
||||
- Hero: `#0a0014` (deep purple) → `#2d0b4e`
|
||||
- Glow: `#ff6b00` (orange), `#ff9900` (amber)
|
||||
- Ingredients: `#fdf4e7` (warm cream)
|
||||
- Flavors: Brand-specific per flavor
|
||||
- CTA: `#0a0014` (returns to hero dark)
|
||||
|
||||
---
|
||||
|
||||
## Example 2: Tech SaaS Landing Page {#saas}
|
||||
|
||||
**Brief:** B2B SaaS product — analytics dashboard. Premium, modern, tech-forward. Animated product screenshots.
|
||||
|
||||
**Techniques Used:**
|
||||
- Window pane iris open (hero reveals from keyhole)
|
||||
- DJI-style scale-in pin (dashboard screenshot fills viewport)
|
||||
- Scrub timeline (features appear one by one)
|
||||
- Curtain panel roll-up (pricing tiers reveal)
|
||||
- Character cylinder rotation (headline numbers: "10x faster")
|
||||
- Line clip wipe (feature descriptions)
|
||||
- Horizontal scroll (integration logos)
|
||||
|
||||
**Section Architecture:**
|
||||
|
||||
```
|
||||
[HERO — midnight blue]
|
||||
Entry: window pane iris — site reveals from tiny centered rectangle
|
||||
depth-0: mesh gradient (dark blue/purple)
|
||||
depth-1: subtle grid pattern (CSS, not PNG) with opacity 0.15
|
||||
depth-2: floating abstract geometric shapes (low opacity)
|
||||
depth-3: dashboard screenshot PNG (float-loop subtle)
|
||||
depth-4: headline with CYLINDER ROTATION on "10x"
|
||||
"Make your analytics 10x smarter"
|
||||
depth-5: small glow dots/particles
|
||||
|
||||
[FEATURE ZOOM — pinned section, 300vh scroll distance]
|
||||
DJI-style: Dashboard screenshot starts small, expands to full viewport
|
||||
Scrub timeline reveals 3 features as user scrolls through pin:
|
||||
- Feature 1: "Real-time insights" fades in left
|
||||
- Feature 2: "AI-powered" fades in right
|
||||
- Feature 3: "Zero setup" fades in center
|
||||
Each feature: line clip wipe on description text
|
||||
|
||||
[HOW IT WORKS — top-down clip birth]
|
||||
3-step process
|
||||
Each step: multi-directional stagger (step 1 from left, step 2 from top, step 3 from right)
|
||||
Numbered steps with variable font weight animation
|
||||
|
||||
[INTEGRATIONS — horizontal scroll]
|
||||
Pin section, logos scroll horizontally
|
||||
Speed reactive marquee for "works with everything you use"
|
||||
|
||||
[PRICING — curtain panel roll-up]
|
||||
3 pricing tiers as curtain panels
|
||||
Free → Pro → Enterprise reveals one by one
|
||||
Each reveal: scramble text on price number
|
||||
|
||||
[CTA — circle iris]
|
||||
Dark background
|
||||
Bleed typography: "START FREE TODAY"
|
||||
Magnetic button (cursor-attracted)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 3: Creative Portfolio {#portfolio}
|
||||
|
||||
**Brief:** Designer/developer portfolio. Bold, experimental, Awwwards-worthy. The work is the hero.
|
||||
|
||||
**Techniques Used:**
|
||||
- Offset diagonal layout for name/title
|
||||
- Theatrical enter+exit for all section content
|
||||
- Horizontal scroll for project showcase
|
||||
- GSAP Flip cross-section for project previews
|
||||
- Scroll-speed reactive marquee for skills
|
||||
- Bleed typography throughout
|
||||
- Diagonal wipe births
|
||||
- Cursor spotlight
|
||||
|
||||
**Section Architecture:**
|
||||
|
||||
```
|
||||
[INTRO — stark black]
|
||||
NO loading screen — shock with immediate bold text
|
||||
depth-0: pure black (#000)
|
||||
depth-4: MASSIVE bleed title — name in 180px+ font
|
||||
offset diagonal layout:
|
||||
Line 1: "ALEX" — top-left, x: 5%
|
||||
Line 2: "MORENO" — lower-right, x: 40%
|
||||
Line 3: "Designer" — far right, smaller, italic
|
||||
Cursor spotlight effect follows mouse
|
||||
CTA: "See Work ↓" — subtle, bottom-right
|
||||
|
||||
[MARQUEE DIVIDER]
|
||||
Scroll-speed reactive marquee:
|
||||
"AVAILABLE FOR WORK · BASED IN LONDON · OPEN TO REMOTE ·"
|
||||
Speed up when user scrolls fast
|
||||
|
||||
[PROJECTS — horizontal scroll, 4 projects]
|
||||
Pinned container, horizontal scroll
|
||||
Each panel: full-bleed project image
|
||||
project title via line clip wipe
|
||||
brief description via theatrical enter
|
||||
On hover: project image scale(1.03), cursor becomes "View →"
|
||||
Between projects: diagonal wipe transition
|
||||
|
||||
[ABOUT — section peel]
|
||||
Upper section peels away to reveal about section
|
||||
depth-3: portrait photo (clip-path circle iris, expands to full)
|
||||
depth-4: about text — curtain line reveal
|
||||
Skills: variable font wave animation
|
||||
|
||||
[PROCESS — pinned scrub timeline]
|
||||
3 process stages animate through scroll:
|
||||
Each stage: top-down clip birth reveals content
|
||||
Numbers: character cylinder rotation
|
||||
|
||||
[CONTACT — minimal]
|
||||
Circle iris expand
|
||||
Email address: scramble text effect on hover
|
||||
Social links: skew + bounce on scroll in
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 4: Gaming Website {#gaming}
|
||||
|
||||
**Brief:** Game launch page. Dark, cinematic, intense. Character reveals, environment depth.
|
||||
|
||||
**Techniques Used:**
|
||||
- Curved path travel (character moves across page)
|
||||
- Perspective zoom fly-through (fly into the game world)
|
||||
- Full layered parallax (6 levels deep)
|
||||
- SVG morph borders (organic landscape edges)
|
||||
- Cascading card stacks (character select)
|
||||
- Word-by-word scroll lighting (lore text)
|
||||
- Particle trails (cursor leaves sparks)
|
||||
- Multiple floating loops (atmospheric)
|
||||
|
||||
**Section Architecture:**
|
||||
|
||||
```
|
||||
[LOADING SCREEN — game-style]
|
||||
Loading bar fills
|
||||
Logo does cylinder rotation
|
||||
Splits open with curtain top/bottom
|
||||
|
||||
[HERO — extreme depth parallax]
|
||||
depth-0: distant mountains/sky PNG (very slow, heavily blurred)
|
||||
depth-1: mid-distance fog layer (slightly blurred, mix-blend: screen)
|
||||
depth-2: closer terrain elements (decorative)
|
||||
depth-3: CHARACTER PNG — hero character (main float-loop)
|
||||
depth-4: game title — "SHADOWREALM" (split converge from sides)
|
||||
depth-5: foreground particles — embers/sparks (fast float)
|
||||
Cursor: particle trail (sparks follow cursor)
|
||||
|
||||
[FLY-THROUGH — perspective zoom, 300vh]
|
||||
Pinned section
|
||||
Camera appears to fly INTO the game world
|
||||
Background rushes toward viewer (scale 0.3 → 1.4)
|
||||
Character appears from far (scale 0.05 → 1)
|
||||
Title resolves via scramble text
|
||||
|
||||
[LORE — word scroll lighting, pinned 400vh]
|
||||
Dark section, long block of atmospheric text
|
||||
Words light up as user scrolls
|
||||
Atmospheric background particles drift slowly
|
||||
Character silhouette visible at depth-1 (very faint)
|
||||
|
||||
[CHARACTERS — cascading card stack, 4 characters]
|
||||
Each card: character art full-bleed
|
||||
Character name: cylinder rotation
|
||||
Class/description: line clip wipe
|
||||
Stats: stagger animate (bars fill on enter)
|
||||
Each card buried: scale(0.88), blur, pushed back
|
||||
|
||||
[WORLD MAP — horizontal scroll]
|
||||
5 zones scroll horizontally
|
||||
Zone titles: offset diagonal layout
|
||||
Environment art at different parallax speeds
|
||||
|
||||
[PRE-ORDER — window pane iris]
|
||||
Iris opens revealing pre-order section
|
||||
Bleed typography: "ENTER THE REALM"
|
||||
Magnetic CTA button
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Example 5: Luxury Product E-Commerce {#ecommerce}
|
||||
|
||||
**Brief:** High-end watch/jewelry brand. Understated elegance. Every animation whispers, not shouts. The product is the hero.
|
||||
|
||||
**Techniques Used:**
|
||||
- DJI-style scale-in (product fills viewport, slowly)
|
||||
- GSAP Flip (watch travels from hero to detail view)
|
||||
- Section peel reveal (product details peel open)
|
||||
- Masked line curtain reveal (all body text)
|
||||
- Clip-path section birth (materials section)
|
||||
- Floating product between sections
|
||||
- Subtle parallax (depth factors halved for elegance)
|
||||
- Bleed typography (collection names)
|
||||
|
||||
**Section Architecture:**
|
||||
|
||||
```
|
||||
[HERO — pure white or cream]
|
||||
No loading screen — immediate elegance
|
||||
depth-0: pure white / soft cream gradient
|
||||
depth-1: VERY subtle warm glow (opacity 0.2 only)
|
||||
depth-2: minimal geometric line decoration (thin, opacity 0.3)
|
||||
depth-3: WATCH PNG — centered, generous space, slow float (14s loop, tiny movement)
|
||||
depth-4: brand name — thin weight, large tracking
|
||||
"Est. 1887" — tiny, centered below
|
||||
Parallax factors reduced: depth-3 factor = 0.3 (elegant, not dramatic)
|
||||
|
||||
[PRODUCT TRANSITION — GSAP Flip]
|
||||
Watch morphs from hero center to detail view (left side)
|
||||
Detail text reveals via masked line curtain (right side)
|
||||
Flip duration: 1.4s (luxury = slow, unhurried)
|
||||
|
||||
[MATERIALS — clip-path section birth]
|
||||
Cream/beige section
|
||||
Product rises up through the section boundary
|
||||
Material close-ups: stagger fade in from bottom (gentle)
|
||||
Text: curtain line reveal (one line at a time, 0.2s stagger)
|
||||
|
||||
[CRAFTSMANSHIP — top-down clip birth, then peel]
|
||||
Section drops from top (elegant, not dramatic)
|
||||
Video/image of watchmaker — DJI scale-in at reduced intensity
|
||||
Text: word-by-word scroll lighting (VERY slow, meditative)
|
||||
|
||||
[COLLECTION — section peel + horizontal scroll]
|
||||
Peel reveals horizontal scroll gallery
|
||||
4 watch variants scroll horizontally
|
||||
Each: full-bleed product + minimal text (clip wipe)
|
||||
|
||||
[PURCHASE — circle iris (small, elegant)]
|
||||
Circle opens from center, but slowly (2s duration)
|
||||
Minimal layout: price, materials, add to cart
|
||||
CTA: subtle skew + bounce (barely perceptible)
|
||||
Trust signals: line-by-line curtain reveal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Combining Patterns — Quick Reference
|
||||
|
||||
These combinations appear most often across successful premium sites:
|
||||
|
||||
**The "Product Hero" Combination:**
|
||||
Floating product between sections + Top-down clip birth + Split converge title + Word scroll lighting
|
||||
|
||||
**The "Cinematic Chapter" Combination:**
|
||||
Pinned sticky + Scrub timeline + Curtain panel roll-up + Theatrical enter/exit
|
||||
|
||||
**The "Tech Premium" Combination:**
|
||||
Window pane iris + DJI scale-in + Line clip wipe + Cylinder rotation
|
||||
|
||||
**The "Editorial" Combination:**
|
||||
Bleed typography + Offset diagonal + Horizontal scroll + Diagonal wipe
|
||||
|
||||
**The "Minimal Luxury" Combination:**
|
||||
GSAP Flip + Section peel + Masked line curtain + Reduced parallax factors
|
||||
493
engineering-team/epic-design/references/inter-section-effects.md
Normal file
493
engineering-team/epic-design/references/inter-section-effects.md
Normal file
@@ -0,0 +1,493 @@
|
||||
# Inter-Section Effects Reference
|
||||
|
||||
These are the most premium techniques — effects where elements **persist, travel, or transition between sections**, creating a seamless narrative thread across the entire page.
|
||||
|
||||
## Table of Contents
|
||||
1. [Floating Product Between Sections](#floating-product)
|
||||
2. [GSAP Flip Cross-Section Morph](#flip-morph)
|
||||
3. [Clip-Path Section Birth (Product Grows from Border)](#clip-birth)
|
||||
4. [DJI-Style Scale-In Pin](#dji-scale)
|
||||
5. [Element Curved Path Travel](#curved-path)
|
||||
6. [Section Peel Reveal](#section-peel)
|
||||
|
||||
---
|
||||
|
||||
## Technique 1: Floating Product Between Sections {#floating-product}
|
||||
|
||||
This is THE signature technique for product brands. A product image (juice bottle, phone, sneaker) starts inside the hero section. As you scroll, it appears to "rise up" through the section boundary and hover between two differently-colored sections — partially owned by neither. Then as you continue scrolling, it gracefully descends back in.
|
||||
|
||||
**The Visual Story:**
|
||||
- Hero section: product sitting naturally inside
|
||||
- Mid-scroll: product "floating" in space, section colors visible above and below it
|
||||
- Continue scroll: product becomes part of the next section
|
||||
|
||||
```css
|
||||
/* The product is positioned in a sticky wrapper */
|
||||
.inter-section-product-wrapper {
|
||||
/* This wrapper spans BOTH sections */
|
||||
position: relative;
|
||||
z-index: 100;
|
||||
pointer-events: none;
|
||||
height: 0; /* no height — just a position anchor */
|
||||
}
|
||||
|
||||
.inter-section-product {
|
||||
position: sticky;
|
||||
top: 50vh; /* stick to vertical center of viewport */
|
||||
transform: translateY(-50%); /* true center */
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.inter-section-product img {
|
||||
width: clamp(280px, 35vw, 560px);
|
||||
/* The product will be exactly at the section boundary
|
||||
when the page is scrolled to that point */
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initFloatingProduct() {
|
||||
const wrapper = document.querySelector('.inter-section-product-wrapper');
|
||||
const productImg = wrapper.querySelector('img');
|
||||
const heroSection = document.querySelector('.hero-section');
|
||||
const nextSection = document.querySelector('.feature-section');
|
||||
|
||||
// Create a ScrollTrigger timeline for the product's journey
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: heroSection,
|
||||
start: 'bottom 80%', // starts rising as hero bottom approaches viewport
|
||||
end: 'bottom 20%', // completes rise when hero fully exited
|
||||
scrub: 1.5,
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 1: Product rises up from hero (scale grows, shadow intensifies)
|
||||
tl.fromTo(productImg,
|
||||
{
|
||||
y: 0,
|
||||
scale: 0.85,
|
||||
filter: 'drop-shadow(0 10px 20px rgba(0,0,0,0.2))',
|
||||
},
|
||||
{
|
||||
y: '-8vh',
|
||||
scale: 1.05,
|
||||
filter: 'drop-shadow(0 40px 80px rgba(0,0,0,0.5))',
|
||||
duration: 0.5,
|
||||
}
|
||||
);
|
||||
|
||||
// Phase 2: Product fully "between" sections — peak visibility
|
||||
tl.to(productImg, {
|
||||
y: '-5vh',
|
||||
scale: 1.1,
|
||||
duration: 0.3,
|
||||
});
|
||||
|
||||
// Phase 3: Product descends into next section
|
||||
ScrollTrigger.create({
|
||||
trigger: nextSection,
|
||||
start: 'top 60%',
|
||||
end: 'top 20%',
|
||||
scrub: 1.5,
|
||||
onUpdate: (self) => {
|
||||
gsap.to(productImg, {
|
||||
y: `${self.progress * 8}vh`,
|
||||
scale: 1.1 - (self.progress * 0.2),
|
||||
duration: 0.1,
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Required HTML Structure
|
||||
|
||||
```html
|
||||
<!-- SECTION 1: Hero (dark background) -->
|
||||
<section class="hero-section" style="background: #0a0014; min-height: 100vh; position: relative; z-index: 1;">
|
||||
<!-- depth layers 0-2 (bg, glow, decorations) -->
|
||||
<!-- NO product image here — it's in the inter-section wrapper -->
|
||||
<div class="layer depth-4">
|
||||
<h1>Your Headline</h1>
|
||||
<p>Hero subtext here</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- THE FLOATING PRODUCT — outside both sections, between them -->
|
||||
<div class="inter-section-product-wrapper">
|
||||
<div class="inter-section-product">
|
||||
<img
|
||||
src="product.png"
|
||||
alt="Product Name — floating between hero and features"
|
||||
class="float-loop"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECTION 2: Features (lighter background) -->
|
||||
<section class="feature-section" style="background: #f5f0ff; min-height: 100vh; position: relative; z-index: 2; padding-top: 15vh;">
|
||||
<!-- Product appears to "land" into this section -->
|
||||
<div class="feature-content">
|
||||
<h2>Features Headline</h2>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 2: GSAP Flip Cross-Section Morph {#flip-morph}
|
||||
|
||||
The same DOM element appears to travel between completely different layout positions across sections. In the hero it's large and centered; in the feature section it's small and left-aligned; in the detail section it's full-width. One smooth morph connects them all.
|
||||
|
||||
```javascript
|
||||
function initFlipMorphSections() {
|
||||
gsap.registerPlugin(Flip);
|
||||
|
||||
// The product element exists in one place in the DOM
|
||||
// but we have "ghost" placeholder positions in other sections
|
||||
const product = document.querySelector('.traveling-product');
|
||||
const positions = {
|
||||
hero: document.querySelector('.product-position-hero'),
|
||||
feature: document.querySelector('.product-position-feature'),
|
||||
detail: document.querySelector('.product-position-detail'),
|
||||
};
|
||||
|
||||
function morphToPosition(positionEl, options = {}) {
|
||||
// Capture current state
|
||||
const state = Flip.getState(product);
|
||||
|
||||
// Move element to new position
|
||||
positionEl.appendChild(product);
|
||||
|
||||
// Animate from captured state to new position
|
||||
Flip.from(state, {
|
||||
duration: 0.9,
|
||||
ease: 'power3.inOut',
|
||||
...options
|
||||
});
|
||||
}
|
||||
|
||||
// Trigger morphs on scroll
|
||||
ScrollTrigger.create({
|
||||
trigger: '.feature-section',
|
||||
start: 'top 60%',
|
||||
onEnter: () => morphToPosition(positions.feature),
|
||||
onLeaveBack: () => morphToPosition(positions.hero),
|
||||
});
|
||||
|
||||
ScrollTrigger.create({
|
||||
trigger: '.detail-section',
|
||||
start: 'top 60%',
|
||||
onEnter: () => morphToPosition(positions.detail),
|
||||
onLeaveBack: () => morphToPosition(positions.feature),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### Ghost Position Placeholders HTML
|
||||
|
||||
```html
|
||||
<!-- Hero section: large, centered position -->
|
||||
<section class="hero-section">
|
||||
<div class="product-position-hero" style="width: 500px; height: 500px; margin: 0 auto;">
|
||||
<!-- Product starts here -->
|
||||
<img class="traveling-product" src="product.png" alt="Product" style="width:100%;">
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Feature section: medium, left-side position -->
|
||||
<section class="feature-section">
|
||||
<div class="feature-layout">
|
||||
<div class="product-position-feature" style="width: 280px; height: 280px;">
|
||||
<!-- Product morphs to here -->
|
||||
</div>
|
||||
<div class="feature-text">...</div>
|
||||
</div>
|
||||
</section>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 3: Clip-Path Section Birth (Product Grows from Border) {#clip-birth}
|
||||
|
||||
The product image starts completely hidden below the section's bottom border — clipped out of existence. As the user scrolls into the section boundary, the product "grows up" through the border like a plant emerging from soil. This is distinct from the floating product — here, the section itself is the stage.
|
||||
|
||||
```css
|
||||
.birth-section {
|
||||
position: relative;
|
||||
overflow: hidden; /* hard clip at section border */
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.birth-product {
|
||||
position: absolute;
|
||||
bottom: -20%; /* starts 20% below the section — invisible */
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: clamp(300px, 40vw, 600px);
|
||||
/* Will animate up through the section boundary */
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initClipPathBirth(sectionEl, productEl) {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl,
|
||||
start: 'top 80%',
|
||||
end: 'top 20%',
|
||||
scrub: 1.2,
|
||||
}
|
||||
});
|
||||
|
||||
// Product rises from below section boundary
|
||||
tl.fromTo(productEl,
|
||||
{
|
||||
y: '120%', // fully below section
|
||||
scale: 0.7,
|
||||
opacity: 0,
|
||||
filter: 'blur(8px)'
|
||||
},
|
||||
{
|
||||
y: '0%', // sits naturally in section
|
||||
scale: 1,
|
||||
opacity: 1,
|
||||
filter: 'blur(0px)',
|
||||
ease: 'power3.out',
|
||||
duration: 1,
|
||||
}
|
||||
);
|
||||
|
||||
// Continue scroll → product rises further and becomes full height
|
||||
// then disappears back below as section exits
|
||||
ScrollTrigger.create({
|
||||
trigger: sectionEl,
|
||||
start: 'bottom 60%',
|
||||
end: 'bottom top',
|
||||
scrub: 1,
|
||||
onUpdate: (self) => {
|
||||
gsap.to(productEl, {
|
||||
y: `${-self.progress * 50}%`,
|
||||
opacity: 1 - self.progress,
|
||||
scale: 1 + self.progress * 0.2,
|
||||
duration: 0.1,
|
||||
overwrite: true,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 4: DJI-Style Scale-In Pin {#dji-scale}
|
||||
|
||||
Made famous by DJI drone product pages. A section starts with a small, contained image. As the user scrolls, the image scales up to fill the entire viewport — THEN the section unpins and the next content reveals. Creates a "zoom into the world" feeling.
|
||||
|
||||
```javascript
|
||||
function initDJIScaleIn(sectionEl) {
|
||||
const heroMedia = sectionEl.querySelector('.dji-media');
|
||||
const heroContent = sectionEl.querySelector('.dji-content');
|
||||
const overlay = sectionEl.querySelector('.dji-overlay');
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sectionEl,
|
||||
start: 'top top',
|
||||
end: '+=300%',
|
||||
pin: true,
|
||||
scrub: 1.5,
|
||||
}
|
||||
});
|
||||
|
||||
// Stage 1: Small image scales up to fill viewport
|
||||
tl.fromTo(heroMedia,
|
||||
{
|
||||
borderRadius: '20px',
|
||||
scale: 0.3,
|
||||
width: '60%',
|
||||
left: '20%',
|
||||
top: '20%',
|
||||
},
|
||||
{
|
||||
borderRadius: '0px',
|
||||
scale: 1,
|
||||
width: '100%',
|
||||
left: '0%',
|
||||
top: '0%',
|
||||
duration: 0.4,
|
||||
ease: 'power2.inOut',
|
||||
}
|
||||
)
|
||||
// Stage 2: Overlay fades in over the full-viewport image
|
||||
.fromTo(overlay,
|
||||
{ opacity: 0 },
|
||||
{ opacity: 0.6, duration: 0.2 },
|
||||
0.35
|
||||
)
|
||||
// Stage 3: Content text appears over the overlay
|
||||
.from(heroContent.querySelectorAll('.dji-line'),
|
||||
{
|
||||
y: 40,
|
||||
opacity: 0,
|
||||
stagger: 0.08,
|
||||
duration: 0.25,
|
||||
},
|
||||
0.45
|
||||
);
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.dji-section {
|
||||
position: relative;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.dji-media {
|
||||
position: absolute;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
/* Will be animated to full coverage */
|
||||
}
|
||||
.dji-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to bottom, transparent, rgba(0,0,0,0.8));
|
||||
opacity: 0;
|
||||
}
|
||||
.dji-content {
|
||||
position: absolute;
|
||||
bottom: 15%;
|
||||
left: 8%;
|
||||
right: 8%;
|
||||
color: white;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 5: Element Curved Path Travel {#curved-path}
|
||||
|
||||
The most advanced technique. A product element travels along a smooth, curved Bezier path across the page as the user scrolls — arcing through space like it's floating or being thrown, rather than just translating in a straight line.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initCurvedPathTravel(productEl) {
|
||||
gsap.registerPlugin(MotionPathPlugin);
|
||||
|
||||
// Define the curved path as SVG coordinates
|
||||
// Relative to the product's parent container
|
||||
const path = [
|
||||
{ x: 0, y: 0 }, // Start: hero center
|
||||
{ x: -200, y: -100 }, // Arc left and up
|
||||
{ x: 100, y: -300 }, // Continue arcing
|
||||
{ x: 300, y: -150 }, // Swing right
|
||||
{ x: 200, y: 50 }, // Land into feature section
|
||||
];
|
||||
|
||||
gsap.to(productEl, {
|
||||
motionPath: {
|
||||
path: path,
|
||||
curviness: 1.4, // How curvy (0 = straight lines, 2 = very curved)
|
||||
autoRotate: false, // Don't rotate along path (keep product upright)
|
||||
},
|
||||
scale: gsap.utils.interpolate([0.8, 1.1, 0.9, 1.0, 1.2]),
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.journey-container',
|
||||
start: 'top top',
|
||||
end: '+=400%',
|
||||
pin: true,
|
||||
scrub: 1.5,
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 6: Section Peel Reveal {#section-peel}
|
||||
|
||||
The section below is revealed by the section above peeling away — like turning a page. Uses `sticky: bottom: 0` so the lower section sticks to the screen bottom while the upper section scrolls away.
|
||||
|
||||
```css
|
||||
.peel-upper {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
min-height: 100vh;
|
||||
/* This section scrolls away normally */
|
||||
}
|
||||
|
||||
.peel-lower {
|
||||
position: sticky;
|
||||
bottom: 0; /* sticks to BOTTOM of viewport */
|
||||
z-index: 1;
|
||||
min-height: 100vh;
|
||||
/* This section waits at the bottom as upper section peels away */
|
||||
}
|
||||
|
||||
/* Container wraps both */
|
||||
.peel-container {
|
||||
position: relative;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initSectionPeel() {
|
||||
const upper = document.querySelector('.peel-upper');
|
||||
const lower = document.querySelector('.peel-lower');
|
||||
|
||||
// As upper section scrolls, reveal lower by reducing clip
|
||||
gsap.fromTo(upper,
|
||||
{ clipPath: 'inset(0 0 0 0)' },
|
||||
{
|
||||
clipPath: 'inset(0 0 100% 0)', // upper peels up and away
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.peel-container',
|
||||
start: 'top top',
|
||||
end: 'center top',
|
||||
scrub: true,
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Lower section content animates in as it's revealed
|
||||
gsap.from(lower.querySelectorAll('.peel-content > *'), {
|
||||
y: 30,
|
||||
opacity: 0,
|
||||
stagger: 0.1,
|
||||
duration: 0.6,
|
||||
scrollTrigger: {
|
||||
trigger: '.peel-container',
|
||||
start: '30% top',
|
||||
toggleActions: 'play none none reverse',
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Choosing the Right Inter-Section Technique
|
||||
|
||||
| Situation | Best Technique |
|
||||
|-----------|---------------|
|
||||
| Brand/product site with hero image | Floating Product Between Sections |
|
||||
| Product appears in multiple contexts | GSAP Flip Cross-Section Morph |
|
||||
| Product "rises" from section boundary | Clip-Path Section Birth |
|
||||
| Cinematic "enter the world" feeling | DJI-Style Scale-In Pin |
|
||||
| Product travels a journey narrative | Curved Path Travel |
|
||||
| Elegant section-to-section transition | Section Peel Reveal |
|
||||
| Dark → light section transition | Floating Product (section backgrounds change beneath) |
|
||||
531
engineering-team/epic-design/references/motion-system.md
Normal file
531
engineering-team/epic-design/references/motion-system.md
Normal file
@@ -0,0 +1,531 @@
|
||||
# Motion System Reference
|
||||
|
||||
## Table of Contents
|
||||
1. [GSAP Setup & CDN](#gsap-setup)
|
||||
2. [Pattern 1: Multi-Layer Parallax](#pattern-1)
|
||||
3. [Pattern 2: Pinned Sticky Sections](#pattern-2)
|
||||
4. [Pattern 3: Cascading Card Stack](#pattern-3)
|
||||
5. [Pattern 4: Scrub Timeline](#pattern-4)
|
||||
6. [Pattern 5: Clip-Path Wipe Reveals](#pattern-5)
|
||||
7. [Pattern 6: Horizontal Scroll Conversion](#pattern-6)
|
||||
8. [Pattern 7: Perspective Zoom Fly-Through](#pattern-7)
|
||||
9. [Pattern 8: Snap-to-Section](#pattern-8)
|
||||
10. [Lenis Smooth Scroll](#lenis)
|
||||
11. [IntersectionObserver Activation](#intersection-observer)
|
||||
|
||||
---
|
||||
|
||||
## GSAP Setup & CDN {#gsap-setup}
|
||||
|
||||
Always load from jsDelivr CDN:
|
||||
|
||||
```html
|
||||
<!-- Core GSAP -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<!-- ScrollTrigger plugin — required for all scroll patterns -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||
<!-- ScrollSmoother — optional, pairs with ScrollTrigger -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollSmoother.min.js"></script>
|
||||
<!-- Flip plugin — for cross-section element morphing -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/Flip.min.js"></script>
|
||||
<!-- MotionPathPlugin — for curved element paths -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/MotionPathPlugin.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Always register plugins immediately
|
||||
gsap.registerPlugin(ScrollTrigger, Flip, MotionPathPlugin);
|
||||
|
||||
// Respect prefers-reduced-motion
|
||||
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
if (prefersReduced) {
|
||||
gsap.globalTimeline.timeScale(0); // Freeze all animations
|
||||
}
|
||||
</script>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 1: Multi-Layer Parallax {#pattern-1}
|
||||
|
||||
The foundation of all 2.5D depth. Different layers scroll at different speeds.
|
||||
|
||||
```javascript
|
||||
function initParallax() {
|
||||
const layers = document.querySelectorAll('[data-depth]');
|
||||
|
||||
const depthFactors = {
|
||||
'0': 0.10, '1': 0.25, '2': 0.50,
|
||||
'3': 0.80, '4': 1.00, '5': 1.20
|
||||
};
|
||||
|
||||
layers.forEach(layer => {
|
||||
const depth = layer.dataset.depth;
|
||||
const factor = depthFactors[depth] || 1.0;
|
||||
|
||||
gsap.to(layer, {
|
||||
yPercent: -15 * factor, // adjust multiplier for desired effect intensity
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: layer.closest('.scene'),
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: true, // 1:1 scroll-to-animation
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**When to use:** Every project. This is always on.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 2: Pinned Sticky Sections {#pattern-2}
|
||||
|
||||
A section stays fixed while its content animates. Other sections slide over/under it. The "window over window" effect.
|
||||
|
||||
```javascript
|
||||
function initPinnedSection(sceneEl) {
|
||||
// The section stays pinned for `duration` scroll pixels
|
||||
// while inner content animates on a scrubbed timeline
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sceneEl,
|
||||
start: 'top top',
|
||||
end: '+=150%', // stay pinned for 1.5x viewport of scroll
|
||||
pin: true, // THIS is what pins the section
|
||||
scrub: 1, // 1 second smoothing
|
||||
anticipatePin: 1, // prevents jump on pin
|
||||
}
|
||||
});
|
||||
|
||||
// Inner content animations while pinned
|
||||
// These play out over the scroll distance
|
||||
tl.from('.pinned-title', { opacity: 0, y: 60, duration: 0.3 })
|
||||
.from('.pinned-image', { scale: 0.8, opacity: 0, duration: 0.4 })
|
||||
.to('.pinned-bg', { backgroundColor: '#1a0a2e', duration: 0.3 })
|
||||
.from('.pinned-sub', { opacity: 0, x: -40, duration: 0.3 });
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
**Visual result:** Section feels like a chapter — the page "lives inside it" for a while, then moves on.
|
||||
|
||||
---
|
||||
|
||||
## Pattern 3: Cascading Card Stack {#pattern-3}
|
||||
|
||||
New sections slide over previous ones. Each buried section scales down and darkens, feeling like it's receding.
|
||||
|
||||
```css
|
||||
/* CSS Setup */
|
||||
.card-stack-section {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
/* Each subsequent section has higher z-index */
|
||||
}
|
||||
.card-stack-section:nth-child(1) { z-index: 1; }
|
||||
.card-stack-section:nth-child(2) { z-index: 2; }
|
||||
.card-stack-section:nth-child(3) { z-index: 3; }
|
||||
.card-stack-section:nth-child(4) { z-index: 4; }
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initCardStack() {
|
||||
const cards = gsap.utils.toArray('.card-stack-section');
|
||||
|
||||
cards.forEach((card, i) => {
|
||||
// Each card (except last) gets buried as next one enters
|
||||
if (i < cards.length - 1) {
|
||||
gsap.to(card, {
|
||||
scale: 0.88,
|
||||
filter: 'brightness(0.5) blur(3px)',
|
||||
borderRadius: '20px',
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: cards[i + 1], // fires when NEXT card enters
|
||||
start: 'top bottom',
|
||||
end: 'top top',
|
||||
scrub: true,
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 4: Scrub Timeline {#pattern-4}
|
||||
|
||||
The most powerful pattern. Elements transform EXACTLY in sync with scroll position. One pixel of scroll = one frame of animation.
|
||||
|
||||
```javascript
|
||||
function initScrubTimeline(sceneEl) {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sceneEl,
|
||||
start: 'top top',
|
||||
end: '+=200%',
|
||||
pin: true,
|
||||
scrub: 1.5, // 1.5s lag for smooth, dreamy feel (use 0 for precise 1:1)
|
||||
}
|
||||
});
|
||||
|
||||
// Sequences play out as user scrolls
|
||||
// 0.0 to 0.25 → first 25% of scroll
|
||||
tl.fromTo('.hero-product',
|
||||
{ scale: 0.6, opacity: 0, y: 100 },
|
||||
{ scale: 1, opacity: 1, y: 0, duration: 0.25 }
|
||||
)
|
||||
// 0.25 to 0.5 → second quarter
|
||||
.to('.hero-title span:first-child', {
|
||||
x: '-30vw', opacity: 0, duration: 0.25
|
||||
}, 0.25)
|
||||
.to('.hero-title span:last-child', {
|
||||
x: '30vw', opacity: 0, duration: 0.25
|
||||
}, 0.25)
|
||||
// 0.5 to 0.75 → third quarter
|
||||
.to('.hero-product', {
|
||||
scale: 1.3, y: -50, duration: 0.25
|
||||
}, 0.5)
|
||||
.fromTo('.next-section-content',
|
||||
{ opacity: 0, y: 80 },
|
||||
{ opacity: 1, y: 0, duration: 0.25 },
|
||||
0.5
|
||||
)
|
||||
// 0.75 to 1.0 → final quarter
|
||||
.to('.hero-product', {
|
||||
opacity: 0, scale: 1.6, duration: 0.25
|
||||
}, 0.75);
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 5: Clip-Path Wipe Reveals {#pattern-5}
|
||||
|
||||
Content is hidden behind a clip-path mask that animates away to reveal the content beneath. GPU-accelerated, buttery smooth.
|
||||
|
||||
```javascript
|
||||
// Left-to-right horizontal wipe
|
||||
function initHorizontalWipe(el) {
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: 'inset(0 100% 0 0)' },
|
||||
{
|
||||
clipPath: 'inset(0 0% 0 0)',
|
||||
duration: 1.2,
|
||||
ease: 'power3.out',
|
||||
scrollTrigger: { trigger: el, start: 'top 80%' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Top-to-bottom drop reveal
|
||||
function initTopDropReveal(el) {
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: 'inset(0 0 100% 0)' },
|
||||
{
|
||||
clipPath: 'inset(0 0 0% 0)',
|
||||
duration: 1.0,
|
||||
ease: 'power2.out',
|
||||
scrollTrigger: { trigger: el, start: 'top 75%' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Circle iris expand
|
||||
function initCircleIris(el) {
|
||||
gsap.fromTo(el,
|
||||
{ clipPath: 'circle(0% at 50% 50%)' },
|
||||
{
|
||||
clipPath: 'circle(75% at 50% 50%)',
|
||||
duration: 1.4,
|
||||
ease: 'power2.inOut',
|
||||
scrollTrigger: { trigger: el, start: 'top 60%' }
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// Window pane iris (tiny box expands to full)
|
||||
function initWindowPaneIris(sceneEl) {
|
||||
gsap.fromTo(sceneEl,
|
||||
{ clipPath: 'inset(45% 30% 45% 30% round 8px)' },
|
||||
{
|
||||
clipPath: 'inset(0% 0% 0% 0% round 0px)',
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: sceneEl,
|
||||
start: 'top 80%',
|
||||
end: 'top 20%',
|
||||
scrub: 1,
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 6: Horizontal Scroll Conversion {#pattern-6}
|
||||
|
||||
Vertical scrolling drives horizontal movement through panels. Classic premium technique.
|
||||
|
||||
```javascript
|
||||
function initHorizontalScroll(containerEl) {
|
||||
const panels = gsap.utils.toArray('.h-panel', containerEl);
|
||||
|
||||
gsap.to(panels, {
|
||||
xPercent: -100 * (panels.length - 1),
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: containerEl,
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
end: () => `+=${containerEl.offsetWidth * (panels.length - 1)}`,
|
||||
snap: 1 / (panels.length - 1), // auto-snap to each panel
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.h-scroll-container {
|
||||
display: flex;
|
||||
width: calc(300vw); /* 3 panels × 100vw */
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
.h-panel {
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 7: Perspective Zoom Fly-Through {#pattern-7}
|
||||
|
||||
User appears to fly toward content. Combines scale, Z-axis, and opacity on a scrubbed pin.
|
||||
|
||||
```javascript
|
||||
function initPerspectiveZoom(sceneEl) {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: sceneEl,
|
||||
start: 'top top',
|
||||
end: '+=300%',
|
||||
pin: true,
|
||||
scrub: 2,
|
||||
}
|
||||
});
|
||||
|
||||
// Background "rushes toward" viewer
|
||||
tl.fromTo('.zoom-bg',
|
||||
{ scale: 0.4, filter: 'blur(20px)', opacity: 0.3 },
|
||||
{ scale: 1.2, filter: 'blur(0px)', opacity: 1, duration: 0.6 }
|
||||
)
|
||||
// Product appears from far
|
||||
.fromTo('.zoom-product',
|
||||
{ scale: 0.1, z: -2000, opacity: 0 },
|
||||
{ scale: 1, z: 0, opacity: 1, duration: 0.5, ease: 'power2.out' },
|
||||
0.2
|
||||
)
|
||||
// Text fades in after product arrives
|
||||
.fromTo('.zoom-title',
|
||||
{ opacity: 0, letterSpacing: '2em' },
|
||||
{ opacity: 1, letterSpacing: '0.05em', duration: 0.3 },
|
||||
0.55
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
.zoom-scene {
|
||||
perspective: 1200px;
|
||||
perspective-origin: 50% 50%;
|
||||
transform-style: preserve-3d;
|
||||
overflow: hidden;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 8: Snap-to-Section {#pattern-8}
|
||||
|
||||
Full-page scroll snapping between sections — creates a chapter-like book feeling.
|
||||
|
||||
```javascript
|
||||
// Using GSAP Observer for smooth snapping
|
||||
function initSectionSnap() {
|
||||
// Register Observer plugin
|
||||
gsap.registerPlugin(Observer);
|
||||
|
||||
const sections = gsap.utils.toArray('.snap-section');
|
||||
let currentIndex = 0;
|
||||
let animating = false;
|
||||
|
||||
function goTo(index) {
|
||||
if (animating || index === currentIndex) return;
|
||||
animating = true;
|
||||
|
||||
const direction = index > currentIndex ? 1 : -1;
|
||||
const current = sections[currentIndex];
|
||||
const next = sections[index];
|
||||
|
||||
const tl = gsap.timeline({
|
||||
onComplete: () => {
|
||||
currentIndex = index;
|
||||
animating = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Current section exits upward
|
||||
tl.to(current, {
|
||||
yPercent: -100 * direction,
|
||||
opacity: 0,
|
||||
duration: 0.8,
|
||||
ease: 'power2.inOut'
|
||||
})
|
||||
// Next section enters from below/above
|
||||
.fromTo(next,
|
||||
{ yPercent: 100 * direction, opacity: 0 },
|
||||
{ yPercent: 0, opacity: 1, duration: 0.8, ease: 'power2.inOut' },
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
Observer.create({
|
||||
type: 'wheel,touch',
|
||||
onDown: () => goTo(Math.min(currentIndex + 1, sections.length - 1)),
|
||||
onUp: () => goTo(Math.max(currentIndex - 1, 0)),
|
||||
tolerance: 100,
|
||||
preventDefault: true,
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Lenis Smooth Scroll {#lenis}
|
||||
|
||||
Lenis replaces native browser scroll with silky-smooth physics-based scrolling. Always pair with GSAP ScrollTrigger.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/@studio-freight/lenis@1.0.45/dist/lenis.min.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initLenis() {
|
||||
const lenis = new Lenis({
|
||||
duration: 1.2,
|
||||
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
|
||||
orientation: 'vertical',
|
||||
smoothWheel: true,
|
||||
});
|
||||
|
||||
// CRITICAL: Connect Lenis to GSAP ticker
|
||||
lenis.on('scroll', ScrollTrigger.update);
|
||||
gsap.ticker.add((time) => lenis.raf(time * 1000));
|
||||
gsap.ticker.lagSmoothing(0);
|
||||
|
||||
return lenis;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## IntersectionObserver Activation {#intersection-observer}
|
||||
|
||||
Only animate elements that are currently visible. Critical for performance.
|
||||
|
||||
```javascript
|
||||
function initRevealObserver() {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
entry.target.classList.add('is-visible');
|
||||
// Trigger GSAP animation
|
||||
const animType = entry.target.dataset.animate;
|
||||
if (animType) triggerAnimation(entry.target, animType);
|
||||
// Stop observing after first trigger
|
||||
observer.unobserve(entry.target);
|
||||
}
|
||||
});
|
||||
}, {
|
||||
threshold: 0.15,
|
||||
rootMargin: '0px 0px -50px 0px'
|
||||
});
|
||||
|
||||
document.querySelectorAll('[data-animate]').forEach(el => observer.observe(el));
|
||||
}
|
||||
|
||||
function triggerAnimation(el, type) {
|
||||
const animations = {
|
||||
'fade-up': () => gsap.from(el, { y: 60, opacity: 0, duration: 0.8, ease: 'power3.out' }),
|
||||
'fade-in': () => gsap.from(el, { opacity: 0, duration: 1.0, ease: 'power2.out' }),
|
||||
'scale-in': () => gsap.from(el, { scale: 0.8, opacity: 0, duration: 0.7, ease: 'back.out(1.7)' }),
|
||||
'slide-left': () => gsap.from(el, { x: -80, opacity: 0, duration: 0.8, ease: 'power3.out' }),
|
||||
'slide-right':() => gsap.from(el, { x: 80, opacity: 0, duration: 0.8, ease: 'power3.out' }),
|
||||
'converge': () => animateSplitConverge(el), // See text-animations.md
|
||||
};
|
||||
animations[type]?.();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Pattern 9: Elastic Drop with Impact Shake {#elastic-drop}
|
||||
|
||||
An element falls from above with an elastic overshoot, then a rapid
|
||||
micro-rotation shake fires on landing — simulating physical weight and impact.
|
||||
|
||||
```javascript
|
||||
function initElasticDrop(productEl, wrapperEl) {
|
||||
const tl = gsap.timeline({ delay: 0.3 });
|
||||
|
||||
// Phase 1: element drops with elastic bounce
|
||||
tl.from(productEl, {
|
||||
y: -180,
|
||||
opacity: 0,
|
||||
scale: 1.1,
|
||||
duration: 1.3,
|
||||
ease: 'elastic.out(1, 0.65)',
|
||||
})
|
||||
|
||||
// Phase 2: shake fires just as the elastic settles
|
||||
// Apply to the WRAPPER not the element — avoids transform conflicts
|
||||
.to(wrapperEl, {
|
||||
keyframes: [
|
||||
{ rotation: -2, duration: 0.08 },
|
||||
{ rotation: 2, duration: 0.08 },
|
||||
{ rotation: -1.5, duration: 0.07 },
|
||||
{ rotation: 1, duration: 0.07 },
|
||||
{ rotation: 0, duration: 0.10 },
|
||||
],
|
||||
ease: 'power1.inOut',
|
||||
}, '-=0.35');
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
```html
|
||||
<!-- Wrapper and product must be separate elements -->
|
||||
<div class="drop-wrapper" id="dropWrapper">
|
||||
<img class="drop-product" id="dropProduct" src="product.png" alt="..." />
|
||||
</div>
|
||||
```
|
||||
|
||||
Ease variants:
|
||||
- `elastic.out(1, 0.65)` — standard product, moderate bounce
|
||||
- `elastic.out(1.2, 0.5)` — heavier object, more overshoot
|
||||
- `elastic.out(0.8, 0.8)` — lighter, quicker settle
|
||||
- `back.out(2.5)` — no oscillation, one clean overshoot
|
||||
|
||||
Do NOT use for: gentle floaters, airy elements (flowers, feathers) — use `power3.out` instead.
|
||||
261
engineering-team/epic-design/references/performance.md
Normal file
261
engineering-team/epic-design/references/performance.md
Normal file
@@ -0,0 +1,261 @@
|
||||
# Performance Reference
|
||||
|
||||
## The Golden Rule
|
||||
|
||||
**Only animate properties that the browser can handle on the GPU compositor thread:**
|
||||
|
||||
```
|
||||
✅ SAFE (GPU composited): transform, opacity, filter, clip-path, will-change
|
||||
❌ AVOID (triggers layout): width, height, top, left, right, bottom, margin, padding,
|
||||
font-size, border-width, background-size (avoid)
|
||||
```
|
||||
|
||||
Animating layout properties causes the browser to recalculate the entire page layout on every frame — this is called "layout thrash" and causes jank.
|
||||
|
||||
---
|
||||
|
||||
## requestAnimationFrame Pattern
|
||||
|
||||
Never put animation logic directly in event listeners. Always batch through rAF:
|
||||
|
||||
```javascript
|
||||
let rafId = null;
|
||||
let pendingScrollY = 0;
|
||||
|
||||
function onScroll() {
|
||||
pendingScrollY = window.scrollY;
|
||||
if (!rafId) {
|
||||
rafId = requestAnimationFrame(processScroll);
|
||||
}
|
||||
}
|
||||
|
||||
function processScroll() {
|
||||
rafId = null;
|
||||
document.documentElement.style.setProperty('--scroll-y', pendingScrollY);
|
||||
// update other values...
|
||||
}
|
||||
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
// passive: true is CRITICAL — tells browser scroll handler won't preventDefault
|
||||
// allows browser to scroll on a separate thread
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## will-change Usage Rules
|
||||
|
||||
`will-change` promotes an element to its own GPU layer. Powerful but dangerous if overused.
|
||||
|
||||
```css
|
||||
/* DO: Only apply when animation is about to start */
|
||||
.element-about-to-animate {
|
||||
will-change: transform, opacity;
|
||||
}
|
||||
|
||||
/* DO: Remove after animation completes */
|
||||
element.addEventListener('animationend', () => {
|
||||
element.style.willChange = 'auto';
|
||||
});
|
||||
|
||||
/* DON'T: Apply globally */
|
||||
* { will-change: transform; } /* WRONG — massive GPU memory usage */
|
||||
|
||||
/* DON'T: Apply statically on all animated elements */
|
||||
.animated-thing { will-change: transform; } /* Wrong if there are many of these */
|
||||
```
|
||||
|
||||
### GSAP handles this automatically
|
||||
GSAP applies `will-change` during animations and removes it after. If using GSAP, you generally don't need to manage `will-change` yourself.
|
||||
|
||||
---
|
||||
|
||||
## IntersectionObserver Pattern
|
||||
|
||||
Never animate all elements all the time. Only animate what's currently visible.
|
||||
|
||||
```javascript
|
||||
class AnimationManager {
|
||||
constructor() {
|
||||
this.activeAnimations = new Set();
|
||||
this.observer = new IntersectionObserver(
|
||||
this.handleIntersection.bind(this),
|
||||
{ threshold: 0.1, rootMargin: '50px 0px' }
|
||||
);
|
||||
}
|
||||
|
||||
observe(el) {
|
||||
this.observer.observe(el);
|
||||
}
|
||||
|
||||
handleIntersection(entries) {
|
||||
entries.forEach(entry => {
|
||||
if (entry.isIntersecting) {
|
||||
this.activateElement(entry.target);
|
||||
} else {
|
||||
this.deactivateElement(entry.target);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
activateElement(el) {
|
||||
// Start GSAP animation / add floating class
|
||||
el.classList.add('animate-active');
|
||||
this.activeAnimations.add(el);
|
||||
}
|
||||
|
||||
deactivateElement(el) {
|
||||
// Pause or stop animation
|
||||
el.classList.remove('animate-active');
|
||||
this.activeAnimations.delete(el);
|
||||
}
|
||||
}
|
||||
|
||||
const animManager = new AnimationManager();
|
||||
document.querySelectorAll('.animated-layer').forEach(el => animManager.observe(el));
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## content-visibility: auto
|
||||
|
||||
For pages with many off-screen sections, this dramatically improves initial load and scroll performance:
|
||||
|
||||
```css
|
||||
/* Apply to every major section except the first (which is immediately visible) */
|
||||
.scene:not(:first-child) {
|
||||
content-visibility: auto;
|
||||
/* Tells browser: don't render this until it's near the viewport */
|
||||
contain-intrinsic-size: 0 100vh;
|
||||
/* Gives browser an estimated height so scrollbar is correct */
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Don't apply to the first section — it causes a flash of invisible content.
|
||||
|
||||
---
|
||||
|
||||
## Asset Optimization Rules
|
||||
|
||||
### PNG File Size Targets (Maximum)
|
||||
|
||||
| Depth Level | Element Type | Max File Size | Max Dimensions |
|
||||
|-------------|---------------------|---------------|----------------|
|
||||
| Depth 0 | Background | 150KB | 1920×1080 |
|
||||
| Depth 1 | Glow layer | 60KB | 1000×1000 |
|
||||
| Depth 2 | Decorations | 50KB | 400×400 |
|
||||
| Depth 3 | Main product/hero | 120KB | 1200×1200 |
|
||||
| Depth 4 | UI components | 40KB | 800×800 |
|
||||
| Depth 5 | Particles | 10KB | 128×128 |
|
||||
|
||||
**Total page weight target: Under 2MB for all assets combined.**
|
||||
|
||||
### Image Loading Strategy
|
||||
|
||||
```html
|
||||
<!-- Hero image: preload immediately -->
|
||||
<link rel="preload" as="image" href="hero-product.png">
|
||||
|
||||
<!-- Above-fold images: eager loading -->
|
||||
<img src="hero-bg.png" loading="eager" fetchpriority="high" alt="">
|
||||
|
||||
<!-- Below-fold images: lazy loading -->
|
||||
<img src="section-2-bg.png" loading="lazy" alt="">
|
||||
|
||||
<!-- Use srcset for responsive images -->
|
||||
<img
|
||||
src="product-800.png"
|
||||
srcset="product-400.png 400w, product-800.png 800w, product-1200.png 1200w"
|
||||
sizes="(max-width: 768px) 100vw, 50vw"
|
||||
alt="Product description"
|
||||
loading="eager"
|
||||
>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Mobile Performance
|
||||
|
||||
Touch devices have less GPU power. Always detect and reduce effects:
|
||||
|
||||
```javascript
|
||||
const isTouchDevice = window.matchMedia('(pointer: coarse)').matches;
|
||||
const prefersReduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
const isLowPower = navigator.hardwareConcurrency <= 4; // heuristic for low-end devices
|
||||
|
||||
const performanceMode = (isTouchDevice || prefersReduced || isLowPower) ? 'lite' : 'full';
|
||||
|
||||
function initForPerformanceMode() {
|
||||
if (performanceMode === 'lite') {
|
||||
// Disable: mouse tracking, floating loops, particles, perspective zoom
|
||||
document.documentElement.classList.add('perf-lite');
|
||||
// Keep: basic scroll fade-ins, curtain reveals (CSS only)
|
||||
} else {
|
||||
// Full experience
|
||||
initParallaxLayers();
|
||||
initFloatingLoops();
|
||||
initParticles();
|
||||
initMouseTracking();
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
```css
|
||||
/* Disable GPU-heavy effects in lite mode */
|
||||
.perf-lite .depth-0,
|
||||
.perf-lite .depth-1,
|
||||
.perf-lite .depth-5 {
|
||||
transform: none !important;
|
||||
will-change: auto !important;
|
||||
}
|
||||
.perf-lite .float-loop {
|
||||
animation: none !important;
|
||||
}
|
||||
.perf-lite .glow-blob {
|
||||
display: none;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Chrome DevTools Performance Checklist
|
||||
|
||||
Before shipping, verify:
|
||||
|
||||
1. **Layers panel**: Check `chrome://settings` → DevTools → "Show Composited Layer Borders" — should not show excessive layer count (target: under 20 promoted layers)
|
||||
2. **Performance tab**: Record scroll at 60fps. Look for long frames (>16ms)
|
||||
3. **Memory tab**: Heap snapshot — should not grow during scroll (no leaks)
|
||||
4. **Coverage tab**: Check unused CSS/JS — strip unused animation classes
|
||||
|
||||
---
|
||||
|
||||
## GSAP Performance Tips
|
||||
|
||||
```javascript
|
||||
// BAD: Creates new tween every scroll event
|
||||
window.addEventListener('scroll', () => {
|
||||
gsap.to(element, { y: window.scrollY * 0.5 }); // creates new tween each frame!
|
||||
});
|
||||
|
||||
// GOOD: Use scrub — GSAP manages timing internally
|
||||
gsap.to(element, {
|
||||
y: 200,
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
scrub: true, // GSAP handles this efficiently
|
||||
}
|
||||
});
|
||||
|
||||
// GOOD: Kill ScrollTriggers when not needed
|
||||
const trigger = ScrollTrigger.create({ ... });
|
||||
// Later:
|
||||
trigger.kill();
|
||||
|
||||
// GOOD: Use gsap.set() for instant placement (no tween overhead)
|
||||
gsap.set('.element', { x: 0, opacity: 1 });
|
||||
|
||||
// GOOD: Batch DOM reads/writes
|
||||
gsap.utils.toArray('.elements').forEach(el => {
|
||||
// GSAP batches these reads automatically
|
||||
gsap.from(el, { ... });
|
||||
});
|
||||
```
|
||||
709
engineering-team/epic-design/references/text-animations.md
Normal file
709
engineering-team/epic-design/references/text-animations.md
Normal file
@@ -0,0 +1,709 @@
|
||||
# Text Animation Reference
|
||||
|
||||
## Table of Contents
|
||||
1. [Setup: SplitText & Dependencies](#setup)
|
||||
2. [Technique 1: Split Converge (Left+Right Merge)](#split-converge)
|
||||
3. [Technique 2: Masked Line Curtain Reveal](#masked-line)
|
||||
4. [Technique 3: Character Cylinder Rotation](#cylinder)
|
||||
5. [Technique 4: Word-by-Word Scroll Lighting](#word-lighting)
|
||||
6. [Technique 5: Scramble Text](#scramble)
|
||||
7. [Technique 6: Skew + Elastic Bounce Entry](#skew-bounce)
|
||||
8. [Technique 7: Theatrical Enter + Auto Exit](#theatrical)
|
||||
9. [Technique 8: Offset Diagonal Layout](#offset-diagonal)
|
||||
10. [Technique 9: Line Clip Wipe](#line-clip-wipe)
|
||||
11. [Technique 10: Scroll-Speed Reactive Marquee](#marquee)
|
||||
12. [Technique 11: Variable Font Wave](#variable-font)
|
||||
13. [Technique 12: Bleed Typography](#bleed-type)
|
||||
|
||||
---
|
||||
|
||||
## Setup: SplitText & Dependencies {#setup}
|
||||
|
||||
```html
|
||||
<!-- GSAP SplitText (free in GSAP 3.12+) -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/SplitText.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/ScrollTrigger.min.js"></script>
|
||||
<script>
|
||||
gsap.registerPlugin(SplitText, ScrollTrigger);
|
||||
</script>
|
||||
```
|
||||
|
||||
### Universal Text Setup CSS
|
||||
|
||||
```css
|
||||
/* All text elements that animate need this */
|
||||
.anim-text {
|
||||
overflow: hidden; /* Contains line mask reveals */
|
||||
line-height: 1.15;
|
||||
}
|
||||
/* Screen reader: preserve meaning even when SplitText fragments it */
|
||||
.anim-text[aria-label] > * {
|
||||
aria-hidden: true;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 1: Split Converge (Left+Right Merge) {#split-converge}
|
||||
|
||||
The signature effect: two halves of a title fly in from opposite sides, converge to form the complete title, hold, then diverge and disappear on scroll exit. Exactly what the user described.
|
||||
|
||||
```css
|
||||
.hero-title {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25em;
|
||||
overflow: visible; /* allow parts to fly from outside viewport */
|
||||
}
|
||||
.hero-title .word-left {
|
||||
display: inline-block;
|
||||
/* starts at far left */
|
||||
}
|
||||
.hero-title .word-right {
|
||||
display: inline-block;
|
||||
/* starts at far right */
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initSplitConverge(titleEl) {
|
||||
// Preserve accessibility
|
||||
const fullText = titleEl.textContent;
|
||||
titleEl.setAttribute('aria-label', fullText);
|
||||
|
||||
const words = titleEl.querySelectorAll('.word');
|
||||
const midpoint = Math.floor(words.length / 2);
|
||||
|
||||
const leftWords = Array.from(words).slice(0, midpoint);
|
||||
const rightWords = Array.from(words).slice(midpoint);
|
||||
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: titleEl.closest('.scene'),
|
||||
start: 'top top',
|
||||
end: '+=250%',
|
||||
pin: true,
|
||||
scrub: 1.2,
|
||||
}
|
||||
});
|
||||
|
||||
// Phase 1 — ENTER (0% → 25%): Words converge from sides
|
||||
tl.fromTo(leftWords,
|
||||
{ x: '-120vw', opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: 0.03 },
|
||||
0
|
||||
)
|
||||
.fromTo(rightWords,
|
||||
{ x: '120vw', opacity: 0 },
|
||||
{ x: 0, opacity: 1, duration: 0.25, ease: 'power3.out', stagger: -0.03 },
|
||||
0
|
||||
)
|
||||
|
||||
// Phase 2 — HOLD (25% → 70%): Nothing — words are readable, section pinned
|
||||
// (empty duration keeps the scrub paused here)
|
||||
.to({}, { duration: 0.45 }, 0.25)
|
||||
|
||||
// Phase 3 — EXIT (70% → 100%): Words diverge back out
|
||||
.to(leftWords,
|
||||
{ x: '-120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: 0.02 },
|
||||
0.70
|
||||
)
|
||||
.to(rightWords,
|
||||
{ x: '120vw', opacity: 0, duration: 0.28, ease: 'power3.in', stagger: -0.02 },
|
||||
0.70
|
||||
);
|
||||
|
||||
return tl;
|
||||
}
|
||||
```
|
||||
|
||||
### HTML Template
|
||||
|
||||
```html
|
||||
<h1 class="hero-title anim-text" aria-label="Your Brand Name">
|
||||
<span class="word word-left">Your</span>
|
||||
<span class="word word-left">Brand</span>
|
||||
<span class="word word-right">Name</span>
|
||||
<span class="word word-right">Here</span>
|
||||
</h1>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 2: Masked Line Curtain Reveal {#masked-line}
|
||||
|
||||
Lines slide upward from behind an invisible curtain. Each line is hidden in an `overflow: hidden` container and translates up into view.
|
||||
|
||||
```css
|
||||
.curtain-text .line-mask {
|
||||
overflow: hidden;
|
||||
line-height: 1.2;
|
||||
/* The mask — content starts below and slides up into view */
|
||||
}
|
||||
.curtain-text .line-inner {
|
||||
display: block;
|
||||
/* Starts translated down below the mask */
|
||||
transform: translateY(110%);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initCurtainReveal(textEl) {
|
||||
// SplitText splits into lines automatically
|
||||
const split = new SplitText(textEl, {
|
||||
type: 'lines',
|
||||
linesClass: 'line-inner',
|
||||
// Wraps each line in overflow:hidden container
|
||||
lineThreshold: 0.1,
|
||||
});
|
||||
|
||||
// Wrap each line in a mask container
|
||||
split.lines.forEach(line => {
|
||||
const mask = document.createElement('div');
|
||||
mask.className = 'line-mask';
|
||||
line.parentNode.insertBefore(mask, line);
|
||||
mask.appendChild(line);
|
||||
});
|
||||
|
||||
gsap.from(split.lines, {
|
||||
y: '110%',
|
||||
duration: 0.9,
|
||||
ease: 'power4.out',
|
||||
stagger: 0.12,
|
||||
scrollTrigger: {
|
||||
trigger: textEl,
|
||||
start: 'top 80%',
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 3: Character Cylinder Rotation {#cylinder}
|
||||
|
||||
Letters rotate in on a 3D cylinder axis — like a slot machine or odometer rolling into place. Premium, memorable.
|
||||
|
||||
```css
|
||||
.cylinder-text {
|
||||
perspective: 800px;
|
||||
}
|
||||
.cylinder-text .char {
|
||||
display: inline-block;
|
||||
transform-origin: center center -60px; /* pivot point BEHIND the letter */
|
||||
transform-style: preserve-3d;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initCylinderRotation(titleEl) {
|
||||
const split = new SplitText(titleEl, { type: 'chars' });
|
||||
|
||||
gsap.from(split.chars, {
|
||||
rotateX: -90,
|
||||
opacity: 0,
|
||||
duration: 0.6,
|
||||
ease: 'back.out(1.5)',
|
||||
stagger: {
|
||||
each: 0.04,
|
||||
from: 'start'
|
||||
},
|
||||
scrollTrigger: {
|
||||
trigger: titleEl,
|
||||
start: 'top 75%',
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 4: Word-by-Word Scroll Lighting {#word-lighting}
|
||||
|
||||
Words appear to light up one at a time, driven by scroll position. Apple's signature prose technique.
|
||||
|
||||
```css
|
||||
.scroll-lit-text {
|
||||
/* Start all words dim */
|
||||
}
|
||||
.scroll-lit-text .word {
|
||||
display: inline-block;
|
||||
color: rgba(255, 255, 255, 0.15); /* dim unlit state */
|
||||
transition: color 0.1s ease;
|
||||
}
|
||||
.scroll-lit-text .word.lit {
|
||||
color: rgba(255, 255, 255, 1.0); /* bright lit state */
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initWordScrollLighting(containerEl, textEl) {
|
||||
const split = new SplitText(textEl, { type: 'words' });
|
||||
const words = split.words;
|
||||
const totalWords = words.length;
|
||||
|
||||
// Pin the section and light words as user scrolls
|
||||
ScrollTrigger.create({
|
||||
trigger: containerEl,
|
||||
start: 'top top',
|
||||
end: `+=${totalWords * 80}px`, // ~80px per word
|
||||
pin: true,
|
||||
scrub: 0.5,
|
||||
onUpdate: (self) => {
|
||||
const progress = self.progress;
|
||||
const litCount = Math.round(progress * totalWords);
|
||||
words.forEach((word, i) => {
|
||||
word.classList.toggle('lit', i < litCount);
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 5: Scramble Text {#scramble}
|
||||
|
||||
Characters cycle through random values before resolving to real text. Feels digital, techy, premium.
|
||||
|
||||
```html
|
||||
<script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/TextPlugin.min.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Custom scramble implementation (no plugin needed)
|
||||
function scrambleText(el, finalText, duration = 1.5) {
|
||||
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%';
|
||||
let startTime = null;
|
||||
const originalText = finalText;
|
||||
|
||||
function step(timestamp) {
|
||||
if (!startTime) startTime = timestamp;
|
||||
const progress = Math.min((timestamp - startTime) / (duration * 1000), 1);
|
||||
|
||||
let result = '';
|
||||
for (let i = 0; i < originalText.length; i++) {
|
||||
if (originalText[i] === ' ') {
|
||||
result += ' ';
|
||||
} else if (i / originalText.length < progress) {
|
||||
// This character has resolved
|
||||
result += originalText[i];
|
||||
} else {
|
||||
// Still scrambling
|
||||
result += chars[Math.floor(Math.random() * chars.length)];
|
||||
}
|
||||
}
|
||||
el.textContent = result;
|
||||
|
||||
if (progress < 1) requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
// Trigger on scroll
|
||||
ScrollTrigger.create({
|
||||
trigger: '.scramble-title',
|
||||
start: 'top 80%',
|
||||
once: true,
|
||||
onEnter: () => {
|
||||
scrambleText(
|
||||
document.querySelector('.scramble-title'),
|
||||
document.querySelector('.scramble-title').dataset.text,
|
||||
1.8
|
||||
);
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 6: Skew + Elastic Bounce Entry {#skew-bounce}
|
||||
|
||||
Elements enter with a skew that corrects itself, combined with a slight overshoot. Feels physical and energetic.
|
||||
|
||||
```javascript
|
||||
function initSkewBounce(elements) {
|
||||
gsap.from(elements, {
|
||||
y: 80,
|
||||
skewY: 7,
|
||||
opacity: 0,
|
||||
duration: 0.9,
|
||||
ease: 'back.out(1.7)',
|
||||
stagger: 0.1,
|
||||
scrollTrigger: {
|
||||
trigger: elements[0],
|
||||
start: 'top 85%',
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 7: Theatrical Enter + Auto Exit {#theatrical}
|
||||
|
||||
Element automatically animates in when entering the viewport AND animates out when leaving — zero JavaScript needed.
|
||||
|
||||
```css
|
||||
/* Enter animation */
|
||||
@keyframes theatrical-enter {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(60px);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
filter: blur(0px);
|
||||
}
|
||||
}
|
||||
|
||||
/* Exit animation */
|
||||
@keyframes theatrical-exit {
|
||||
from {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
to {
|
||||
opacity: 0;
|
||||
transform: translateY(-60px);
|
||||
}
|
||||
}
|
||||
|
||||
.theatrical {
|
||||
/* Enter when element comes into view */
|
||||
animation: theatrical-enter linear both;
|
||||
animation-timeline: view();
|
||||
animation-range: entry 0% entry 40%;
|
||||
}
|
||||
|
||||
.theatrical-with-exit {
|
||||
animation: theatrical-enter linear both, theatrical-exit linear both;
|
||||
animation-timeline: view(), view();
|
||||
animation-range: entry 0% entry 30%, exit 60% exit 100%;
|
||||
}
|
||||
```
|
||||
|
||||
**Zero JavaScript required.** Just add `.theatrical` or `.theatrical-with-exit` class.
|
||||
|
||||
---
|
||||
|
||||
## Technique 8: Offset Diagonal Layout {#offset-diagonal}
|
||||
|
||||
Lines of a title start at offset positions (one top-left, one lower-right), then animate FROM their natural offset positions FROM opposite directions. Creates a staircase visual composition that feels dynamic even before animation.
|
||||
|
||||
```css
|
||||
.offset-title {
|
||||
position: relative;
|
||||
/* Don't center — let offset do the work */
|
||||
}
|
||||
.offset-title .line-1 {
|
||||
/* Top-left */
|
||||
display: block;
|
||||
text-align: left;
|
||||
padding-left: 5%;
|
||||
font-size: clamp(48px, 8vw, 100px);
|
||||
}
|
||||
.offset-title .line-2 {
|
||||
/* Lower-right — drops down and shifts right */
|
||||
display: block;
|
||||
text-align: right;
|
||||
padding-right: 5%;
|
||||
margin-top: 0.4em;
|
||||
font-size: clamp(48px, 8vw, 100px);
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initOffsetDiagonal(titleEl) {
|
||||
const line1 = titleEl.querySelector('.line-1');
|
||||
const line2 = titleEl.querySelector('.line-2');
|
||||
|
||||
gsap.from(line1, {
|
||||
x: '-15vw',
|
||||
opacity: 0,
|
||||
duration: 1.0,
|
||||
ease: 'power4.out',
|
||||
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
|
||||
});
|
||||
|
||||
gsap.from(line2, {
|
||||
x: '15vw',
|
||||
opacity: 0,
|
||||
duration: 1.0,
|
||||
ease: 'power4.out',
|
||||
delay: 0.15,
|
||||
scrollTrigger: { trigger: titleEl, start: 'top 75%' }
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 9: Line Clip Wipe {#line-clip-wipe}
|
||||
|
||||
Each line of text reveals from left to right, like a typewriter but with a clean clip-path sweep.
|
||||
|
||||
```javascript
|
||||
function initLineClipWipe(textEl) {
|
||||
const split = new SplitText(textEl, { type: 'lines' });
|
||||
|
||||
split.lines.forEach((line, i) => {
|
||||
gsap.fromTo(line,
|
||||
{ clipPath: 'inset(0 100% 0 0)' },
|
||||
{
|
||||
clipPath: 'inset(0 0% 0 0)',
|
||||
duration: 0.8,
|
||||
ease: 'power3.out',
|
||||
delay: i * 0.12, // stagger between lines
|
||||
scrollTrigger: {
|
||||
trigger: textEl,
|
||||
start: 'top 80%',
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 10: Scroll-Speed Reactive Marquee {#marquee}
|
||||
|
||||
Infinite scrolling text. Speed scales with scroll velocity — fast scroll = fast marquee. Slow scroll = slow/paused.
|
||||
|
||||
```css
|
||||
.marquee-wrapper {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.marquee-track {
|
||||
display: inline-flex;
|
||||
gap: 4rem;
|
||||
/* Two copies side by side for seamless loop */
|
||||
}
|
||||
.marquee-track .marquee-item {
|
||||
display: inline-block;
|
||||
font-size: clamp(2rem, 5vw, 5rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
function initReactiveMarquee(wrapperEl) {
|
||||
const track = wrapperEl.querySelector('.marquee-track');
|
||||
let currentX = 0;
|
||||
let velocity = 0;
|
||||
let baseSpeed = 0.8; // px per frame base speed
|
||||
let lastScrollY = window.scrollY;
|
||||
let lastTime = performance.now();
|
||||
|
||||
// Track scroll velocity
|
||||
window.addEventListener('scroll', () => {
|
||||
const now = performance.now();
|
||||
const dt = now - lastTime;
|
||||
const dy = window.scrollY - lastScrollY;
|
||||
velocity = Math.abs(dy / dt) * 30; // scale to marquee speed
|
||||
lastScrollY = window.scrollY;
|
||||
lastTime = now;
|
||||
}, { passive: true });
|
||||
|
||||
function animate() {
|
||||
velocity = Math.max(0, velocity - 0.3); // decay
|
||||
const speed = baseSpeed + velocity;
|
||||
currentX -= speed;
|
||||
|
||||
// Reset when first copy exits viewport
|
||||
const trackWidth = track.children[0].offsetWidth * track.children.length / 2;
|
||||
if (Math.abs(currentX) >= trackWidth) {
|
||||
currentX += trackWidth;
|
||||
}
|
||||
|
||||
track.style.transform = `translateX(${currentX}px)`;
|
||||
requestAnimationFrame(animate);
|
||||
}
|
||||
animate();
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 11: Variable Font Wave {#variable-font}
|
||||
|
||||
If the font supports variable axes (weight, width), animate them per-character for a wave/ripple effect.
|
||||
|
||||
```javascript
|
||||
function initVariableFontWave(titleEl) {
|
||||
const split = new SplitText(titleEl, { type: 'chars' });
|
||||
|
||||
// Wave through characters using weight axis
|
||||
gsap.to(split.chars, {
|
||||
fontVariationSettings: '"wght" 800',
|
||||
duration: 0.4,
|
||||
ease: 'power2.inOut',
|
||||
stagger: {
|
||||
each: 0.06,
|
||||
yoyo: true,
|
||||
repeat: -1, // infinite loop
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Note:** Requires a variable font. Free options: Inter Variable, Fraunces, Recursive. Load from Google Fonts with `?display=swap&axes=wght`.
|
||||
|
||||
---
|
||||
|
||||
## Technique 12: Bleed Typography {#bleed-type}
|
||||
|
||||
Oversized headline that intentionally exceeds section boundaries. Creates drama, depth, and visual tension.
|
||||
|
||||
```css
|
||||
.bleed-title {
|
||||
font-size: clamp(80px, 18vw, 220px);
|
||||
font-weight: 900;
|
||||
line-height: 0.9;
|
||||
letter-spacing: -0.04em;
|
||||
|
||||
/* Allow bleeding outside section */
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
|
||||
/* Negative margins to bleed out */
|
||||
margin-left: -0.05em;
|
||||
margin-right: -0.05em;
|
||||
|
||||
/* Optionally: half above, half below section boundary */
|
||||
transform: translateY(30%);
|
||||
}
|
||||
|
||||
/* Parent section allows overflow */
|
||||
.bleed-section {
|
||||
overflow: visible;
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
/* Next section needs to be higher to "trap" the bleed */
|
||||
.bleed-section + .next-section {
|
||||
position: relative;
|
||||
z-index: 3;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Parallax on the bleed title — moves at slightly different rate
|
||||
// to emphasize that it belongs to a different depth than content
|
||||
gsap.to('.bleed-title', {
|
||||
y: '-12%',
|
||||
ease: 'none',
|
||||
scrollTrigger: {
|
||||
trigger: '.bleed-section',
|
||||
start: 'top bottom',
|
||||
end: 'bottom top',
|
||||
scrub: true,
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Technique 13: Ghost Outlined Background Text {#ghost-text}
|
||||
|
||||
Massive atmospheric text sitting BEHIND the main product using only a thin stroke
|
||||
with transparent fill. Supports the scene without competing with the content.
|
||||
|
||||
```css
|
||||
.ghost-bg-text {
|
||||
color: transparent;
|
||||
-webkit-text-stroke: 1px rgba(255, 255, 255, 0.15); /* light sites */
|
||||
/* dark sites: -webkit-text-stroke: 1px rgba(255, 106, 26, 0.18); */
|
||||
|
||||
font-size: clamp(5rem, 15vw, 18rem);
|
||||
font-weight: 900;
|
||||
line-height: 0.85;
|
||||
letter-spacing: -0.04em;
|
||||
white-space: nowrap;
|
||||
|
||||
z-index: 2; /* must be lower than the hero product (depth-3 = z-index 3+) */
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
}
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Entrance: lines slide up from a masked overflow:hidden parent
|
||||
function initGhostTextEntrance(lines) {
|
||||
gsap.set(lines, { y: '110%' });
|
||||
gsap.to(lines, {
|
||||
y: '0%',
|
||||
stagger: 0.1,
|
||||
duration: 1.1,
|
||||
ease: 'power4.out',
|
||||
delay: 0.2,
|
||||
});
|
||||
}
|
||||
|
||||
// Exit: lines drift apart as hero scrolls out
|
||||
function addGhostTextExit(scrubTimeline, line1, line2) {
|
||||
scrubTimeline
|
||||
.to(line1, { x: '-12vw', opacity: 0.06, duration: 0.3 }, 0)
|
||||
.to(line2, { x: '12vw', opacity: 0.06, duration: 0.3 }, 0)
|
||||
.to(line1, { x: '-40vw', opacity: 0, duration: 0.25 }, 0.4)
|
||||
.to(line2, { x: '40vw', opacity: 0, duration: 0.25 }, 0.4);
|
||||
}
|
||||
```
|
||||
|
||||
Stroke opacity guide:
|
||||
- `0.08–0.12` → barely-there atmosphere
|
||||
- `0.15–0.22` → readable on inspection, still subtle
|
||||
- `0.25–0.35` → prominently visible — only if it IS the visual focus
|
||||
|
||||
Rules:
|
||||
1. Always `aria-hidden="true"` — never the real heading
|
||||
2. A real `<h1>` must exist elsewhere for SEO/screen readers
|
||||
3. Only works on dark backgrounds — thin strokes vanish on light ones
|
||||
4. Maximum 2 lines — 3+ becomes noise
|
||||
5. Best with ultra-heavy weights (800–900) and tight letter-spacing
|
||||
|
||||
---
|
||||
|
||||
## Combining Techniques
|
||||
|
||||
The most premium results come from layering multiple text techniques in the same section:
|
||||
|
||||
```javascript
|
||||
// Example: Full hero text sequence
|
||||
function initHeroTextSequence() {
|
||||
const tl = gsap.timeline({
|
||||
scrollTrigger: {
|
||||
trigger: '.hero-scene',
|
||||
start: 'top top',
|
||||
end: '+=300%',
|
||||
pin: true,
|
||||
scrub: 1,
|
||||
}
|
||||
});
|
||||
|
||||
// 1. Bleed title already visible via CSS
|
||||
// 2. Subtitle curtain reveal
|
||||
tl.from('.hero-sub .line-inner', {
|
||||
y: '110%', duration: 0.2, stagger: 0.05
|
||||
}, 0)
|
||||
// 3. CTA skew bounce
|
||||
.from('.hero-cta', {
|
||||
y: 40, skewY: 5, opacity: 0, duration: 0.15, ease: 'back.out'
|
||||
}, 0.15)
|
||||
// 4. On scroll-through: title exits via split converge reverse
|
||||
.to('.hero-title .word-left', {
|
||||
x: '-80vw', opacity: 0, duration: 0.25, stagger: 0.03
|
||||
}, 0.7)
|
||||
.to('.hero-title .word-right', {
|
||||
x: '80vw', opacity: 0, duration: 0.25, stagger: -0.03
|
||||
}, 0.7);
|
||||
}
|
||||
```
|
||||
254
engineering-team/epic-design/scripts/inspect-assets.py
Normal file
254
engineering-team/epic-design/scripts/inspect-assets.py
Normal file
@@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
2.5D Asset Inspector
|
||||
Usage: python scripts/inspect-assets.py image1.png image2.jpg ...
|
||||
or: python scripts/inspect-assets.py path/to/folder/
|
||||
|
||||
Checks each image and reports:
|
||||
- Format and mode
|
||||
- Whether it has a real transparent background
|
||||
- Background type if not transparent (dark, light, complex)
|
||||
- Recommended depth level based on image characteristics
|
||||
- Whether the background is likely a problem (product shot vs scene/artwork)
|
||||
|
||||
The AI reads this output and uses it to inform the user.
|
||||
The script NEVER modifies images — inspect only.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
print("PIL not found. Install with: pip install Pillow")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def analyse_image(path):
|
||||
result = {
|
||||
"path": path,
|
||||
"filename": os.path.basename(path),
|
||||
"status": None,
|
||||
"format": None,
|
||||
"mode": None,
|
||||
"size": None,
|
||||
"bg_type": None,
|
||||
"bg_colour": None,
|
||||
"likely_needs_removal": None,
|
||||
"notes": [],
|
||||
}
|
||||
|
||||
try:
|
||||
img = Image.open(path)
|
||||
result["format"] = img.format or os.path.splitext(path)[1].upper().strip(".")
|
||||
result["mode"] = img.mode
|
||||
result["size"] = img.size
|
||||
w, h = img.size
|
||||
|
||||
except Exception as e:
|
||||
result["status"] = "ERROR"
|
||||
result["notes"].append(f"Could not open: {e}")
|
||||
return result
|
||||
|
||||
# --- Alpha / transparency check ---
|
||||
if img.mode == "RGBA":
|
||||
extrema = img.getextrema()
|
||||
alpha_min = extrema[3][0] # 0 = has real transparency, 255 = fully opaque
|
||||
if alpha_min == 0:
|
||||
result["status"] = "CLEAN"
|
||||
result["bg_type"] = "transparent"
|
||||
result["notes"].append("Real alpha channel with transparent pixels — clean cutout")
|
||||
result["likely_needs_removal"] = False
|
||||
return result
|
||||
else:
|
||||
result["notes"].append("RGBA mode but alpha is fully opaque — background was never removed")
|
||||
img = img.convert("RGB") # treat as solid for analysis below
|
||||
|
||||
if img.mode not in ("RGB", "L"):
|
||||
img = img.convert("RGB")
|
||||
|
||||
# --- Sample corners and edges to detect background colour ---
|
||||
pixels = img.load()
|
||||
sample_points = [
|
||||
(0, 0), (w - 1, 0), (0, h - 1), (w - 1, h - 1), # corners
|
||||
(w // 2, 0), (w // 2, h - 1), # top/bottom center
|
||||
(0, h // 2), (w - 1, h // 2), # left/right center
|
||||
]
|
||||
|
||||
samples = []
|
||||
for x, y in sample_points:
|
||||
try:
|
||||
px = pixels[x, y]
|
||||
if isinstance(px, int):
|
||||
px = (px, px, px)
|
||||
samples.append(px[:3])
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not samples:
|
||||
result["status"] = "UNKNOWN"
|
||||
result["notes"].append("Could not sample pixels")
|
||||
return result
|
||||
|
||||
# --- Classify background ---
|
||||
avg_r = sum(s[0] for s in samples) / len(samples)
|
||||
avg_g = sum(s[1] for s in samples) / len(samples)
|
||||
avg_b = sum(s[2] for s in samples) / len(samples)
|
||||
avg_brightness = (avg_r + avg_g + avg_b) / 3
|
||||
|
||||
# Check colour consistency (low variance = solid bg, high variance = scene/complex bg)
|
||||
max_r = max(s[0] for s in samples)
|
||||
max_g = max(s[1] for s in samples)
|
||||
max_b = max(s[2] for s in samples)
|
||||
min_r = min(s[0] for s in samples)
|
||||
min_g = min(s[1] for s in samples)
|
||||
min_b = min(s[2] for s in samples)
|
||||
variance = max(max_r - min_r, max_g - min_g, max_b - min_b)
|
||||
|
||||
result["bg_colour"] = (int(avg_r), int(avg_g), int(avg_b))
|
||||
|
||||
if variance > 80:
|
||||
result["status"] = "COMPLEX_BG"
|
||||
result["bg_type"] = "complex or scene"
|
||||
result["notes"].append(
|
||||
"Background varies significantly across edges — likely a scene, "
|
||||
"photograph, or artwork background rather than a solid colour"
|
||||
)
|
||||
result["likely_needs_removal"] = False # complex bg = probably intentional content
|
||||
result["notes"].append(
|
||||
"JUDGMENT: Complex backgrounds usually mean this image IS the content "
|
||||
"(site screenshot, artwork, section bg). Background likely should be KEPT."
|
||||
)
|
||||
|
||||
elif avg_brightness < 40:
|
||||
result["status"] = "DARK_BG"
|
||||
result["bg_type"] = "solid dark/black"
|
||||
result["notes"].append(
|
||||
f"Solid dark background detected — average edge brightness: {avg_brightness:.0f}/255"
|
||||
)
|
||||
result["likely_needs_removal"] = True
|
||||
result["notes"].append(
|
||||
"JUDGMENT: Dark studio backgrounds on product shots typically need removal. "
|
||||
"BUT if this is a screenshot, artwork, or intentionally dark composition, keep it."
|
||||
)
|
||||
|
||||
elif avg_brightness > 210:
|
||||
result["status"] = "LIGHT_BG"
|
||||
result["bg_type"] = "solid white/light"
|
||||
result["notes"].append(
|
||||
f"Solid light background detected — average edge brightness: {avg_brightness:.0f}/255"
|
||||
)
|
||||
result["likely_needs_removal"] = True
|
||||
result["notes"].append(
|
||||
"JUDGMENT: White studio backgrounds on product shots typically need removal. "
|
||||
"BUT if this is a screenshot, UI mockup, or document, keep it."
|
||||
)
|
||||
|
||||
else:
|
||||
result["status"] = "MIDTONE_BG"
|
||||
result["bg_type"] = "solid mid-tone colour"
|
||||
result["notes"].append(
|
||||
f"Solid mid-tone background detected — avg colour: RGB{result['bg_colour']}"
|
||||
)
|
||||
result["likely_needs_removal"] = None # ambiguous — let AI judge
|
||||
result["notes"].append(
|
||||
"JUDGMENT: Ambiguous — could be a branded background (keep) or a "
|
||||
"studio colour backdrop (remove). AI must judge based on context."
|
||||
)
|
||||
|
||||
# --- JPEG format warning ---
|
||||
if result["format"] in ("JPEG", "JPG"):
|
||||
result["notes"].append(
|
||||
"JPEG format — cannot store transparency. "
|
||||
"If bg removal is needed, user must provide a PNG version or approve CSS workaround."
|
||||
)
|
||||
|
||||
# --- Size note ---
|
||||
if w > 2000 or h > 2000:
|
||||
result["notes"].append(
|
||||
f"Large image ({w}x{h}px) — resize before embedding. "
|
||||
"See references/asset-pipeline.md Step 3 for depth-appropriate targets."
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def print_report(results):
|
||||
print("\n" + "═" * 55)
|
||||
print(" 2.5D Asset Inspector Report")
|
||||
print("═" * 55)
|
||||
|
||||
for r in results:
|
||||
print(f"\n📁 {r['filename']}")
|
||||
print(f" Format : {r['format']} | Mode: {r['mode']} | Size: {r['size']}")
|
||||
|
||||
status_icons = {
|
||||
"CLEAN": "✅",
|
||||
"DARK_BG": "⚠️ ",
|
||||
"LIGHT_BG": "⚠️ ",
|
||||
"COMPLEX_BG": "🔵",
|
||||
"MIDTONE_BG": "❓",
|
||||
"UNKNOWN": "❓",
|
||||
"ERROR": "❌",
|
||||
}
|
||||
icon = status_icons.get(r["status"], "❓")
|
||||
print(f" Status : {icon} {r['status']}")
|
||||
|
||||
if r["bg_type"]:
|
||||
print(f" Bg type: {r['bg_type']}")
|
||||
|
||||
if r["likely_needs_removal"] is True:
|
||||
print(" Removal: Likely needed (product/object shot)")
|
||||
elif r["likely_needs_removal"] is False:
|
||||
print(" Removal: Likely NOT needed (scene/artwork/content image)")
|
||||
else:
|
||||
print(" Removal: Ambiguous — AI must judge from context")
|
||||
|
||||
for note in r["notes"]:
|
||||
print(f" → {note}")
|
||||
|
||||
print("\n" + "═" * 55)
|
||||
clean = sum(1 for r in results if r["status"] == "CLEAN")
|
||||
flagged = sum(1 for r in results if r["status"] in ("DARK_BG", "LIGHT_BG", "MIDTONE_BG"))
|
||||
complex_bg = sum(1 for r in results if r["status"] == "COMPLEX_BG")
|
||||
errors = sum(1 for r in results if r["status"] == "ERROR")
|
||||
|
||||
print(f" Clean: {clean} | Flagged: {flagged} | Complex/Scene: {complex_bg} | Errors: {errors}")
|
||||
print("═" * 55)
|
||||
print("\nNext step: Read JUDGMENT notes above and inform the user.")
|
||||
print("See references/asset-pipeline.md for the exact notification format.\n")
|
||||
|
||||
|
||||
def collect_paths(args):
|
||||
paths = []
|
||||
for arg in args:
|
||||
if os.path.isdir(arg):
|
||||
for f in os.listdir(arg):
|
||||
if f.lower().endswith((".png", ".jpg", ".jpeg", ".webp", ".avif")):
|
||||
paths.append(os.path.join(arg, f))
|
||||
elif os.path.isfile(arg):
|
||||
paths.append(arg)
|
||||
else:
|
||||
print(f"⚠️ Not found: {arg}")
|
||||
return paths
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2 or sys.argv[1] in ('-h', '--help'):
|
||||
print("\nUsage:")
|
||||
print(" python scripts/inspect-assets.py image.png")
|
||||
print(" python scripts/inspect-assets.py image1.jpg image2.png")
|
||||
print(" python scripts/inspect-assets.py path/to/folder/\n")
|
||||
if len(sys.argv) < 2:
|
||||
sys.exit(1)
|
||||
else:
|
||||
sys.exit(0)
|
||||
|
||||
paths = collect_paths(sys.argv[1:])
|
||||
if not paths:
|
||||
print("No valid image files found.")
|
||||
sys.exit(1)
|
||||
|
||||
results = [analyse_image(p) for p in paths]
|
||||
print_report(results)
|
||||
165
engineering-team/epic-design/scripts/validate-layers.js
Normal file
165
engineering-team/epic-design/scripts/validate-layers.js
Normal file
@@ -0,0 +1,165 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* 2.5D Layer Validator
|
||||
* Usage: node scripts/validate-layers.js path/to/your/index.html
|
||||
*
|
||||
* Checks:
|
||||
* 1. Every animated element has a data-depth attribute
|
||||
* 2. Decorative elements have aria-hidden="true"
|
||||
* 3. prefers-reduced-motion is implemented in CSS
|
||||
* 4. Product images have alt text
|
||||
* 5. SplitText elements have aria-label
|
||||
* 6. No more than 80 animated elements (performance)
|
||||
* 7. Will-change is not applied globally
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const filePath = process.argv[2];
|
||||
|
||||
if (!filePath) {
|
||||
console.error('\n❌ Usage: node validate-layers.js path/to/index.html\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const html = fs.readFileSync(path.resolve(filePath), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
const results = [];
|
||||
|
||||
function check(label, condition, suggestion) {
|
||||
if (condition) {
|
||||
passed++;
|
||||
results.push({ status: '✅', label });
|
||||
} else {
|
||||
failed++;
|
||||
results.push({ status: '❌', label, suggestion });
|
||||
}
|
||||
}
|
||||
|
||||
function warn(label, condition, suggestion) {
|
||||
if (!condition) {
|
||||
results.push({ status: '⚠️ ', label, suggestion });
|
||||
}
|
||||
}
|
||||
|
||||
// --- CHECKS ---
|
||||
|
||||
// 1. Scene elements present
|
||||
check(
|
||||
'Scene elements found (.scene)',
|
||||
html.includes('class="scene') || html.includes("class='scene"),
|
||||
'Wrap each major section in <section class="scene"> for the depth system to work.'
|
||||
);
|
||||
|
||||
// 2. Depth layers present
|
||||
const depthMatches = html.match(/data-depth=["']\d["']/g) || [];
|
||||
check(
|
||||
`Depth attributes found (${depthMatches.length} elements)`,
|
||||
depthMatches.length >= 3,
|
||||
'Each scene needs at least 3 elements with data-depth="0" through data-depth="5".'
|
||||
);
|
||||
|
||||
// 3. prefers-reduced-motion in linked CSS
|
||||
const hasReducedMotionInline = html.includes('prefers-reduced-motion');
|
||||
check(
|
||||
'prefers-reduced-motion implemented',
|
||||
hasReducedMotionInline || html.includes('hero-section.css'),
|
||||
'Add @media (prefers-reduced-motion: reduce) { } block. See references/accessibility.md.'
|
||||
);
|
||||
|
||||
// 4. Decorative elements have aria-hidden
|
||||
const decorativeElements = (html.match(/class="[^"]*(?:depth-0|depth-1|depth-5|glow-blob|particle|deco)[^"]*"/g) || []).length;
|
||||
const ariaHiddenCount = (html.match(/aria-hidden="true"/g) || []).length;
|
||||
check(
|
||||
`Decorative elements have aria-hidden (found ${ariaHiddenCount})`,
|
||||
ariaHiddenCount >= 1,
|
||||
'Add aria-hidden="true" to all decorative layers (depth-0, depth-1, particles, glows).'
|
||||
);
|
||||
|
||||
// 5. Images have alt text
|
||||
const imgTags = html.match(/<img[^>]*>/g) || [];
|
||||
const imgsWithoutAlt = imgTags.filter(tag => !tag.includes('alt=')).length;
|
||||
check(
|
||||
`All images have alt attributes (${imgTags.length} images found)`,
|
||||
imgsWithoutAlt === 0,
|
||||
`${imgsWithoutAlt} image(s) missing alt attribute. Decorative images use alt="", meaningful images need descriptive alt text.`
|
||||
);
|
||||
|
||||
// 6. Skip link present
|
||||
check(
|
||||
'Skip-to-content link present',
|
||||
html.includes('skip-link') || html.includes('Skip to'),
|
||||
'Add <a href="#main-content" class="skip-link">Skip to main content</a> as first element in <body>.'
|
||||
);
|
||||
|
||||
// 7. GSAP script loaded
|
||||
check(
|
||||
'GSAP script included',
|
||||
html.includes('gsap') || html.includes('gsap.min.js'),
|
||||
'Include GSAP from CDN: <script src="https://cdn.jsdelivr.net/npm/gsap@3.12.5/dist/gsap.min.js"></script>'
|
||||
);
|
||||
|
||||
// 8. ScrollTrigger plugin loaded
|
||||
warn(
|
||||
'ScrollTrigger plugin loaded',
|
||||
html.includes('ScrollTrigger'),
|
||||
'Add ScrollTrigger plugin for scroll animations: <script src=".../ScrollTrigger.min.js"></script>'
|
||||
);
|
||||
|
||||
// 9. Performance: too many animated elements
|
||||
const animatedElements = (html.match(/data-animate=/g) || []).length + depthMatches.length;
|
||||
check(
|
||||
`Animated element count acceptable (${animatedElements} total)`,
|
||||
animatedElements <= 80,
|
||||
`${animatedElements} animated elements found. Target is under 80 for smooth 60fps performance.`
|
||||
);
|
||||
|
||||
// 10. Main landmark present
|
||||
check(
|
||||
'<main> landmark present',
|
||||
html.includes('<main'),
|
||||
'Wrap page content in <main id="main-content"> for accessibility and skip link target.'
|
||||
);
|
||||
|
||||
// 11. Heading hierarchy
|
||||
const h1Count = (html.match(/<h1[\s>]/g) || []).length;
|
||||
check(
|
||||
`Single <h1> present (found ${h1Count})`,
|
||||
h1Count === 1,
|
||||
h1Count === 0
|
||||
? 'Add one <h1> element as the main page heading.'
|
||||
: `Multiple <h1> elements found (${h1Count}). Each page should have exactly one <h1>.`
|
||||
);
|
||||
|
||||
// 12. lang attribute on html
|
||||
check(
|
||||
'<html lang=""> attribute present',
|
||||
html.includes('lang='),
|
||||
'Add lang="en" (or your language) to the <html> element: <html lang="en">'
|
||||
);
|
||||
|
||||
// --- REPORT ---
|
||||
|
||||
console.log('\n📋 2.5D Layer Validator Report');
|
||||
console.log('═══════════════════════════════════════');
|
||||
console.log(`File: ${filePath}\n`);
|
||||
|
||||
results.forEach(r => {
|
||||
console.log(`${r.status} ${r.label}`);
|
||||
if (r.suggestion) {
|
||||
console.log(` → ${r.suggestion}`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log('\n═══════════════════════════════════════');
|
||||
console.log(`Passed: ${passed} | Failed: ${failed}`);
|
||||
|
||||
if (failed === 0) {
|
||||
console.log('\n🎉 All checks passed! Your 2.5D site is ready.\n');
|
||||
} else {
|
||||
console.log(`\n🔧 Fix the ${failed} issue(s) above before shipping.\n`);
|
||||
process.exit(1);
|
||||
}
|
||||
Reference in New Issue
Block a user