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.
<!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 |