# Venetian Blind - Skill Reference > Split a fullscreen slideshow into N vertical strips, each animated via `CSS mask` driven by `@property` custom properties — no JS animation library, no GSAP. ## What this teaches - CSS `mask` with multiple `linear-gradient` layers for independent per-strip control - `@property` registration with `syntax: ""` to make custom properties interpolatable in `@keyframes` - Staggered strip animation baked into keyframe stop positions (no `animation-delay`) - zIndex swap + `animationend` listener for stateless slide sequencing in JS - Two directions from one formula: collapse (outgoing) vs. reveal (incoming) ## Core concept Each strip is a `linear-gradient` mask layer positioned at an exact x-offset. The visible region of each strip is controlled by two custom properties (`--t` top edge, `--b` bottom edge). Registering those as `` via `@property` lets `@keyframes` interpolate them — which is impossible with raw `` values. The stagger effect isn't achieved with `animation-delay`: instead, each strip's progress value (`--p1`…`--p5`) advances at a different keyframe position within a single animation, giving precise control over overlap and pacing without multiple animations. ## Key code ### HTML structure ```html
``` ### CSS — the effect ```css /* Must be , not — @keyframes can only interpolate registered numeric types */ @property --p1 { syntax: ""; inherits: false; initial-value: 0; } @property --p2 { syntax: ""; inherits: false; initial-value: 0; } @property --p3 { syntax: ""; inherits: false; initial-value: 0; } @property --p4 { syntax: ""; inherits: false; initial-value: 0; } @property --p5 { syntax: ""; inherits: false; initial-value: 0; } /* Stagger formula (N=5 strips, S=10 stagger offset, W=60 travel window): - Each strip's progress value starts S% later than the previous - Ease-in curve baked in: values approximate t² (quadratic) at each sample - p=0 → strip at initial position; p=1 → strip at final position */ @keyframes slide-mask-v1 { 0% { --p1: 0; --p2: 0; --p3: 0; --p4: 0; --p5: 0; } 10% { --p1: 0.03; --p2: 0; --p3: 0; --p4: 0; --p5: 0; } 20% { --p1: 0.11; --p2: 0.03; --p3: 0; --p4: 0; --p5: 0; } 30% { --p1: 0.25; --p2: 0.11; --p3: 0.03; --p4: 0; --p5: 0; } 40% { --p1: 0.44; --p2: 0.25; --p3: 0.11; --p4: 0.03; --p5: 0; } 50% { --p1: 0.69; --p2: 0.44; --p3: 0.25; --p4: 0.11; --p5: 0.03; } 60% { --p1: 1; --p2: 0.69; --p3: 0.44; --p4: 0.25; --p5: 0.11; } 70% { --p1: 1; --p2: 1; --p3: 0.69; --p4: 0.44; --p5: 0.25; } 80% { --p1: 1; --p2: 1; --p3: 1; --p4: 0.69; --p5: 0.44; } 90% { --p1: 1; --p2: 1; --p3: 1; --p4: 1; --p5: 0.69; } 100% { --p1: 1; --p2: 1; --p3: 1; --p4: 1; --p5: 1; } } .slide { position: absolute; inset: 0; /* Strip formula (disappear — shrink toward center): --t = p * 50% → top edge moves down from 0% to 50% --b = 100% - p * 50% → bottom edge moves up from 100% to 50% p=0: t=0%, b=100% → fully visible p=1: t=50%, b=50% → strip collapsed to nothing Each mask layer = one strip: x-position: 0%, 25%, 50%, 75%, 100% (i / (N-1) × 100%) width: calc(100% / 5 + 1px) → +1px closes sub-pixel rasterization gaps */ --t1: calc(var(--p1) * 50%); --b1: calc(100% - var(--p1) * 50%); --t2: calc(var(--p2) * 50%); --b2: calc(100% - var(--p2) * 50%); --t3: calc(var(--p3) * 50%); --b3: calc(100% - var(--p3) * 50%); --t4: calc(var(--p4) * 50%); --b4: calc(100% - var(--p4) * 50%); --t5: calc(var(--p5) * 50%); --b5: calc(100% - var(--p5) * 50%); mask: linear-gradient(to bottom, transparent 0 var(--t1), #000 var(--t1) var(--b1), transparent var(--b1)) 0% 0 / calc(100% / 5 + 1px) 100% no-repeat, linear-gradient(to bottom, transparent 0 var(--t2), #000 var(--t2) var(--b2), transparent var(--b2)) 25% 0 / calc(100% / 5 + 1px) 100% no-repeat, linear-gradient(to bottom, transparent 0 var(--t3), #000 var(--t3) var(--b3), transparent var(--b3)) 50% 0 / calc(100% / 5 + 1px) 100% no-repeat, linear-gradient(to bottom, transparent 0 var(--t4), #000 var(--t4) var(--b4), transparent var(--b4)) 75% 0 / calc(100% / 5 + 1px) 100% no-repeat, linear-gradient(to bottom, transparent 0 var(--t5), #000 var(--t5) var(--b5), transparent var(--b5)) 100% 0 / calc(100% / 5 + 1px) 100% no-repeat; } .slide.active { /* linear because the easing curve is baked into the keyframe value progression above */ animation: slide-mask-v1 0.8s linear forwards; } ``` ### JS — state machine ```ts function initSlideshow(el: HTMLElement) { const delay = parseFloat(el.dataset.slideshow ?? '3') * 1000 const slides = Array.from(el.children) as HTMLElement[] let current = 0 // Set z-index for the whole stack at once — simpler than tracking pairs function setZ(outgoing: number, incoming: number) { slides.forEach((s, i) => { s.style.zIndex = i === outgoing ? '2' : i === incoming ? '1' : '0' }) } setZ(current, -1) // init: first slide on top, no incoming setInterval(() => { const outgoing = current const incoming = (current + 1) % slides.length setZ(outgoing, incoming) slides[outgoing].classList.add('active') // triggers CSS @keyframes slides[outgoing].addEventListener('animationend', () => { slides[outgoing].classList.remove('active') slides[outgoing].style.zIndex = '0' // send outgoing to bottom of stack current = incoming }, { once: true }) // once: true — prevents listener accumulation across intervals }, delay) } document.querySelectorAll('[data-slideshow]').forEach(initSlideshow) ``` ## Variants ### Disappear (index.html) Active class goes on the **outgoing** slide. Strips shrink top+bottom toward center until gone. The incoming slide (z=1) becomes visible as the outgoing collapses. Strip formula: ```css --t: calc(var(--p) * 50%); /* top edge: 0% → 50% */ --b: calc(100% - var(--p) * 50%); /* bottom edge: 100% → 50% */ ``` ### Reveal (index-2.html) Active class goes on the **incoming** slide. Strips expand outward from center, covering the outgoing slide (z=1) beneath. Non-active slides have **no mask** — fully visible by default. Strip formula (inverted — expand from center): ```css --t: calc(50% - var(--p) * 50%); /* top edge: 50% → 0% */ --b: calc(50% + var(--p) * 50%); /* bottom edge: 50% → 100% */ ``` JS difference: `slides[incoming].classList.add('active')` instead of `slides[outgoing]`. On `animationend`, remove `.active` from incoming and push outgoing to z=0. ## Adapt to your layout - **Strip count (N)**: change `5` to any N. Update: number of `@property` declarations, keyframe column (`S = 100 / (N + N-1)`, `W = 100 - (N-1) × S`), mask layer count, and x-positions (`i / (N-1) × 100%` for each strip). - **Timing**: `0.8s` in `.slide.active` is total animation duration. Stagger spread = `(N-1) × S / 100 × duration`. - **Easing per strip**: the quadratic ease-in is baked as sampled values. Replace with linear (`0, 0.1, 0.2, … 1`) for uniform speed, or use cubic samples for a different curve. - **Direction (horizontal strips)**: swap `to bottom` → `to right`, swap `transparent 0 var(--t)` positions for left/right edges, swap mask x/y positioning to `0 i%`. - **Selector names**: `.slide` and `.active` are arbitrary — rename freely. - **Auto-advance delay**: `data-slideshow="2"` on the container sets seconds. Read via `el.dataset.slideshow` in JS. ## Browser support `@property` (Chromium 85+, Firefox 128+, Safari 16.4+) · CSS `mask` with multiple layers (all modern browsers) · No polyfill path — feature-detect with `CSS.supports('(--p: 0)', '')` before calling `initSlideshow` if needed. ## Accessibility ```ts if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) { initSlideshow(el) // skip entirely — don't just slow it down } ``` Don't reduce duration — skip the animation. Rapid motion harms users with vestibular disorders even at lower speeds. Show all slides statically or freeze on the first slide instead.