Venetian Blind
Two CSS mask transitions that split a slide into vertical strips — one collapses the outgoing image, the other reveals the incoming one. No JS animation library.
Built with CSS Mask, CSS Custom Properties, @property, CSS Animations, Vanilla JS, TypeScript
Two variations of a venetian-blind transition for full-bleed image slideshows — both built entirely with CSS mask and @keyframes, no animation library. The effect splits a slide into 5 vertical strips that collapse or expand with a staggered timing, creating a mechanical reveal that works well for editorial and portfolio contexts.
Disappear: outgoing slide collapses
Each strip on the outgoing slide shrinks toward its vertical center, exposing the next image beneath.
Five @property-registered custom properties (--p1…--p5) animate from 0 to 1 via @keyframes. Each drives a column of the mask, shrinking the visible region toward center:
/* p=0 → strip fully visible (t=0%, b=100%) */
/* p=1 → strip collapsed to center (t=50%, b=50%) */
--t: calc(var(--p) * 50%);
--b: calc(100% - var(--p) * 50%);
mask:
linear-gradient(to bottom, transparent 0 var(--t), #000 var(--t) var(--b), transparent var(--b))
0% 0 / calc(100% / 5 + 1px) 100% no-repeat;
The +1px on column width closes rasterization gaps at non-integer device pixel ratios. .active goes on the outgoing slide (direction: "collapse"); after animationend it’s removed and the slide drops to z-index: 0.
Reveal: incoming slide expands
Same strips, reversed direction — the incoming slide expands outward from center rather than collapsing inward.
Two things differ from the disappear variant:
.activetargets the incoming slide, not the outgoing. The outgoing stays atz-index: 1beneath, fully visible until covered.- Strip formula is inverted: starts at center, expands to edges.
/* p=0 → strips at center (zero height) → invisible */
/* p=1 → strips fill full height → fully visible */
--t: calc(50% - var(--p) * 50%);
--b: calc(50% + var(--p) * 50%);
With direction: "reveal" non-active slides have no mask applied — they’re fully visible by default. Only the incoming .active slide carries the mask during its animation.
In the packaged effect both variants are one class, switched by the direction param. A single @property --vm-vb-p runs 0 to 1 over duration, and the mask built for each slide derives every strip’s own progress from it (stagger percent later per strip, eased in as t squared), so the strip count is a param too. The direction is purely determined by the --t/--b formula.
Why @property
Custom properties are strings by default — they can’t be interpolated by @keyframes. Registering them as <number> via @property makes them numeric and animatable:
@property --p1 { syntax: "<number>"; inherits: false; initial-value: 0; }
Without this, the mask would snap from start to end on the first frame instead of animating.
Why no JS animation library
The script only manages state: track current index, raise the animated slide’s z-index, toggle .active. All interpolation happens in CSS. Zero dependencies.
Accessibility
if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
mount(el, { images })
}
When prefers-reduced-motion is set, skip mount and keep the first image as a static <img>. Slides stay static.
Use it on a site
One script, no build step. Every element with data-venetian-blind starts the effect; settings go in data-* attributes in kebab-case.
<div data-venetian-blind style="height:480px"></div>
<script src="https://vanilla-motion.com/embed/venetian-blind/1.0.0/venetian-blind.js"></script>

