follow-through
انیمیشنهای طبیعی با تأخیر، اورشوت و حرکت زنجیرهای طراحی کنید.
این Skill اصل Follow-Through و Overlapping Action را برای ساخت حرکتهای طبیعی و مبتنی بر فیزیک بهکار میگیرد؛ یعنی عناصر ثانویه پس از توقف حرکت اصلی کمی ادامه میدهند و عناصر متعدد با تأخیرهای حسابشده وارد حرکت میشوند.
با استفاده از tokenهای آماده، spring physics و presetهای CSS، GSAP، Framer Motion و Remotion میتوانید انیمیشنهای UI، فهرستهای staggered، کارتها و badgeها را روانتر و زندهتر کنید.
چه چیزی میگیرید
- تنظیمات آماده برای شدتهای tight، loose، fluid، heavy و elastic
- الگوهای staggered و cascading برای فهرستها و گروهی از عناصر
- پارامترهای spring شامل stiffness، damping و mass
- نمونهکدهای قابل استفاده در CSS، GSAP، Framer Motion و Remotion
- راهنمای رعایت prefers-reduced-motion و جلوگیری از خطاهای رایج
برای چه کاری خوب است
- طراحی تعاملهای UI طبیعی برای کارتها، badgeها، منوها و فهرستها
- ساخت انیمیشنهای محصول با GSAP یا Framer Motion
- تولید ویدیوهای موشن و انیمیشنهای زمانبندیشده با Remotion
- زمانی که حرکتهای همزمان و خشک، حس مصنوعی ایجاد میکنند
برای چه کاری نیست
- انیمیشنهای کاملاً فوری و بدون تأخیر یا overshoot
- پروژههایی که حرکت اضافی میتواند سرعت یا دسترسیپذیری را کاهش دهد
- زمانی که به طراحی مسیر حرکت پیچیده یا سیستم کامل timeline نیاز دارید
دربارهٔ این اسکیل
Follow-Through & Overlapping Action
Secondary elements continue moving after the primary action stops, creating natural, physics-based settling. Overlapping action staggers multiple elements for fluid choreography.
Core Principles
Follow-Through
Secondary elements lag behind and overshoot before settling.
Primary stops: ################ STOP
Secondary: ####################-><-> SETTLE
^
overshoot + oscillation
Overlapping Action
Multiple elements animate with staggered timing, not simultaneously.
All at once: ######## (robotic)
########
########
Overlapping: ######## (natural)
########
########
Token Reference
Load from: tokens/follow-through.tokens.json
Follow-Through Configs
| Token | Delay | Overshoot | Settle | Use Case |
|---|---|---|---|---|
tight | 30ms | 1.02 | 100ms | Enterprise, controlled |
loose | 80ms | 1.08 | 200ms | Consumer, friendly |
fluid | 120ms | 1.15 | 300ms | Playful, organic |
heavy | 150ms | 1.05 | 400ms | Weighty, dramatic |
elastic | 60ms | 1.25 | 350ms | Bouncy, rubber-like |
Overlap Offsets
| Token | Offset | Effect |
|---|---|---|
sequential | 20% | Elements nearly finished before next starts |
staggered | 40% | Standard wave effect |
cascading | 60% | Multiple elements animating at once |
simultaneous | 80% | Near-instant cascade |
Spring Physics
Follow-through is best achieved with spring physics:
Spring Parameters:
- stiffness: How quickly it snaps back (higher = faster)
- damping: How quickly oscillations die (lower = bouncier)
- mass: Weight of the element (higher = slower, heavier)
Configs from tokens:
- tight-spring: stiffness: 400, damping: 30, mass: 1
- standard-spring: stiffness: 200, damping: 20, mass: 1
- bouncy-spring: stiffness: 300, damping: 10, mass: 1
- wobbly-spring: stiffness: 180, damping: 8, mass: 1
Implementation Presets
CSS
/* Follow-through with keyframes */
@keyframes follow-through-settle {
0% { transform: translateX(0); }
60% { transform: translateX(100px); }
75% { transform: translateX(108px); } /* Overshoot */
85% { transform: translateX(97px); } /* Undershoot */
95% { transform: translateX(101px); } /* Tiny overshoot */
100% { transform: translateX(100px); }
}
/* Staggered list items */
.list-item {
opacity: 0;
transform: translateY(20px);
animation: slide-in 300ms ease-out forwards;
}
.list-item:nth-child(1) { animation-delay: 0ms; }
.list-item:nth-child(2) { animation-delay: 60ms; }
.list-item:nth-child(3) { animation-delay: 120ms; }
.list-item:nth-child(4) { animation-delay: 180ms; }
@keyframes slide-in {
to {
opacity: 1;
transform: translateY(0);
}
}
/* CSS spring approximation using cubic-bezier */
.spring-settle {
transition: transform 400ms cubic-bezier(0.175, 0.885, 0.32, 1.275);
}
GSAP
import { gsap } from 'gsap';
import tokens from '../tokens/follow-through.tokens.json';
// Follow-through with elastic easing
function animateWithFollowThrough(element, target, intensity = 'loose') {
const config = tokens['follow-through'][intensity].$value;
return gsap.to(element, {
...target,
duration: config.settleDuration / 1000 + 0.3,
ease: `elastic.out(1, ${1 - config.overshoot + 0.5})`
});
}
// Staggered list animation
function staggerList(items, intensity = 'staggered') {
const offset = parseFloat(tokens.overlap[intensity].$value.offset) / 100;
const staggerTime = 0.3 * (1 - offset); // Convert offset to stagger delay
return gsap.from(items, {
opacity: 0,
y: 30,
duration: 0.4,
stagger: staggerTime,
ease: 'back.out(1.7)'
});
}
// Primary + secondary element follow-through
function primarySecondaryAnimation(primary, secondary, intensity = 'loose') {
const { delay, overshoot, settleDuration } = tokens['follow-through'][intensity].$value;
const tl = gsap.timeline();
// Primary movement
tl.to(primary, {
x: 200,
duration: 0.3,
ease: 'power2.out'
});
// Secondary follows with delay and overshoot
tl.to(secondary, {
x: 200 * overshoot,
duration: 0.3,
ease: 'power2.out'
}, `<${delay / 1000}`); // Start slightly after primary
// Secondary settles back
tl.to(secondary, {
x: 200,
duration: settleDuration / 1000,
ease: 'elastic.out(1, 0.5)'
});
return tl;
}
// Card with badge follow-through
function cardWithBadge(card, badge) {
const tl = gsap.timeline();
// Card moves
tl.to(card, {
y: -20,
duration: 0.2,
ease: 'power2.out'
});
// Badge follows with lag and wobble
tl.to(badge, {
y: -25, // Overshoots
rotation: 5,
duration: 0.25,
ease: 'power2.out'
}, '<0.05'); // Slight delay
// Badge settles
tl.to(badge, {
y: -20,
rotation: 0,
duration: 0.3,
ease: 'elastic.out(1, 0.4)'
});
return tl;
}
Framer Motion
import { motion, useAnimation, useSpring } from 'framer-motion';
import tokens from '../tokens/follow-through.tokens.json';
// Spring-based follow-through
const springConfigs = {
tight: { stiffness: 400, damping: 30, mass: 1 },
loose: { stiffness: 200, damping: 20, mass: 1 },
bouncy: { stiffness: 300, damping: 10, mass: 1 },
wobbly: { stiffness: 180, damping: 8, mass: 1 }
};
export const FollowThroughCard: React.FC<{ children: React.ReactNode }> = ({
children
}) => (
<motion.div
whileHover={{ y: -10 }}
transition={{ type: 'spring', ...springConfigs.loose }}
>
{children}
</motion.div>
);
// Staggered children with overlap
const containerVariants = {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.08, // 80ms between children
delayChildren: 0.1
}
}
};
const itemVariants = {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: { type: 'spring', ...springConfigs.loose }
}
};
export const StaggeredList: React.FC<{ items: string[] }> = ({ items }) => (
<motion.ul
variants={containerVariants}
initial="hidden"
animate="visible"
>
{items.map((item, i) => (
<motion.li key={i} variants={itemVariants}>
{item}
</motion.li>
))}
</motion.ul>
);
// Parent-child follow-through
export const ParentChildFollowThrough: React.FC = () => {
const controls = useAnimation();
const handleHover = async () => {
await controls.start('hover');
};
return (
<motion.div
className="parent"
whileHover="hover"
onHoverStart={handleHover}
>
<motion.div
className="child"
variants={{
rest: { y: 0, rotate: 0 },
hover: {
y: -15,
rotate: 5,
transition: {
type: 'spring',
stiffness: 200,
damping: 15,
delay: 0.05 // Follow-through delay
}
}
}}
initial="rest"
/>
</motion.div>
);
};
Remotion
import { useCurrentFrame, interpolate, spring } from 'remotion';
import tokens from '../tokens/follow-through.tokens.json';
// Spring-based follow-through
export const useFollowThrough = (
primaryStopFrame: number,
config: 'tight' | 'loose' | 'fluid' = 'loose'
) => {
const frame = useCurrentFrame();
const { delay, overshoot, settleDuration } = tokens['follow-through'][config].$value;
const delayFrames = Math.round((delay / 1000) * 30);
const secondaryFrame = frame - delayFrames;
// Spring physics for settling
const springProgress = spring({
frame: secondaryFrame - primaryStopFrame,
fps: 30,
config: {
damping: 15,
stiffness: 150,
mass: 1,
overshootClamping: false
}
});
return {
progress: springProgress,
overshoot: overshoot,
scale: 1 + (overshoot - 1) * (1 - springProgress),
rotation: interpolate(springProgress, [0, 0.5, 1], [5, -2, 0])
};
};
// Staggered list animation
export const StaggeredList: React.FC<{
startFrame: number;
items: React.ReactNode[];
staggerFrames?: number;
}> = ({ startFrame, items, staggerFrames = 3 }) => {
const frame = useCurrentFrame();
return (
<>
{items.map((item, index) => {
const itemStartFrame = startFrame + index * staggerFrames;
const progress = spring({
frame: frame - itemStartFrame,
fps: 30,
config: { damping: 20, stiffness: 200, mass: 1 }
});
const opacity = interpolate(progress, [0, 1], [0, 1]);
const y = interpolate(progress, [0, 1], [30, 0]);
return (
<div
key={index}
style={{
opacity,
transform: `translateY(${y}px)`
}}
>
{item}
</div>
);
})}
</>
);
};
// Card with secondary element
export const CardWithBadge: React.FC<{
moveFrame: number;
children: React.ReactNode;
}> = ({ moveFrame, children }) => {
const frame = useCurrentFrame();
// Primary card spring
const cardSpring = spring({
frame: frame - moveFrame,
fps: 30,
config: { damping: 20, stiffness: 200 }
});
// Secondary badge spring (delayed)
const badgeSpring = spring({
frame: frame - moveFrame - 2, // 2 frame delay
fps: 30,
config: { damping: 12, stiffness: 180 } // Bouncier
});
const cardY = interpolate(cardSpring, [0, 1], [0, -30]);
const badgeY = interpolate(badgeSpring, [0, 1], [0, -35]); // Overshoots
const badgeRotation = interpolate(badgeSpring, [0, 0.7, 1], [0, 8, 0]);
return (
<div style={{ transform: `translateY(${cardY}px)` }}>
{children}
<div
className="badge"
style={{
transform: `translateY(${badgeY - cardY}px) rotate(${badgeRotation}deg)`
}}
/>
</div>
);
};
Secondary Element Hierarchy
| Element Type | Delay | Behavior |
|---|---|---|
| Badges, icons on cards | 1-2 frames | Tight, minimal lag |
| Labels, text | 2-3 frames | Subtle follow |
| Decorative elements | 3-5 frames | Noticeable lag |
| Hair, cloth, ribbons | 5-10 frames | Pronounced drag |
| Independent elements | Varies | Own physics |
Accessibility
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
// Reduced motion: remove stagger and settle
const stagger = prefersReducedMotion ? 0 : 0.08;
const transition = prefersReducedMotion
? { duration: 0.01 }
: { type: 'spring', stiffness: 200, damping: 20 };
Common Mistakes
- Too much overshoot - Keep under 1.2x for UI
- Same timing everywhere - Vary stagger by importance
- Fighting springs - Don't interrupt spring animations
- No hierarchy - Primary should clearly lead secondaries
- Over-staggering - Too much delay feels sluggish
Related Skills
- anticipation - The wind-up phase that precedes the motion follow-through settles
- squash-stretch - Follow-through often includes diminishing squash oscillations
- secondary-action - Secondary actions have their own follow-through behaviors
- exaggeration - Controls intensity of overshoot and settle oscillations
- arc-motion - Follow-through motion often continues along arc paths