Tailwind UI Pattern Registry for humans and agents

clip-path wipe scroll reveal before-after comparison vanilla-js motion-one

Clip-Path Wipe

Clip-Path Wipe

Scroll-progress-driven clip-path reveal that wipes between two stacked layers — a wireframe and a design mockup — as the user scrolls.

Motion One Scroll
Live Preview
HTML
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Clip-Path Wipe — Motion Recipe</title>
  <script src="https://cdn.tailwindcss.com"></script>
  <style>
    .wipe-stage {
      position: relative;
      overflow: hidden;
      border-radius: 16px;
      user-select: none;
    }

    .wipe-layer {
      position: absolute;
      inset: 0;
    }

    .wipe-layer-top {
      clip-path: inset(0 100% 0 0);
      transition: none; /* driven by script */
      z-index: 2;
    }

    /* Divider line */
    .wipe-divider {
      position: absolute;
      top: 0;
      bottom: 0;
      width: 3px;
      background: white;
      box-shadow: 0 0 12px rgba(255,255,255,0.6);
      z-index: 3;
      left: 0%;
      transform: translateX(-50%);
      pointer-events: none;
    }

    /* Handle icon */
    .wipe-handle {
      position: absolute;
      top: 50%;
      left: 0%;
      transform: translate(-50%, -50%);
      z-index: 4;
      width: 40px;
      height: 40px;
      border-radius: 50%;
      background: white;
      box-shadow: 0 2px 12px rgba(0,0,0,0.25);
      display: flex;
      align-items: center;
      justify-content: center;
      pointer-events: none;
    }

    @media (prefers-reduced-motion: reduce) {
      .wipe-layer-top,
      .wipe-divider,
      .wipe-handle { transition: none !important; }
    }
  </style>
