Vanilla MotionVanilla Motion

Venetian Blind

motioncsscss-maskanimation

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

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.

MobileDesktop
Open Demo

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; 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 */
.slide-v2.active {
  --t1: calc(50% - var(--p1) * 50%);
  --b1: calc(50% + var(--p1) * 50%);
  /* … repeated for p2–p5 … */
  animation: slide-mask-v2 .8s linear forwards;
}

Non-active slides have no mask applied — they’re fully visible by default. Only the incoming .active slide carries the mask during its animation.

The @keyframes table is identical to the disappear variant: --p1 starts first, each subsequent property lags 10% behind. 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 incoming slide’s z-index, toggle .active. All interpolation happens in CSS. Zero dependencies.

Accessibility

if (!window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
  document.querySelectorAll<HTMLElement>('[data-slideshow]').forEach(initSlideshow)
}

When prefers-reduced-motion is set, skip initSlideshow. Slides stay static.

More tutorials

Ripple Reveal

Ripple Reveal

motioncsscss-maskanimation
Ripple Reveal WebGL

Ripple Reveal WebGL

motionwebglglslanimation
CSS Tab Shape

CSS Tab Shape

css-maskcss-clip-pathshapestabs