Skip to content

01Web design · Transition

v1.0.0

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.

Section
Web design
Published

Published Jul 21, 2026.

02How to use

Three ways
  1. 01Customize it

    Tune every value in the app until it looks right.

  2. 02Copy the embed

    Settings are data-* attributes on the element, 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>
  3. 03Paste it on your site

    Anywhere that takes HTML: an Embed element in Webflow or Framer, a Custom HTML block in WordPress, or straight into your page.

03Case study

2 min read

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.

04More effects

Web design