---
name: circle-wipe-transition
description: Use when you want "Playful, cinematic, modern" - Reveals pages through an expanding circle.
---

# Circle Wipe Transition

> **Category:** Loading / Transition  -  **Personality:** Playful, cinematic, modern
>
> **Best use:** Creative pages, route changes
>
> **Ratings:** Professional 3/5 - Casual 4/5 - Exotic 4/5 - Visual impact 4/5 - Difficulty 3/5 - Performance cost 2/5

## What it is

The circle wipe (a.k.a. **iris transition**) swaps one full view for another by growing a circular `clip-path` from a chosen point until it covers the screen. Because the incoming "page" is revealed *through* an expanding circle rather than a hard cut or fade, it reads as cinematic and deliberate — which is why it suits route changes, section switches and creative intros where the navigation itself should be part of the show. In this demo it toggles between two featured films on a streaming-style hero, and the circle grows straight out of the button you press.

## Dependencies / CDN

None — pure CSS `clip-path` + vanilla JS. The demo only loads display/body fonts (optional):

```html
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Onest:wght@400;500;600&family=Unbounded:wght@600;700;800&display=swap" rel="stylesheet">
```

## HTML

Two full-bleed "pages" stacked inside a clipped stage, plus a persistent trigger that doubles as the wipe origin:

```html
<div class="stage">
  <section class="view view--a is-current" aria-label="Featured: Golden Meridian"> ...page one... </section>
  <section class="view view--b" aria-hidden="true" aria-label="Featured: Neon Rain"> ...page two... </section>

  <!-- persistent chrome: the button is the circle's origin -->
  <button class="next" type="button" aria-label="Reveal next feature">Next feature &rarr;</button>
</div>
```

## CSS

```css
.stage{position:relative; overflow:hidden; border-radius:26px; isolation:isolate; min-height:620px}

/* both pages fill the stage and stack */
.view{position:absolute; inset:0; -webkit-clip-path:none; clip-path:none}
.view.is-current { z-index:1 }                 /* the page you currently see        */
.view.is-incoming{ z-index:2; will-change:clip-path } /* the page being revealed, on top */

/* the transition that grows the circle (note both prefixed + standard) */
.view.is-wiping{
  transition:-webkit-clip-path .82s cubic-bezier(.7,0,.2,1);
  transition:clip-path .82s cubic-bezier(.7,0,.2,1),
            -webkit-clip-path .82s cubic-bezier(.7,0,.2,1);
}

@media (prefers-reduced-motion: reduce){
  .view.is-wiping{transition:none}             /* JS also swaps instantly */
}
```

## JavaScript

```js
var stage = document.querySelector('.stage');
var views = { a: stage.querySelector('.view--a'), b: stage.querySelector('.view--b') };
var current = 'a', busy = false;
var reduce = window.matchMedia && matchMedia('(prefers-reduced-motion: reduce)').matches;

function setClip(el, v){ el.style.webkitClipPath = v; el.style.clipPath = v; }

// radius big enough to reach the FARTHEST stage corner from the origin
function coverRadius(x, y, w, h){
  var dx = Math.max(x, w - x), dy = Math.max(y, h - y);
  return Math.ceil(Math.sqrt(dx*dx + dy*dy)) + 2;
}

function finish(target, inEl, curEl){
  inEl.classList.remove('is-incoming', 'is-wiping');
  setClip(inEl, 'none');
  curEl.classList.remove('is-current'); curEl.setAttribute('aria-hidden', 'true');
  inEl.classList.add('is-current');     inEl.setAttribute('aria-hidden', 'false');
  current = target; busy = false;
}

function wipeTo(target, x, y){
  if(busy || target === current) return;
  var inEl = views[target], curEl = views[current];
  var r = stage.getBoundingClientRect();
  var R = coverRadius(x, y, r.width, r.height);

  inEl.classList.add('is-incoming');                  // raise above the current view
  setClip(inEl, 'circle(0px at '+x+'px '+y+'px)');     // hidden — set BEFORE paint, no flash
  if(reduce){ finish(target, inEl, curEl); return; }   // reduced motion = instant swap

  busy = true;
  void inEl.offsetWidth;                               // commit the 0-radius start state
  inEl.classList.add('is-wiping');
  setClip(inEl, 'circle('+R+'px at '+x+'px '+y+'px)');  // grow until it covers the stage

  var done = false;
  function onEnd(e){
    if(e && e.propertyName && e.propertyName.indexOf('clip-path') === -1) return;
    if(done) return; done = true;
    inEl.removeEventListener('transitionend', onEnd);
    finish(target, inEl, curEl);
  }
  inEl.addEventListener('transitionend', onEnd);
  setTimeout(onEnd, 1150);                             // safety if transitionend is missed
}

// the trigger button doubles as the circle's origin
var next = document.querySelector('.next');
next.addEventListener('click', function(){
  var s = stage.getBoundingClientRect(), b = next.getBoundingClientRect();
  wipeTo(current === 'a' ? 'b' : 'a', b.left + b.width/2 - s.left, b.top + b.height/2 - s.top);
});
```

