# clip-path: inset() as an Animation Primitive

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

> SHOULD: Reach for `clip-path: inset(t r b l)` for reveals, wipes, hold-to-delete overlays, seamless tab-color swaps, and comparison sliders — each value eats in from that side, the element keeps its box, so nothing below it moves and no layout is recalculated (unlike animating `height`).

Reach for clip-path: inset() to reveal, wipe, and mask without touching layout

Read inset(top right bottom left) as "how far the clip eats in from each side": inset(0 0 100% 0) removes the element entirely from the bottom up, and animating to inset(0 0 0 0) wipes it back open. The element keeps its box the whole time, so unlike a height animation nothing below it moves and no layout is recalculated. The same primitive gives you a hold-to-delete overlay (fill left-to-right while the button is held), a seamless tab color change (duplicate the label and clip the active copy, so there is no crossfade), and a comparison slider. A hold-to-delete overlay is also the canonical place to spend asymmetric timing — clip-path 2s linear on press, 200ms ease-out on release; see animations-emil-asymmetric.

## Rule snippet

```tsx
.reveal { clip-path: inset(0 0 100% 0); transition: clip-path 400ms ease-out; }
.reveal.is-visible { clip-path: inset(0 0 0 0); }
```

## Bad — do not do this

`animations-clip-path-reveal-bad`

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

/**
 * Bad: the same bottom-up reveal, but built by animating `height` from 0.
 * The container's box really does grow, so every sibling below it is shoved
 * down for the whole 700ms — a reveal that reflows the page.
 */
export function ClipPathRevealBad() {
  const [revealed, setRevealed] = useState(false);
  const frame = useRef(0);

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

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

  return (
    <div className="w-full space-y-4">
      <button
        onClick={replay}
        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"
      >
        Replay reveal
      </button>

      <div className="rounded-lg bg-muted p-3">
        <div
          className="overflow-hidden"
          style={{
            transition: 'height 700ms cubic-bezier(0.77, 0, 0.175, 1)',
            height: revealed ? '104px' : '0px',
          }}
        >
          <div className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground">
            <p className="font-medium">Deployment summary</p>
            <p className="text-muted-foreground mt-1">
              12 files changed across 3 packages. Build finished in 41s.
            </p>
          </div>
        </div>

        <p className="mt-3 text-xs text-muted-foreground">
          ↑ this line is pushed down for the whole animation
        </p>
      </div>

      <p className="text-xs text-destructive">
        Animating height re-runs layout every frame and drags every sibling below with it. The reveal is real, but so
        is the reflow.
      </p>
    </div>
  );
}
```

## Good — do this

`animations-clip-path-reveal-good`

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

/**
 * Good: the same 700ms bottom-up reveal, driven by `clip-path: inset()`.
 *
 * `inset(0 0 100% 0)` means "eat 100% in from the bottom" — the element is fully
 * clipped away but still occupies its box. Animating to `inset(0 0 0 0)` wipes it
 * open without ever touching layout, so nothing below it moves.
 */
export function ClipPathRevealGood() {
  const [revealed, setRevealed] = useState(false);
  const frame = useRef(0);
  const reduced = useSimulatedReducedMotion();

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

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

  const reveal = reduced
    ? {
        clipPath: 'inset(0 0 0 0)',
        transition: revealed ? 'opacity 150ms linear' : 'none',
        opacity: revealed ? 1 : 0,
      }
    : {
        transition: revealed ? 'clip-path 700ms cubic-bezier(0.77, 0, 0.175, 1)' : 'none',
        clipPath: revealed ? 'inset(0 0 0 0)' : 'inset(0 0 100% 0)',
        opacity: 1,
      };

  return (
    <div className="w-full space-y-4">
      <div className="flex flex-wrap items-center gap-3">
        <button
          onClick={replay}
          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"
        >
          Replay reveal
        </button>
        <ReducedMotionSwitch />
      </div>

      <div className="rounded-lg bg-muted p-3">
        <div style={reveal}>
          <div className="rounded-md border border-border bg-card p-3 text-sm text-card-foreground">
            <p className="font-medium">Deployment summary</p>
            <p className="text-muted-foreground mt-1">
              12 files changed across 3 packages. Build finished in 41s.
            </p>
          </div>
        </div>

        <p className="mt-3 text-xs text-muted-foreground">
          ↑ this line never moves — the box was always its full size
        </p>
      </div>

      <p className="text-xs text-success">
        inset(0 0 100% 0) → inset(0 0 0 0): the clip eats in from the bottom, so the card wipes open in place. Zero
        layout work, zero shift. Reduced motion skips the wipe and fades.
      </p>
    </div>
  );
}
```

## References

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