</head>
<body class="bg-slate-950 text-slate-200 font-sans antialiased">

  <!-- Hero -->
  <div class="mx-auto max-w-3xl px-8 pt-16 pb-12 text-center">
    <span class="mb-5 inline-block rounded-full border border-violet-500/20 bg-violet-500/10 px-3.5 py-1 text-[0.7rem] font-semibold uppercase tracking-widest text-violet-400">Motion · Scroll Wipe</span>
    <h1 class="mb-5 text-4xl font-black leading-[1.1] text-slate-100 sm:text-5xl">Clip-Path Wipe</h1>
    <p class="mx-auto max-w-md text-base leading-relaxed text-slate-400">Scroll down — the <code class="rounded bg-slate-800 px-1.5 py-0.5 font-mono text-xs text-violet-300">clip-path</code> wipe follows your scroll position, revealing the design layer beneath.</p>
    <div class="mt-8 flex items-center justify-center gap-2 text-sm text-slate-500">
      <svg class="h-4 w-4 animate-bounce text-violet-400" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" aria-hidden="true"><path stroke-linecap="round" stroke-linejoin="round" d="M19.5 8.25l-7.5 7.5-7.5-7.5"/></svg>
      Scroll down
    </div>
  </div>

  <!-- Wipe demo — sticky inside scroll section -->
  <div class="relative h-[200vh]">
    <div class="sticky top-0 flex h-screen items-center justify-center px-8" id="wipe-container">

      <div class="w-full max-w-2xl">
        <p class="mb-4 text-xs font-semibold uppercase tracking-widest text-slate-500">
          <span id="progress-label">0</span>% revealed
        </p>

        <!-- Wipe stage -->
        <div class="wipe-stage aspect-[16/9]" id="wipe-stage" aria-label="Wipe comparison: mockup vs design" role="img">

          <!-- Bottom layer: "Mockup" (dark wireframe) -->
          <div class="wipe-layer bg-slate-800 flex items-center justify-center" aria-hidden="true">
            <div class="w-full h-full p-8 flex flex-col gap-4">
              <div class="flex gap-3 items-center">
                <div class="h-8 w-8 rounded-full bg-slate-600"></div>
                <div class="h-4 w-32 rounded bg-slate-600"></div>
                <div class="ml-auto h-4 w-20 rounded bg-slate-600"></div>
              </div>
              <div class="flex-1 grid grid-cols-3 gap-3 mt-2">
                <div class="col-span-2 rounded-xl bg-slate-700 p-4">
                  <div class="h-3 w-3/4 rounded bg-slate-600 mb-2"></div>
                  <div class="h-3 w-full rounded bg-slate-600 mb-2"></div>
                  <div class="h-3 w-5/6 rounded bg-slate-600"></div>
                  <div class="mt-4 h-24 rounded-lg bg-slate-600/60"></div>
                </div>
                <div class="flex flex-col gap-3">
                  <div class="rounded-xl bg-slate-700 p-3 flex-1">
                    <div class="h-3 w-full rounded bg-slate-600 mb-2"></div>
                    <div class="h-6 w-3/4 rounded bg-slate-600"></div>
                  </div>
                  <div class="rounded-xl bg-slate-700 p-3 flex-1">
                    <div class="h-3 w-full rounded bg-slate-600 mb-2"></div>
                    <div class="h-6 w-3/4 rounded bg-slate-600"></div>
                  </div>
                </div>
              </div>
              <!-- Wireframe label -->
              <div class="absolute bottom-4 left-4 rounded-full bg-slate-700 px-3 py-1 text-xs font-semibold text-slate-400">Wireframe</div>
            </div>
          </div>

          <!-- Top layer: "Design" (coloured, revealed by clip-path) -->
          <div class="wipe-layer wipe-layer-top bg-gradient-to-br from-indigo-600 to-violet-700 flex items-center justify-center" id="wipe-top" aria-hidden="true">
            <div class="w-full h-full p-8 flex flex-col gap-4">
              <div class="flex gap-3 items-center">
                <div class="h-8 w-8 rounded-full bg-white/30"></div>
                <div class="h-4 w-32 rounded bg-white/40"></div>
                <div class="ml-auto h-4 w-20 rounded-full bg-white/90"></div>
              </div>
              <div class="flex-1 grid grid-cols-3 gap-3 mt-2">
                <div class="col-span-2 rounded-xl bg-white/15 backdrop-blur-sm p-4 border border-white/20">
                  <div class="h-3 w-3/4 rounded bg-white/60 mb-2"></div>
                  <div class="h-3 w-full rounded bg-white/40 mb-2"></div>
                  <div class="h-3 w-5/6 rounded bg-white/40"></div>
                  <div class="mt-4 h-24 rounded-lg bg-white/10 border border-white/20 flex items-center justify-center">
                    <div class="h-12 w-12 rounded-full bg-white/20"></div>
                  </div>
                </div>
                <div class="flex flex-col gap-3">
                  <div class="rounded-xl bg-white/15 p-3 flex-1 border border-white/20">
                    <div class="h-3 w-full rounded bg-white/40 mb-2"></div>
                    <div class="h-6 w-3/4 rounded bg-white/60"></div>
                  </div>
                  <div class="rounded-xl bg-white/15 p-3 flex-1 border border-white/20">
                    <div class="h-3 w-full rounded bg-white/40 mb-2"></div>
                    <div class="h-6 w-3/4 rounded bg-white/60"></div>
                  </div>
                </div>
              </div>
              <!-- Design label -->
              <div class="absolute bottom-4 right-4 rounded-full bg-white/20 px-3 py-1 text-xs font-semibold text-white border border-white/30">Design</div>
            </div>
          </div>

          <!-- Divider + handle -->
          <div class="wipe-divider" id="wipe-divider"></div>
          <div class="wipe-handle" id="wipe-handle" aria-hidden="true">
            <svg class="h-5 w-5 text-slate-700" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M8.25 15L12 18.75 15.75 15m-7.5-6L12 5.25 15.75 9"/></svg>
          </div>
        </div>

        <p class="mt-4 text-xs text-slate-500 text-center">Scroll-linked <code class="font-mono">clip-path: inset(0 X% 0 0)</code></p>
      </div>
    </div>
  </div>

  <!-- Explanation section -->
  <div class="mx-auto max-w-3xl px-8 pb-24">
    <p class="mb-5 text-xs font-semibold uppercase tracking-[0.14em] text-slate-500">How it works</p>
    <div class="rounded-2xl border border-white/[0.06] bg-white/[0.03] p-8 space-y-4 text-sm text-slate-400 leading-relaxed">
      <p>The wipe stage uses two absolutely positioned layers. The top layer starts with <code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">clip-path: inset(0 100% 0 0)</code> — fully hidden from the right. As scroll progress increases, the right inset shrinks toward <code class="rounded bg-slate-800 px-1 font-mono text-xs text-violet-300">inset(0 0% 0 0)</code>, revealing the design layer.</p>
      <p>An easeOutQuad function softens the motion so the transition decelerates as it approaches full reveal, matching the feel of a natural swipe gesture.</p>
      <p>The divider line and handle icon track the same percentage, creating a satisfying visual link between your scroll position and the reveal edge.</p>
    </div>
  </div>

  <script>
    // easeOutQuad: fast start, soft finish
    function easeOutQuad(t) { return t * (2 - t); }

    const wipeTop = document.getElementById('wipe-top');
    const wipeDivider = document.getElementById('wipe-divider');
    const wipeHandle = document.getElementById('wipe-handle');
    const progressLabel = document.getElementById('progress-label');
    const container = document.getElementById('wipe-container');

    const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;

    function update() {
      const scrollTop = window.scrollY;
      // The sticky section starts after the hero (~400px), spans one viewport height
      const heroH = container.closest('.relative').offsetTop;
      const sectionH = window.innerHeight;
      const rawProgress = Math.max(0, Math.min(1, (scrollTop - heroH) / sectionH));
      const progress = reducedMotion ? 1 : easeOutQuad(rawProgress);

      const revealed = progress * 100;
      const rightInset = 100 - revealed;

      wipeTop.style.clipPath = `inset(0 ${rightInset.toFixed(2)}% 0 0)`;
      wipeDivider.style.left = `${revealed.toFixed(2)}%`;
      wipeHandle.style.left = `${revealed.toFixed(2)}%`;
      progressLabel.textContent = Math.round(revealed);
    }

    window.addEventListener('scroll', update, { passive: true });
    update(); // initial render
  </script>

</body>
</html>

A sticky container holds two absolutely positioned layers. The top layer (design) starts with clip-path: inset(0 100% 0 0) — completely hidden from the right edge. A scroll event listener maps window.scrollY to a 0–1 progress value, passes it through easeOutQuad, then applies the result as:

clip-path: inset(0 {100 - revealed}% 0 0)

A divider line and handle icon are positioned at the same percentage, creating a visual scrubber that tracks the clip edge.

EaseOutQuad (t * (2 - t)) makes the wipe decelerate as it approaches full reveal — the motion feels like dragging a heavy panel that slows at the end rather than stopping abruptly.

prefers-reduced-motion — when the user prefers reduced motion, progress is clamped to 1 immediately so the design layer is shown fully revealed without animation.

No dependencies — vanilla scroll event with { passive: true }. No Motion One or GSAP required for this recipe. The clip-path approach is more performant than opacity fades because the compositor can skip painting the hidden portion entirely.

Key measurements

| Variable | Default | Effect | |---|---|---| | Section height | 100vh | Travel distance for a full wipe | | easeOutQuad | t * (2 - t) | Deceleration curve | | Right inset | 100% → 0% | Clip-path from hidden to revealed |