DEV Community

Fazal Shah
Fazal Shah

Posted on

Lottie Micro-Interactions: Patterns for Hover, Click, and State Changes

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
  );
}
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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)