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
| Level | Intensity | Role | Example |
|---|---|---|---|
| Primary | 100% | Main focus | Button press, modal open |
| Secondary | 30-50% | Reinforcement | Background pulse, icon wiggle |
| Tertiary | 10-20% | Ambient | Particle 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
| Timing | Pattern | Use Case |
|---|---|---|
| Simultaneous | Secondary starts with primary | Immediate reinforcement |
| During | Secondary runs through primary | Continuous enhancement |
| After | Secondary follows primary completion | Conclusion emphasis |
| Anticipation | Secondary starts before primary | Building 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
| Context | Secondary Intensity | Notes |
|---|---|---|
| Enterprise/Professional | 20-30% | Very subtle |
| Consumer/Standard | 30-50% | Noticeable but not distracting |
| Playful/Gaming | 50-70% | More pronounced |
| Celebration/Achievement | 60-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
- Secondary steals focus - Too intense, distracts from primary
- Wrong timing - Secondary should support, not lead
- Too many secondaries - Creates visual noise
- Mismatched mood - Secondary contradicts primary's emotion
- 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