# Mask Imperfect Crossfades with Blur

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

> SHOULD: When a crossfade still reads as a ghosted double exposure, add a small transition-scoped `filter: blur(2px)` (2–4px, single element, only for the duration of the transition, always < 20px) so the two states fuse into one morph. This is the narrow carve-out from "never animate blur", which targets large, long-lived, or continuously-animating blurs — stay especially conservative in Safari.

Add a small blur to a crossfade so two overlapping states read as one transformation, not a double image

In a straight opacity crossfade both layers sit near 50% at the midpoint and stay separately legible, so the user perceives a ghosted double exposure rather than a change. A 2px blur on the outgoing layer destroys its legibility exactly where the overlap happens, and the eye fuses the two into a single morph. Note the tension with the blanket "never animate blur" performance rule: that rule targets large, long-lived, or continuously-animating blurs, which force expensive re-rasterization. A small (2–4px), short, transition-scoped blur on a single element is the deliberate carve-out — stay well under 20px, and be especially conservative in Safari.

## Bad — do not do this

`animations-emil-blur-crossfade-bad`

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

const PANELS = [
  { label: 'This week', value: '1,204 deploys' },
  { label: 'This month', value: '8,930 deploys' },
];

/**
 * Bad: a straight opacity crossfade. Mid-transition both layers are ~50%
 * opaque and perfectly legible, so the user sees two states double-exposed
 * instead of one state becoming another.
 */
export function EmilBlurCrossfadeBad() {
  const [index, setIndex] = useState(0);

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={() => setIndex((i) => (i === 0 ? 1 : 0))}
        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"
      >
        Swap range
      </button>

      <div className="relative h-24 rounded-lg border border-border bg-card">
        {PANELS.map((panel, i) => (
          <div
            key={panel.label}
            className="absolute inset-0 flex flex-col justify-center p-4"
            style={{
              transition: 'opacity 320ms ease-out',
              opacity: index === i ? 1 : 0,
            }}
          >
            <span className="text-xs text-muted-foreground">{panel.label}</span>
            <span className="text-xl font-semibold text-card-foreground">{panel.value}</span>
          </div>
        ))}
      </div>

      <p className="text-xs text-error">
        Both numbers stay readable at the halfway point — you see a ghosted double image, not a change
      </p>
    </div>
  );
}
```

## Good — do this

`animations-emil-blur-crossfade-good`

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

const PANELS = [
  { label: 'This week', value: '1,204 deploys' },
  { label: 'This month', value: '8,930 deploys' },
];

/**
 * Good: the outgoing layer picks up a 2px blur (and a hair of scale) while it
 * fades. The two states stop being separately legible at the midpoint, so the
 * eye reads one transformation instead of two overlapping panels.
 *
 * Keep the blur small (well under 20px) and scoped to the transition — a large
 * or long-lived blur is genuinely expensive to rasterize, especially in Safari.
 */
export function EmilBlurCrossfadeGood() {
  const [index, setIndex] = useState(0);
  const reduced = useMediaQuery('(prefers-reduced-motion: reduce)');

  return (
    <div className="w-full max-w-sm space-y-4">
      <button
        onClick={() => setIndex((i) => (i === 0 ? 1 : 0))}
        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"
      >
        Swap range
      </button>

      <div className="relative h-24 rounded-lg border border-border bg-card">
        {PANELS.map((panel, i) => {
          const active = index === i;
          return (
            <div
              key={panel.label}
              className="absolute inset-0 flex flex-col justify-center p-4"
              style={{
                transition: reduced
                  ? 'opacity 150ms linear'
                  : 'opacity 320ms ease-out, filter 320ms ease-out, transform 320ms ease-out',
                opacity: active ? 1 : 0,
                filter: reduced || active ? 'blur(0px)' : 'blur(2px)',
                transform: reduced || active ? 'scale(1)' : 'scale(0.98)',
              }}
            >
              <span className="text-xs text-muted-foreground">{panel.label}</span>
              <span className="text-xl font-semibold text-card-foreground">{panel.value}</span>
            </div>
          );
        })}
      </div>

      <p className="text-xs text-success">
        A 2px blur on the outgoing layer masks the overlap — the two states merge into one perceived change
      </p>
    </div>
  );
}
```

## References

- [Emil Kowalski — review-animations STANDARDS](https://github.com/emilkowalski/skills/tree/main/skills/review-animations)
- [MDN — filter](https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/filter)
