# Don't Signal State With Opacity Alone

**NEVER** · **ID:** `animations-lottie-never-opacity-only` · **Category:** animations
**Source:** [LottieFiles](https://github.com/lottiefiles/motion-design-skill)
**Interactive version:** https://ui-guides-agent-rules.netlify.app/principles/animations-lottie-never-opacity-only

> NEVER: Signal an important state change with opacity alone — peripheral vision reads movement, not luminance, so a "Saved" pill that fades in place off-axis is frequently never seen. Pair the fade with position or scale (a 6px rise is enough). Motion is the REINFORCEMENT channel only: the state still needs `role="status"` / `aria-live="polite"`, and since the transform is dropped under `prefers-reduced-motion`, the opacity change must remain sufficient on its own.

Pair any important state change with position or scale — a pure fade is nearly invisible off-axis

Peripheral vision is poor at absolute luminance and very good at movement. A "Saved" confirmation that fades 0 → 1 in place, in a corner the user is not looking at because they are still looking at the button they just pressed, frequently never registers at all — so they press Save again. Add a 6px rise and the same pill is caught off-axis without a glance. ACCESSIBILITY: motion is the REINFORCEMENT channel here, never the accessible one. The confirmation still needs to be a real live region (role="status" / aria-live="polite"), because a screen-reader user gets nothing from either the fade or the rise, and WCAG 1.4.1 has the same shape — no important state may depend on a single perceptual channel. And under prefers-reduced-motion the transform is dropped, which means the opacity change alone must remain sufficient on its own: the rise makes a good signal better, it is not allowed to be the only thing carrying the state.

## Bad — do not do this

`animations-lottie-never-opacity-only-bad`

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

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';

/**
 * Bad: the "Saved" confirmation is signalled with opacity alone. Peripheral vision
 * is poor at absolute luminance and good at movement, so a pill that simply
 * brightens in place — away from where the user is actually looking, which is the
 * button they just pressed — frequently never registers. They press Save again.
 */
export function LottieNeverOpacityOnlyBad() {
  const [saved, setSaved] = useState(false);
  const [clicks, setClicks] = useState(0);

  useEffect(() => {
    if (!saved) return;
    const t = setTimeout(() => setSaved(false), 1800);
    return () => clearTimeout(t);
  }, [saved, clicks]);

  return (
    <div className="w-full space-y-4">
      <p className="text-xs text-muted-foreground">
        Keep your eyes on the Save button. Did the corner change?
      </p>

      <div className="relative flex h-40 items-end rounded-lg border border-border bg-muted/40 p-4">
        {/* Opacity only. No transform, no aria-live. */}
        <span
          className="absolute right-4 top-4 rounded-full border border-border bg-card px-3 py-1 text-xs text-card-foreground"
          style={{
            transition: `opacity 220ms ${EASE}`,
            opacity: saved ? 1 : 0,
          }}
        >
          Saved
        </span>

        <button
          onClick={() => {
            setClicks((c) => c + 1);
            setSaved(true);
          }}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Save
        </button>
        <span className="ml-3 text-xs tabular-nums text-muted-foreground">
          Save pressed <strong className="text-destructive">{clicks}×</strong>
        </span>
      </div>

      <p className="text-xs text-destructive">
        A pure luminance change is nearly invisible off-axis, and there is no{' '}
        <code>aria-live</code> region either — so neither a sighted user glancing at the button nor a screen reader
        user is told the save landed. Both respond the same way: press Save again.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-lottie-never-opacity-only-good`

```tsx
import { useEffect, useState } from 'react';
import { ReducedMotionSwitch } from '@/components/demo-kit/ReducedMotionSwitch';
import { useSimulatedReducedMotion } from '@/hooks/useSimulatedReducedMotion';

const EASE = 'cubic-bezier(0.23, 1, 0.32, 1)';
const RISE_PX = 6;

/**
 * Good: the same confirmation, now carried by opacity AND a 6px rise. Peripheral
 * vision is tuned to movement, so the pill registers even while the eye is still on
 * the button.
 *
 * Motion is the reinforcement channel, not the accessible one: the region is a
 * `role="status"` / `aria-live="polite"` live region regardless, and under reduced
 * motion the transform is dropped and the opacity change alone must — and does —
 * remain sufficient.
 */
export function LottieNeverOpacityOnlyGood() {
  const [saved, setSaved] = useState(false);
  const [clicks, setClicks] = useState(0);
  const reduced = useSimulatedReducedMotion();

  useEffect(() => {
    if (!saved) return;
    const t = setTimeout(() => setSaved(false), 1800);
    return () => clearTimeout(t);
  }, [saved, clicks]);

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <p className="text-xs text-muted-foreground">Keep your eyes on the Save button.</p>
        <ReducedMotionSwitch />
      </div>

      <div className="relative flex h-40 items-end rounded-lg border border-border bg-muted/40 p-4">
        <span
          role="status"
          aria-live="polite"
          className="absolute right-4 top-4 rounded-full border border-border bg-card px-3 py-1 text-xs text-card-foreground"
          style={
            reduced
              ? { transition: 'opacity 150ms linear', transform: 'none', opacity: saved ? 1 : 0 }
              : {
                  transition: `transform 220ms ${EASE}, opacity 220ms ${EASE}`,
                  transform: saved ? 'translateY(0)' : `translateY(${RISE_PX}px)`,
                  opacity: saved ? 1 : 0,
                }
          }
        >
          {saved ? 'Saved' : ''}
        </span>

        <button
          onClick={() => {
            setClicks((c) => c + 1);
            setSaved(true);
          }}
          className="px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm transition-transform duration-150 ease-out active:scale-[0.97] focus-visible:outline-hidden focus-visible:ring-2 focus-visible:ring-ring"
        >
          Save
        </button>
        <span className="ml-3 text-xs tabular-nums text-muted-foreground">
          Save pressed <strong className="text-success">{clicks}×</strong>
        </span>
      </div>

      <p className="text-xs text-success">
        Fade plus a {RISE_PX}px rise: the movement is what your peripheral vision actually catches, so the confirmation
        registers without looking at it. The pill is still an <code>aria-live</code> region, and with reduced motion on
        the rise disappears while the announcement and the opacity change carry the state on their own.
      </p>
    </div>
  );
}
```

## References

- [LottieFiles — motion-design skill](https://github.com/lottiefiles/motion-design-skill/blob/main/skills/motion-design/SKILL.md)
- [MDN — ARIA live regions](https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Guides/Live_regions)
- [WCAG 2.2 — Status Messages](https://www.w3.org/WAI/WCAG22/Understanding/status-messages.html)
