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

TokenDelayOvershootSettleUse Case
tight30ms1.02100msEnterprise, controlled
loose80ms1.08200msConsumer, friendly
fluid120ms1.15300msPlayful, organic
heavy150ms1.05400msWeighty, dramatic
elastic60ms1.25350msBouncy, rubber-like

Overlap Offsets

TokenOffsetEffect
sequential20%Elements nearly finished before next starts
staggered40%Standard wave effect
cascading60%Multiple elements animating at once
simultaneous80%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 TypeDelayBehavior
Badges, icons on cards1-2 framesTight, minimal lag
Labels, text2-3 framesSubtle follow
Decorative elements3-5 framesNoticeable lag
Hair, cloth, ribbons5-10 framesPronounced drag
Independent elementsVariesOwn 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

  1. Too much overshoot - Keep under 1.2x for UI
  2. Same timing everywhere - Vary stagger by importance
  3. Fighting springs - Don't interrupt spring animations
  4. No hierarchy - Primary should clearly lead secondaries
  5. 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

دانلود ZIP