secondary-action

انیمیشن‌های مکملی بسازید که کنش اصلی را تقویت کنند، نه اینکه حواس کاربر را پرت کنند.

این Skill برای طراحی و پیاده‌سازی Secondary Action در موشن رابط کاربری است؛ افکت‌هایی مثل ripple، pulse، glow، wiggle و flash که در کنار انیمیشن اصلی اجرا می‌شوند. شدت، زمان‌بندی و جایگاه این حرکات طوری تنظیم می‌شود که سلسله‌مراتب توجه حفظ شود.

با این راهنما می‌توانید برای وضعیت‌هایی مانند موفقیت، خطا، بارگذاری، hover و جشن، موشن‌های مکملی در CSS، GSAP، Framer Motion یا Remotion بسازید. اصول دسترس‌پذیری، عملکرد و پشتیبانی از prefers-reduced-motion نیز در طراحی لحاظ می‌شوند.

چه چیزی می‌گیرید

  • الگوی طراحی موشن مکمل با شدت ۳۰ تا ۵۰ درصد نسبت به کنش اصلی
  • نمونه‌های آماده برای ripple، glow، pulse، shake، flash و icon wiggle
  • راهنمای هماهنگ‌سازی زمان‌بندی با timeline و easing
  • پیاده‌سازی نمونه در CSS، GSAP، Framer Motion و Remotion
  • توصیه‌های کاربردی برای دسترس‌پذیری، عملکرد و کاهش شلوغی بصری

برای چه کاری خوب است

  • طراحان محصول و توسعه‌دهندگانی که می‌خواهند تعاملات UI را غنی‌تر کنند
  • ساخت بازخورد بصری برای موفقیت، خطا، بارگذاری، hover و تأیید عملیات
  • پروژه‌هایی که یک انیمیشن اصلی دارند و به افکت‌های ظریف پشتیبان نیاز دارند

برای چه کاری نیست

  • صفحه‌ها یا تعاملاتی که کاربر باید روی یک پیام پیچیده کاملاً متمرکز بماند
  • پروژه‌هایی با محدودیت شدید عملکرد یا دستگاه‌های کم‌توان
  • مواردی که انیمیشن اصلی به‌اندازه کافی گویا است یا سادگی دسترس‌پذیر اولویت دارد

دربارهٔ این اسکیل

Secondary Action

Supporting animations that enhance the primary action. Secondary actions add richness without stealing focus from the main event.

Core Principle

Secondary actions support and reinforce the primary action. They should never compete for attention or distract from the main message.

Primary action:    [BUTTON PRESS] <- User's focus here
Secondary action:  (subtle ripple behind) <- Enhances, doesn't distract

Hierarchy of Attention

LevelIntensityRoleExample
Primary100%Main focusButton press, modal open
Secondary30-50%ReinforcementBackground pulse, icon wiggle
Tertiary10-20%AmbientParticle effects, subtle gradients

When to Use Secondary Actions

Good Use Cases

  • Confirmation reinforcement - Checkmark + subtle success glow
  • Loading enrichment - Spinner + skeleton shimmer
  • Error emphasis - Shake + background flash
  • Celebration - Confetti + screen pulse
  • Focus indication - Scale + ring pulse

Avoid When

  • Primary action is complex enough
  • User needs to concentrate
  • Performance is constrained
  • Accessibility requires simplicity

Timing Relationships

TimingPatternUse Case
SimultaneousSecondary starts with primaryImmediate reinforcement
DuringSecondary runs through primaryContinuous enhancement
AfterSecondary follows primary completionConclusion emphasis
AnticipationSecondary starts before primaryBuilding tension

Implementation Presets

CSS

/* Button with ripple secondary action */
.button-with-ripple {
  position: relative;
  overflow: hidden;
}

.button-with-ripple::after {
  content: '';
  position: absolute;
  inset: 0;
  background: radial-gradient(circle, rgba(255,255,255,0.3) 0%, transparent 70%);
  opacity: 0;
  transform: scale(0);
  pointer-events: none;
}

.button-with-ripple:active::after {
  animation: ripple-secondary 400ms ease-out;
}

@keyframes ripple-secondary {
  0% { opacity: 1; transform: scale(0); }
  100% { opacity: 0; transform: scale(2.5); }
}

/* Success state with glow secondary */
.success-icon {
  animation: checkmark-primary 300ms ease-out;
}

.success-icon::before {
  content: '';
  position: absolute;
  inset: -10px;
  border-radius: 50%;
  background: var(--color-success);
  opacity: 0;
  animation: success-glow 600ms ease-out;
}

@keyframes checkmark-primary {
  from { transform: scale(0); }
  to { transform: scale(1); }
}

@keyframes success-glow {
  0% { opacity: 0.4; transform: scale(0.5); }
  50% { opacity: 0.2; transform: scale(1.2); }
  100% { opacity: 0; transform: scale(1.5); }
}

/* Loading spinner with pulse secondary */
.loading-container {
  animation: pulse-secondary 2s ease-in-out infinite;
}

@keyframes pulse-secondary {
  0%, 100% { background-color: rgba(0,0,0,0.02); }
  50% { background-color: rgba(0,0,0,0.05); }
}

