Ice LogoCursor01Web design · Transition
v1.0.0Venetian 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
- Kind
- Transition
- Published
- Built with
- CSS
- Version
- 1.0.0
- Tech
- CSS Mask, CSS Custom Properties, @property, CSS Animations, Vanilla JS, TypeScript
npx vanilla-motion add venetian-blind
Published Jul 21, 2026.
02How to use
Three ways- 01Customize it
Tune every value in the app until it looks right.
- 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> - 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.
- 01Connect once
Add it as an MCP server (or custom connector) in Claude, ChatGPT, Cursor, VS Code or any app that supports MCP.
https://mcp.vanilla-motion.com/mcp - 02Ask for it
In your project, ask:
Add Venetian Blind to this project. - 03What it reads
The same files are open to any tool, with or without the MCP.
- 01shadcn
In a project that uses shadcn:
npx shadcn@latest add https://vanilla-motion.com/r/venetian-blind.json - 02Any project
Copies the source into ./vanilla-motion/venetian-blind/ and lists the packages it needs.
npx vanilla-motion add venetian-blind - 03Use it
import { mount } from "./vanilla-motion/venetian-blind/venetian-blind"; const fx = mount(document.querySelector("#my-effect"), { /* params */ }); fx.setParams({ /* change any param later */ }); fx.dispose();
03Case study
2 min readTwo 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.