*(The live demo adds three optional flourishes on top of this core: an expanding ring at the circle's leading edge for an iris/lens-flare glint, a slow Ken-Burns scale on the active view's background, and a persistent HUD — index + dots — whose buttons survive each wipe.)*

## How it works

- Both pages are absolutely positioned to fill the stage. The **current** page sits on top (`z-index:1`); the other waits behind it, invisible.
- To navigate, the **incoming** page is raised above the current one and clipped with `clip-path: circle(0 at originX originY)` — a zero-radius circle, so nothing shows.
- A CSS transition then grows the radius to `coverRadius()` — the distance to the *farthest* corner — so the circle expands until it fully covers the stage. The new page **irises in** from the origin point, over the old one.
- On `transitionend`, the clip is cleared and the `is-current` / `is-incoming` flags swap: the incoming page becomes current and the next wipe runs the other way.
- The origin `(x, y)` is read from the trigger button's centre, so the circle visibly **grows out of the button you pressed** — the signature material/iris feel. Pass pointer coordinates instead for a "reveal from wherever I click".

## Customization

- **Origin:** feed any point — centre (`w/2, h/2`), a corner, or live `e.clientX/Y` (minus the stage's offset) for a click-anywhere reveal.
- **Tempo & feel:** tune the `.82s` and `cubic-bezier`. Ease-out reads like a fast camera iris; ease-in-out like a closing/opening shutter.
- **Shape:** swap `circle()` for `ellipse()`, `inset()` or `polygon()` to get diagonal, blinds or barn-door wipes — any animatable basic shape works the same way.
- **Direction:** to wipe *out* (collapse the leaving page) instead of *in*, clip the **outgoing** view from full radius down to `circle(0)`.
- **Theme:** give each view its own palette via a `--acc` custom property (this demo uses warm gold for view A, neon cyan for view B) and tint the leading ring to the incoming accent.

## Accessibility & performance

- `clip-path` is composited on the GPU, so growing the circle is cheap — far lighter than animating `width/height`. Set `will-change:clip-path` only during the wipe (the `is-incoming` class) and drop it after.
- Respect `prefers-reduced-motion`: this demo both checks `matchMedia` in JS (swaps instantly, skips the ring) **and** zeroes the transition in CSS.
- Flip `aria-hidden` on swap so assistive tech never reads two stacked pages at once; the transition is decorative — the content swap is the real navigation.
- Keep triggers as real `<button>`s (keyboard + screen-reader reachable) and guard against re-entrancy with a `busy` flag so rapid clicks can't desync the state.

## Gotchas

- **Prefix Safari/iOS:** set and transition **both** `-webkit-clip-path` and `clip-path`, or it silently does nothing on WebKit.
- **One-frame flash:** set `circle(0 …)` in the *same synchronous block* where you raise the incoming view — before the browser paints — or the full page flashes for a frame. Then force a reflow (`void offsetWidth`) so the 0-radius is the transition's start, not its end.
- **`none` is not interpolatable:** never transition `none ↔ circle()`. Start from `circle(0 …)`, and only set `clip-path:none` *after* the wipe completes.
- **Cover the corners:** the radius must reach the farthest corner (the hypotenuse), not just the nearest edge — otherwise an off-centre origin leaves the page only partly revealed.
- **`transitionend` is flaky:** it can fire twice (once each for `clip-path` and `-webkit-clip-path`) and can be skipped if the tab is backgrounded — guard with a `done` flag and a `setTimeout` fallback.
- **Clip the stage:** the parent needs `overflow:hidden` (and ideally `isolation:isolate`) so the expanding circle is bounded and the stacking context stays predictable.
