A workflow for adding a modern motion layer to a webpage. Key: motion is a signal, not decoration. It should guide the user's gaze to key information or reinforce hierarchy.
Workflow
Step 1: Choose a background animation (for Hero)
Option A: WebGL Shader Gradient (Stripe·Linear style) — recommended
- A smoothly flowing color mesh. GPU-accelerated, lightweight
- Direct GLSL fragment shader without a library
- Colors based on design-tokens' accent-primary/secondary
Option B: Canvas Particle Network (Apple·Bloomberg style)
- A network where dots connect and move
- Suitable as an HBM/AI data flow metaphor
- canvas 2D, 100–150 particles (50 on mobile)
Option C: Animated Orbs (Vercel·Linear style)
- 3–5 large blurred circles move slowly and blend colors
- Possible with CSS alone, but smoother with Canvas
Recommended: Option A (WebGL shader gradient) — modern, smooth, and lightweight. As an alternative, Option B (particle network) also fits the HBM/AI concept well.
Step 2: Implement WebGL Shader Gradient (web/scripts/background-animation.js)
const canvas = document.getElementById('bg-canvas');
const gl = canvas.getContext('webgl');
if (!gl) {
// Fallback: CSS gradient
document.body.style.background = 'linear-gradient(135deg, #0a0e1a 0%, #1f2937 100%)';
// 정적 fallback
} else {
// Fragment shader (간소화 예시)
const fragmentSource = `
precision highp float;
uniform vec2 u_resolution;
uniform float u_time;
// simplex noise function (생략, GLSL 표준)
void main() {
vec2 uv = gl_FragCoord.xy / u_resolution.xy;
float t = u_time * 0.0001;
// 3개의 천천히 움직이는 색상 점
vec3 color1 = vec3(0.024, 0.071, 0.102); // bg-primary
vec3 color2 = vec3(0.024, 0.71, 0.83); // accent-primary (cyan)
vec3 color3 = vec3(0.545, 0.361, 0.965); // accent-secondary (purple)
float n1 = snoise(vec3(uv * 2.0, t)) * 0.5 + 0.5;
float n2 = snoise(vec3(uv * 3.0, t + 100.0)) * 0.5 + 0.5;
vec3 color = mix(color1, color2, n1 * 0.3);
color = mix(color, color3, n2 * 0.2);
gl_FragColor = vec4(color, 1.0);
}
`;
// 컴파일·렌더 루프
// ...
}
// prefers-reduced-motion 체크
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
if (prefersReducedMotion) {
// 정적 그라데이션으로 대체
cancelAnimationFrame(animationId);
document.body.style.background = 'radial-gradient(circle at 30% 20%, rgba(6,182,212,0.1) 0%, transparent 50%), var(--bg-primary)';
}
// 화면 밖에서는 일시 중지 (성능)
const ioBg = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) startAnim(); else pauseAnim();
});
ioBg.observe(canvas);
Alternative: you can use a prebuilt solution such as a shader library (e.g., https://cdn.jsdelivr.net/npm/shaderlib)나 whatamesh ).
Step 3: Scroll entrance animation (web/scripts/scroll-animations.js)
Use GSAP ScrollTrigger (CDN: cdn.jsdelivr.net/npm/gsap@3/dist/gsap.min.js + dist/ScrollTrigger.min.js):
import gsap from 'https://cdn.skypack.dev/gsap';
import { ScrollTrigger } from 'https://cdn.skypack.dev/gsap/ScrollTrigger';
gsap.registerPlugin(ScrollTrigger);
// 섹션 진입 시 fade + slide-up
gsap.utils.toArray('.section').forEach((section) => {
gsap.from(section.querySelectorAll('h2, p, .summary-card, .chart-container'), {
y: 60,
opacity: 0,
duration: 0.8,
ease: 'power3.out',
stagger: 0.1,
scrollTrigger: {
trigger: section,
start: 'top 75%',
toggleActions: 'play none none reverse',
},
});
});
// Hero 텍스트 reveal (단어 단위)
const heroTitle = document.querySelector('.hero-title .display-1');
const words = heroTitle.textContent.split('').map(c => `<span class="char">${c}</span>`).join('');
heroTitle.innerHTML = words;
gsap.from('.hero-title .char', {
y: 120,
opacity: 0,
duration: 1,
stagger: 0.04,
ease: 'power4.out',
});
// 메타 메트릭 카드 stagger
gsap.from('.meta-metrics .metric-card', {
y: 40,
opacity: 0,
duration: 0.8,
stagger: 0.15,
delay: 0.6,
ease: 'power3.out',
});
// Pin & scrub 예시 (분석 영역에서 시각화 잠금)
// 옵션: 시나리오 카드에서 스크롤로 Bear → Base → Bull 전환
Step 4: Micro-interactions (web/scripts/microinteractions.js)
// 숫자 카운트업 (IntersectionObserver + RAF)
function countUp(el, target, duration = 1500, formatter) {
const start = 0;
const startTime = performance.now();
function tick(now) {
const elapsed = now - startTime;
const progress = Math.min(elapsed / duration, 1);
const eased = 1 - Math.pow(1 - progress, 3); // easeOutCubic
const value = start + (target - start) * eased;
el.textContent = formatter ? formatter(value) : Math.round(value).toLocaleString('ko-KR');
if (progress < 1) requestAnimationFrame(tick);
}
requestAnimationFrame(tick);
}
const countUpObserver = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting && !entry.target.dataset.counted) {
entry.target.dataset.counted = 'true';
const target = parseFloat(entry.target.dataset.countup);
countUp(entry.target, target);
}
});
}, { threshold: 0.6 });
document.querySelectorAll('[data-countup]').forEach(el => countUpObserver.observe(el));
// 카드 3D tilt on hover
document.querySelectorAll('.metric-card, .summary-card').forEach(card => {
card.addEventListener('mousemove', (e) => {
const rect = card.getBoundingClientRect();
const x = (e.clientX - rect.left) / rect.width - 0.5;
const y = (e.clientY - rect.top) / rect.height - 0.5;
card.style.transform = `perspective(1000px) rotateY(${x * 6}deg) rotateX(${-y * 6}deg) translateY(-2px)`;
});
card.addEventListener('mouseleave', () => {
card.style.transform = '';
});
});
// 텍스트 reveal on scroll (대안: SplitType + GSAP)
Step 5: CSS animation utilities (web/styles/animations.css)
/* 스크롤 인디케이터 펄스 */
@keyframes pulse-down {
0%, 100% { transform: translateY(0); opacity: 0.6; }
50% { transform: translateY(8px); opacity: 1; }
}
.scroll-indicator { animation: pulse-down 2s ease-in-out infinite; }
/* 글로우 펄스 (강조 메트릭) */
@keyframes glow {
0%, 100% { box-shadow: 0 0 32px rgba(6,182,212,0.2); }
50% { box-shadow: 0 0 48px rgba(6,182,212,0.4); }
}
.metric-card.highlight { animation: glow 3s ease-in-out infinite; }
/* 진입용 fade-up (JS 없이 fallback) */
@keyframes fade-up {
from { opacity: 0; transform: translateY(40px); }
to { opacity: 1; transform: translateY(0); }
}
/* Reduced motion 강제 비활성화 */
@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;
}
#bg-canvas { display: none; }
}
Step 6: Measure performance & write motion-notes
web/_design/motion-notes.mdOrganize in:
- Libraries and versions used (GSAP, ScrollTrigger CDN URL)
- Reason for choosing the background option (WebGL shader gradient)
- Trigger, duration, and easing for each motion
- Performance measurements (expected fps, GPU usage)
- prefers-reduced-motion handling items
- Fallback (CSS gradient when WebGL is unsupported)
Working principles
- Motion = signal: Use only to reinforce key metrics, conversions, and hierarchy. No meaningless decoration
- Performance first: Maintain 60fps. Simplify under WebGL load. Use requestAnimationFrame
- Enforce prefers-reduced-motion: Disable all non-essential motion (background canvas also static)
- Layer separation:
position: fixed; z-index: -1to separate background and content - Dark/light adaptation: Automatically switch based on design-tokens' color variables
- Memory management: Pause off-screen motion with IntersectionObserver
- Follow motion tokens: Use design-lead's duration and easing
Team communication
- Receive motion tokens, background concept, and key metric emphasis locations from design-lead
- SendMessage to frontend-engineer with background canvas container spec (e.g.,
<canvas id="bg-canvas">) and trigger class (.section,[data-countup],[data-stagger]) spec - Coordinate chart entry stagger with visualization-engineer
- Provide motion-notes.md to web-qa + request reduced-motion and performance verification
On follow-up execution
- Feedback that motion looks excessive → reduce intensity (shorten duration, weaker opacity, reduce scale)
- Feedback that it feels monotonous → add variations (different easing, adjust stagger)
- Preserve previous motion JS, modify only the changed areas