# Keep Spring Bounce Subtle

**SHOULD** · **ID:** `animations-emil-subtle-bounce` · **Category:** animations
**Source:** [Emil Kowalski](https://emilkowalski.com/)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-emil-subtle-bounce

> SHOULD: Keep spring bounce within 0.1–0.3 (`{ type: "spring", duration: 0.5, bounce: 0.2 }`) and reserve any bounce for drag-to-dismiss and playful interactions — everyday menus, dropdowns and modals are usually better with none.

Keep spring bounce between 0.1 and 0.3, and reserve real bounce for drag-to-dismiss and playful moments

Bounce is a claim that the element has mass and momentum. That claim is true for something you flung with your finger, and false for a settings popover that simply appeared. Above roughly 0.3 the overshoot becomes a visible wobble that reads as cartoonish on utility UI and, on a control opened dozens of times a day, as broken. In Apple-style spring terms: `{ type: "spring", duration: 0.5, bounce: 0.2 }`. Everyday menus, dropdowns and modals are usually better with no bounce at all.

## Bad — do not do this

`animations-emil-subtle-bounce-bad`

```tsx
import { useEffect, useRef, useState } from 'react';

/**
 * Bad: a high-bounce spring on a settings popover. It overshoots hard and
 * wobbles back — roughly bounce 0.6+. It reads as a toy, and on a control the
 * user opens fifty times a day it reads as broken.
 */
export function EmilSubtleBounceBad() {
  const [entered, setEntered] = useState(false);
  const frame = useRef(0);

  useEffect(() => {
    frame.current = requestAnimationFrame(() => setEntered(true));
    return () => cancelAnimationFrame(frame.current);
  }, []);

  const replay = () => {
    setEntered(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setEntered(true))
    );
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={replay}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay popover
      </button>

      <div className="h-28 rounded-lg bg-muted p-3 overflow-hidden">
        <div
          className="origin-top rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={{
            // Huge overshoot: the curve blows past 1 and springs back.
            transition: entered
              ? 'transform 520ms cubic-bezier(0.34, 2.8, 0.5, 1)'
              : 'none',
            transform: entered ? 'scale(1) translateY(0)' : 'scale(0.9) translateY(-14px)',
            opacity: entered ? 1 : 0,
          }}
        >
          Notification settings
        </div>
      </div>

      <p className="text-xs text-error">
        Bounce ~0.6 on a utility popover — cartoonish, and it draws attention it has not earned
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-subtle-bounce-good`

```tsx
import { useEffect, useRef, useState } from 'react';
import { useMediaQuery } from '@/hooks/useMediaQuery';

/**
 * Good: bounce kept in the 0.1–0.3 range — a single, barely-perceptible
 * overshoot that gives the popover weight without turning it into a toy.
 * Save real bounce for drag-to-dismiss and other playful, physical gestures.
 */
export function EmilSubtleBounceGood() {
  const [entered, setEntered] = useState(false);
  const frame = useRef(0);
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

  useEffect(() => {
    frame.current = requestAnimationFrame(() => setEntered(true));
    return () => cancelAnimationFrame(frame.current);
  }, []);

  const replay = () => {
    setEntered(false);
    frame.current = requestAnimationFrame(() =>
      requestAnimationFrame(() => setEntered(true))
    );
  };

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={replay}
        className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
      >
        Replay popover
      </button>

      <div className="h-28 rounded-lg bg-muted p-3 overflow-hidden">
        <div
          className="origin-top rounded-md border border-border bg-card p-3 text-sm text-card-foreground"
          style={{
            transition: reduced
              ? entered
                ? 'opacity 150ms linear'
                : 'none'
              : entered
                ? 'transform 320ms cubic-bezier(0.34, 1.28, 0.64, 1)'
                : 'none',
            transform: reduced
              ? 'none'
              : entered
                ? 'scale(1) translateY(0)'
                : 'scale(0.96) translateY(-8px)',
            opacity: entered ? 1 : 0,
          }}
        >
          Notification settings
        </div>
      </div>

      <p className="text-xs text-success">
        Bounce ~0.2 — one small overshoot, then it settles. Reduced motion drops it to a fade.
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [animations.dev](https://animations.dev/)
