Micro-interactions are small animations triggered by user actions — hover effects, button clicks, form validation feedback, toggle states. Done right, they make an interface feel polished and responsive. Done wrong, they add lag and distraction. This guide covers every micro-interaction pattern with Lottie.
What Makes a Good Micro-Interaction
Before you start: preview every animation at IconKing to check timing and feel. A micro-interaction that takes more than 400ms feels slow. One that loops when it shouldn't is annoying.
Good micro-interactions are:
- Fast: 150–400ms for responses to user actions
- Purposeful: They communicate state, not just "look cool"
- Single-use: They play once per interaction, then stop
-
Accessible: They respect
prefers-reduced-motion
Pattern 1: Hover-Play Icon
import { useRef } from 'react';
import Lottie from 'lottie-react';
import type { LottieRefCurrentProps } from 'lottie-react';
export function BellIcon({ onClick }: { onClick?: () => void }) {
const lottieRef = useRef<LottieRefCurrentProps>(null);
return (
<button
onMouseEnter={() => lottieRef.current?.play()}
onMouseLeave={() => lottieRef.current?.stop()}
onFocus={() => lottieRef.current?.play()}
onBlur={() => lottieRef.current?.stop()}
onClick={onClick}
aria-label="Notifications"
style={{ background: 'none', border: 'none', cursor: 'pointer', padding: 8 }}
>
<div aria-hidden="true" style={{ width: 24, height: 24 }}>
<Lottie lottieRef={lottieRef} animationData={bellAnim} loop={false} autoplay={false} />
</div>
</button>
);
}
Key details: loop={false} plays once and stops. stop() returns to frame 0 so hover always starts fresh.
Pattern 2: Click-to-Play (Once)
export function LikeButton() {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const [liked, setLiked] = useState(false);
function handleClick() {
if (liked) return;
setLiked(true);
lottieRef.current?.play();
}
return (
<button onClick={handleClick} aria-label={liked ? 'Liked' : 'Like'} aria-pressed={liked}
style={{ background: 'none', border: 'none', cursor: liked ? 'default' : 'pointer' }}
>
<div aria-hidden="true" style={{ width: 32, height: 32 }}>
<Lottie lottieRef={lottieRef} animationData={heartAnim} loop={false} autoplay={false} />
</div>
</button>
);
}
Pattern 3: Toggle Animation (A → B → A)
export function MenuToggle({ onToggle }: { onToggle?: (open: boolean) => void }) {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const [open, setOpen] = useState(false);
function handleClick() {
const nextOpen = !open;
setOpen(nextOpen);
onToggle?.(nextOpen);
const anim = lottieRef.current;
if (!anim) return;
if (nextOpen) { anim.setDirection(1); anim.play(); }
else { anim.setDirection(-1); anim.play(); }
}
return (
<button onClick={handleClick} aria-label={open ? 'Close menu' : 'Open menu'} aria-expanded={open}
style={{ background: 'none', border: 'none', cursor: 'pointer' }}
>
<div aria-hidden="true" style={{ width: 32, height: 32 }}>
<Lottie lottieRef={lottieRef} animationData={toggleAnim} loop={false} autoplay={false} />
</div>
</button>
);
}
Pattern 4: Segment-Based State Machine
// Verify frame ranges in IconKing's layer panel
const STATES = {
idle: [0, 0] as [number, number],
hover: [0, 15] as [number, number],
click: [15, 30] as [number, number],
success: [30, 60] as [number, number],
} as const;
export function AnimatedButton({ onClick, label }: { onClick: () => Promise<void>; label: string }) {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const [state, setState] = React.useState<keyof typeof STATES>('idle');
useEffect(() => {
lottieRef.current?.playSegments(STATES[state], true);
}, [state]);
async function handleClick() {
setState('click');
try { await onClick(); setState('success'); }
catch { setState('idle'); }
}
return (
<button onMouseEnter={() => state === 'idle' && setState('hover')}
onMouseLeave={() => state === 'hover' && setState('idle')}
onClick={handleClick} aria-label={label} disabled={state === 'click'}
>
<div aria-hidden="true" style={{ width: 200, height: 48 }}>
<Lottie lottieRef={lottieRef} animationData={buttonAnim} loop={false} autoplay={false} />
</div>
</button>
);
}
Pattern 5: Scroll-Triggered Reveal
export function ScrollReveal({ animationData, width = 200, height = 200 }) {
const lottieRef = useRef<LottieRefCurrentProps>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [hasPlayed, setHasPlayed] = useState(false);
useEffect(() => {
if (hasPlayed) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
lottieRef.current?.play();
setHasPlayed(true);
observer.disconnect();
}
},
{ threshold: 0.3 }
);
if (containerRef.current) observer.observe(containerRef.current);
return () => observer.disconnect();
}, [hasPlayed]);
return (
<div ref={containerRef} style={{ width, height }}>
<Lottie lottieRef={lottieRef} animationData={animationData} loop={false} autoplay={false} />
</div>
);
}
Accessibility for Micro-Interactions
// Icon-only button
<button aria-label="Delete item">
<div aria-hidden="true"><Lottie animationData={trashAnim} ... /></div>
</button>
// Status feedback
<div role="status" aria-live="polite" aria-label="Saved successfully">
<Lottie animationData={checkAnim} ... />
</div>
prefers-reduced-motion
function useReducedMotion() {
return typeof window !== 'undefined' &&
window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
const reducedMotion = useReducedMotion();
if (reducedMotion) { lottieRef.current?.goToAndStop(totalFrames - 1, true); return; }
lottieRef.current?.play();
Summary
-
Hover animations:
play()on enter,stop()on leave,loop={false} -
Click-once:
play()on click, track played state to prevent re-trigger -
Toggles:
setDirection(1/-1)+play()for bidirectional animation -
Segment states:
playSegments([start, end])for multi-state animations -
Scroll reveals:
IntersectionObserver+observer.disconnect()after first play
Before building any micro-interaction, verify timing and feel in IconKing.
Top comments (0)