GSAP

import { gsap } from 'gsap';

// Primary button press with secondary ripple
function buttonPressWithRipple(button, rippleElement) {
  const tl = gsap.timeline();

  // Primary: button scale
  tl.to(button, {
    scale: 0.95,
    duration: 0.1,
    ease: 'power2.out'
  });

  // Secondary: ripple (30% intensity - more subtle)
  tl.to(rippleElement, {
    scale: 2.5,
    opacity: 0,
    duration: 0.4,
    ease: 'power2.out'
  }, '<'); // Start simultaneously

  // Primary: button release
  tl.to(button, {
    scale: 1,
    duration: 0.2,
    ease: 'back.out(2)'
  }, '-=0.2');

  return tl;
}

// Success notification with secondaries
function successNotification(container, icon, background) {
  const tl = gsap.timeline();

  // Primary: icon scale-in
  tl.from(icon, {
    scale: 0,
    rotation: -180,
    duration: 0.4,
    ease: 'back.out(1.7)'
  });

  // Secondary: background pulse (50% intensity)
  tl.to(background, {
    scale: 1.1,
    opacity: 0.3,
    duration: 0.3,
    ease: 'power2.out'
  }, '<0.1');

  tl.to(background, {
    scale: 1.5,
    opacity: 0,
    duration: 0.4,
    ease: 'power2.out'
  });

  // Tertiary: subtle container glow
  tl.to(container, {
    boxShadow: '0 0 30px rgba(var(--success-rgb), 0.3)',
    duration: 0.2,
    ease: 'power2.out'
  }, '<');

  tl.to(container, {
    boxShadow: '0 0 0px rgba(var(--success-rgb), 0)',
    duration: 0.4,
    ease: 'power2.out'
  });

  return tl;
}

// Error shake with flash secondary
function errorWithFlash(element, container) {
  const tl = gsap.timeline();

  // Primary: shake animation
  tl.to(element, {
    x: [-10, 10, -8, 8, -5, 5, 0],
    duration: 0.5,
    ease: 'power2.out'
  });

  // Secondary: background flash (30% intensity)
  tl.to(container, {
    backgroundColor: 'rgba(var(--error-rgb), 0.1)',
    duration: 0.1
  }, '<');

  tl.to(container, {
    backgroundColor: 'transparent',
    duration: 0.4,
    ease: 'power2.out'
  });

  return tl;
}

// Hover state with icon wiggle secondary
function hoverWithIconWiggle(card, icon) {
  const tl = gsap.timeline({ paused: true });

  // Primary: card lift
  tl.to(card, {
    y: -5,
    boxShadow: '0 10px 30px rgba(0,0,0,0.15)',
    duration: 0.2,
    ease: 'power2.out'
  });

  // Secondary: icon wiggle (subtle, 40% intensity)
  tl.to(icon, {
    rotation: [0, -5, 5, -3, 3, 0],
    duration: 0.4,
    ease: 'power2.inOut'
  }, '<0.1');

  return tl;
}

Framer Motion

import { motion, AnimatePresence } from 'framer-motion';

// Button with ripple secondary
export const ButtonWithRipple: React.FC<{ children: React.ReactNode }> = ({
  children
}) => {
  const [ripples, setRipples] = useState<{ x: number; y: number; id: number }[]>([]);

  const handleClick = (e: React.MouseEvent) => {
    const rect = e.currentTarget.getBoundingClientRect();
    const newRipple = {
      x: e.clientX - rect.left,
      y: e.clientY - rect.top,
      id: Date.now()
    };
    setRipples(prev => [...prev, newRipple]);
  };

  return (
    <motion.button
      onClick={handleClick}
      whileTap={{ scale: 0.95 }} // Primary
      transition={{ type: 'spring', stiffness: 400, damping: 25 }}
      style={{ position: 'relative', overflow: 'hidden' }}
    >
      {children}

      {/* Secondary: ripples */}
      <AnimatePresence>
        {ripples.map(ripple => (
          <motion.span
            key={ripple.id}
            initial={{ scale: 0, opacity: 0.5 }}
            animate={{ scale: 2.5, opacity: 0 }}
            exit={{ opacity: 0 }}
            transition={{ duration: 0.4 }}
            onAnimationComplete={() => {
              setRipples(prev => prev.filter(r => r.id !== ripple.id));
            }}
            style={{
              position: 'absolute',
              left: ripple.x,
              top: ripple.y,
              width: 100,
              height: 100,
              marginLeft: -50,
              marginTop: -50,
              borderRadius: '50%',
              backgroundColor: 'rgba(255,255,255,0.3)',
              pointerEvents: 'none'
            }}
          />
        ))}
      </AnimatePresence>
    </motion.button>
  );
};

// Success notification with secondaries
const successVariants = {
  hidden: { scale: 0, opacity: 0 },
  visible: {
    scale: 1,
    opacity: 1,
    transition: { type: 'spring', stiffness: 300, damping: 20 }
  }
};

