# Ripple Reveal - Skill Reference > Slideshow transition that expands the incoming slide outward from a configurable origin via CSS `mask` + `radial-gradient`, driven by an animated `@property --p` custom property. ## What this teaches - CSS `mask` with `radial-gradient` to clip visibility by an animatable radius - `@property` registration as `` to enable `@keyframes` interpolation on a custom property - Feather zone math: two gradient stops derived from a single `--p` value to create a soft edge - Monotonic `zIndex` counter + `setTimeout` cleanup for zero-listener-leak slide sequencing - `--delay` and `--duration` CSS custom properties read from computed style at init, with `s`/`ms` unit parsing ## Core concept `radial-gradient` can't be animated directly, but its stop positions can be if they're computed from a registered custom property. `@property --p` goes from `0` to `1` via `@keyframes`, and every length in the mask derives from that single number — so the entire mask animation is one `@keyframes` block. The feather zone is the gap between `--edge-in` and `--edge-out`, both offset from `--radius` by half of `--feather`. This keeps the soft edge constant width throughout the expansion, unlike blur-based approaches that widen as the circle grows. ## Key code ### HTML structure ```html
``` ### CSS — the effect ```css /* not — browser can't interpolate unregistered or percentage custom properties in @keyframes */ @property --p { syntax: ""; inherits: false; initial-value: 0; } @keyframes ripple-reveal { from { --p: 0; } to { --p: 1; } /* JS sets animation-duration via --duration; this stays simple */ } .slideshow-ripple-reveal { --origin-x: 50%; /* ripple origin — override inline per slideshow or per slide */ --origin-y: 50%; --feather: 20%; /* gap between visible and transparent stops; larger = softer ring */ --duration: 1s; /* JS reads this via getComputedStyle — keep in sync */ --delay: 5s; /* JS reads this too — time between slide advances */ --ease: cubic-bezier(0.4, 0, 1, 1); /* ease-in: slow start → fast expand feels snappy */ } .slideshow-ripple-reveal > .active { /* 150% ensures circle covers all four corners regardless of --origin-x/y position */ --radius: calc(var(--p) * 150%); --edge-in: calc(var(--radius) - var(--feather) / 2); /* inner hard edge */ --edge-out: calc(var(--radius) + var(--feather) / 2); /* outer fade edge */ mask: radial-gradient( circle at var(--origin-x) var(--origin-y), #000 var(--edge-in), /* fully visible inside */ transparent var(--edge-out) /* fully hidden outside */ ); animation: ripple-reveal var(--duration) var(--ease) forwards; } ``` ### JS — state machine ```ts interface RippleOptions { delay?: number // ms — overrides --delay on el animDuration?: number // ms — overrides --duration on el } function initRippleSlideshow(el: HTMLElement, options: RippleOptions = {}) { const style = getComputedStyle(el) // parse CSS time value ('1s' → 1000, '500ms' → 500); fallback if missing function parseCssTime(raw: string, fallback: number): number { const s = raw.trim() if (!s) return fallback if (s.endsWith('ms')) return parseFloat(s) if (s.endsWith('s')) return parseFloat(s) * 1000 return fallback } const delay = options.delay ?? parseCssTime(style.getPropertyValue('--delay'), 5000) const animDuration = options.animDuration ?? parseCssTime(style.getPropertyValue('--duration'), 1000) let current = 0 let zCounter = 1 // monotonically increases; no two slides ever share a z-index function setActive(idx: number) { const prev = current current = idx slides[idx].style.zIndex = String(++zCounter) // bump z above all previous slides slides[idx].classList.remove('active') // force @keyframes restart slides[idx].classList.add('active') setTimeout(() => { if (prev === idx) return slides[prev].classList.remove('active') // stops Ken Burns too slides[prev].style.zIndex = '0' // sink to bottom of stack }, animDuration + 200) // +200ms buffer so animation fully settles before cleanup } requestAnimationFrame(() => slides[0].classList.add('active')) setInterval(() => setActive((current + 1) % slides.length), delay) } ``` ## Variants ### Default (index.html) Incoming slide expands over the outgoing one. `.active` goes on the **incoming** slide; the previous slide sits fully visible beneath until cleanup. Origin set to `70% 50%` (right-center) — override `--origin-x` / `--origin-y` on the container or per-slide for corner origins. ## Adapt to your layout - **Origin**: set `--origin-x` / `--origin-y` on `.slideshow-ripple-reveal` or override inline per slide (e.g. click coordinates for cursor-origin ripple). - **Feather**: `--feather: 0%` → hard circle wipe; `--feather: 40%` → wide soft dissolve ring. - **Max radius**: `150%` covers corners for center origins. For corner origins (`0% 0%`), the farthest corner needs `~141%`; `150%` still clears it. - **Duration**: change `--duration` on the container — JS reads it via `getComputedStyle` automatically. - **Slide delay**: change `--delay` on the container (e.g. `--delay: 3s`) — no JS constant to update. - **Easing**: `cubic-bezier(0.4, 0, 1, 1)` is ease-in; try `cubic-bezier(0, 0, 0.6, 1)` (ease-out) for a slower-finish feel. - **Ken Burns**: the `scale(1.1)` / `10s linear` on `img` is independent — remove `.active img` rule to disable. ## Browser support `@property` (Chromium 85+, Firefox 128+, Safari 16.4+) · CSS `mask` with `radial-gradient` (all modern browsers) · No polyfill path — feature-detect with `CSS.supports('(--p: 0)', '')` or check `CSS.registerProperty` before init. ## Accessibility ```ts document.querySelectorAll('.slideshow-ripple-reveal').forEach(el => { // skip init entirely — don't just slow it down // vestibular disorders can be triggered even by reduced-speed motion if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { initRippleSlideshow(el) } }) ```