Vanilla MotionVanilla Motion

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.

MotionCSSCSS MaskAnimation

Built with CSS Mask, CSS Custom Properties, @property, CSS Animations, Vanilla JS, TypeScript

MobileDesktop
Open Demo

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.

MobileDesktop
Open Demo

Two things differ from the disappear variant:

  1. .active targets the incoming slide, not the outgoing. The outgoing stays at z-index: 1 beneath, fully visible until covered.
  2. 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>

More

Ripple Reveal

Ripple Reveal

motioncsscss-maskanimation
Ripple Reveal WebGL

Ripple Reveal WebGL

motionwebglglslanimation
Ice Logo

Ice Logo

webglglslmotion