const glowVariants = {
  hidden: { scale: 0.5, opacity: 0 },
  visible: {
    scale: [0.5, 1.2, 1.5],
    opacity: [0.4, 0.2, 0],
    transition: { duration: 0.6, times: [0, 0.5, 1] }
  }
};

export const SuccessNotification: React.FC = () => (
  <motion.div className="success-container">
    {/* Secondary: glow */}
    <motion.div
      className="glow"
      variants={glowVariants}
      initial="hidden"
      animate="visible"
    />

    {/* Primary: icon */}
    <motion.div
      className="icon"
      variants={successVariants}
      initial="hidden"
      animate="visible"
    >
      checkmark
    </motion.div>
  </motion.div>
);

Remotion

import { useCurrentFrame, interpolate, spring, AbsoluteFill } from 'remotion';

// Button press with ripple secondary
export const ButtonPressWithRipple: React.FC<{
  pressFrame: number;
  children: React.ReactNode;
}> = ({ pressFrame, children }) => {
  const frame = useCurrentFrame();

  // Primary: button scale
  const buttonScale = interpolate(
    frame,
    [pressFrame, pressFrame + 3, pressFrame + 10],
    [1, 0.95, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  // Secondary: ripple (30% visual weight)
  const rippleProgress = interpolate(
    frame,
    [pressFrame, pressFrame + 12],
    [0, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  const rippleScale = interpolate(rippleProgress, [0, 1], [0, 2.5]);
  const rippleOpacity = interpolate(rippleProgress, [0, 0.3, 1], [0.5, 0.3, 0]);

  return (
    <div style={{ position: 'relative' }}>
      {/* Secondary layer - behind primary */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center',
          pointerEvents: 'none'
        }}
      >
        <div
          style={{
            width: 50,
            height: 50,
            borderRadius: '50%',
            backgroundColor: 'rgba(255,255,255,0.5)',
            transform: `scale(${rippleScale})`,
            opacity: rippleOpacity
          }}
        />
      </div>

      {/* Primary layer */}
      <div style={{ transform: `scale(${buttonScale})` }}>
        {children}
      </div>
    </div>
  );
};

// Celebration with confetti secondary
export const CelebrationMoment: React.FC<{
  triggerFrame: number;
  children: React.ReactNode;
}> = ({ triggerFrame, children }) => {
  const frame = useCurrentFrame();

  // Primary: main element
  const primarySpring = spring({
    frame: frame - triggerFrame,
    fps: 30,
    config: { damping: 10, stiffness: 200 }
  });

  const primaryScale = interpolate(primarySpring, [0, 1], [0, 1]);

  // Secondary: background pulse
  const pulseProgress = interpolate(
    frame,
    [triggerFrame, triggerFrame + 20],
    [0, 1],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  const pulseScale = interpolate(pulseProgress, [0, 1], [0.8, 1.5]);
  const pulseOpacity = interpolate(pulseProgress, [0, 0.3, 1], [0.3, 0.15, 0]);

  // Tertiary: screen flash
  const flashOpacity = interpolate(
    frame,
    [triggerFrame, triggerFrame + 3, triggerFrame + 10],
    [0, 0.1, 0],
    { extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }
  );

  return (
    <AbsoluteFill>
      {/* Tertiary: screen flash */}
      <div
        style={{
          position: 'absolute',
          inset: 0,
          backgroundColor: '#FFD700',
          opacity: flashOpacity,
          pointerEvents: 'none'
        }}
      />

      {/* Secondary: pulse */}
      <div
        style={{
          position: 'absolute',
          inset: '50%',
          width: 200,
          height: 200,
          marginLeft: -100,
          marginTop: -100,
          borderRadius: '50%',
          backgroundColor: '#FFD700',
          transform: `scale(${pulseScale})`,
          opacity: pulseOpacity
        }}
      />

      {/* Primary: main content */}
      <div
        style={{
          transform: `scale(${primaryScale})`,
          display: 'flex',
          alignItems: 'center',
          justifyContent: 'center'
        }}
      >
        {children}
      </div>
    </AbsoluteFill>
  );
};

Intensity Guidelines

ContextSecondary IntensityNotes
Enterprise/Professional20-30%Very subtle
Consumer/Standard30-50%Noticeable but not distracting
Playful/Gaming50-70%More pronounced
Celebration/Achievement60-80%Peak moments only

Accessibility

const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

// Remove secondary actions entirely for reduced motion
// Keep only primary action
const animation = prefersReducedMotion
  ? { /* primary only */ }
  : { /* primary + secondaries */ };

Common Mistakes

  1. Secondary steals focus - Too intense, distracts from primary
  2. Wrong timing - Secondary should support, not lead
  3. Too many secondaries - Creates visual noise
  4. Mismatched mood - Secondary contradicts primary's emotion
  5. Performance hit - Complex secondaries on low-end devices

Related Skills

  • anticipation - Secondary elements may have their own micro-anticipations
  • follow-through - Secondary actions have their own settling behavior
  • exaggeration - Determines intensity difference between primary and secondary
  • squash-stretch - Secondary elements may react with delayed deformation
  • arc-motion - Secondary elements may follow their own arc trajectories

دانلود ZIP