Image Effects
VenetianBlinds
A window with wooden slats shut over the picture. Hover and they tip open in a wave that starts at the pointer. Pull the cord to set them, press the window to keep them open. The cord is a real slider for keyboards.
Download
- React
- Tailwind CSS v4
- motion
components/ui/venetian-blinds.tsx
"use client";
import { useEffect, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { useReducedMotion } from "motion/react";
type VenetianBlindsProps = {
src: string;
alt: string;
/** Number of slats. */
slats?: number;
/** How far the slats sit tilted at the start, 0 shut to 1 open. */
tilt?: number;
/** How far hover opens them, 0 to 1. */
peek?: number;
/** Slat colour, light side. */
wood?: string;
className?: string;
};
const clamp = (v: number) => Math.min(1, Math.max(0, v));
const SHUT = 10;
const OPEN = 83;
/* VenetianBlinds: a window with real slats shut over the picture. Hover and they tip open in a wave from the pointer; pull the cord to set them, press to keep them open. */
export function VenetianBlinds({ src, alt, slats = 13, tilt = 0, peek = 0.82, wood = "#c79a63", className = "" }: VenetianBlindsProps) {
const reduce = useReducedMotion();
const stage = useRef<HTMLButtonElement>(null);
const [cord, setCord] = useState(tilt);
const [held, setHeld] = useState(false);
const live = useRef({ now: Array.from({ length: slats }, () => tilt), goal: tilt, row: slats / 2 });
const wake = useRef(() => {});
useEffect(() => {
const s = live.current;
const box = stage.current!;
const parts = [...box.querySelectorAll<HTMLElement>("[data-slat]")];
const shades = [...box.querySelectorAll<HTMLElement>("[data-shade]")];
s.now = Array.from({ length: parts.length }, (_, i) => s.now[i] ?? s.goal);
let raf = 0;
function paint() {
parts.forEach((part, i) => {
part.style.transform = `rotateX(${SHUT + (OPEN - SHUT) * s.now[i]}deg)`;
shades[i].style.opacity = String(0.05 + s.now[i] * 0.55);
});
}
function tick() {
let moving = false;
s.now = s.now.map((v, i) => {
const near = Math.exp(-(((i - s.row) / parts.length) ** 2) * 16);
const next = reduce ? s.goal : v + (s.goal - v) * (0.05 + near * 0.16);
if (Math.abs(s.goal - next) > 0.002) moving = true;
return moving ? next : s.goal;
});
paint();
raf = moving ? requestAnimationFrame(tick) : 0;
}
wake.current = () => {
if (!raf) raf = requestAnimationFrame(tick);
};
paint();
return () => cancelAnimationFrame(raf);
}, [slats, reduce]);
function aim(goal: number) {
live.current.goal = goal;
wake.current();
}
function row(e: PointerEvent<HTMLElement>) {
const box = e.currentTarget.getBoundingClientRect();
live.current.row = ((e.clientY - box.top) / box.height) * slats;
}
function enter(e: PointerEvent<HTMLButtonElement>) {
row(e);
if (e.pointerType === "mouse" && !held) aim(Math.max(cord, peek));
}
function press() {
const next = !held;
setHeld(next);
aim(next ? 1 : cord);
}
function pull(v: number) {
const value = clamp(v);
setCord(value);
setHeld(false);
live.current.row = slats / 2;
aim(value);
}
function drag(e: PointerEvent<HTMLElement>) {
if (e.buttons !== 1) return;
const box = stage.current!.getBoundingClientRect();
pull(((e.clientY - box.top) / box.height - 0.26) / 0.56);
}
function keys(e: KeyboardEvent) {
const to = { ArrowDown: cord + 0.1, ArrowRight: cord + 0.1, ArrowUp: cord - 0.1, ArrowLeft: cord - 0.1, Home: 0, End: 1 }[e.key];
if (to === undefined) return;
e.preventDefault();
pull(to);
}
return (
<div className={`@container w-[26rem] ${className}`}>
<div className="relative rounded-[0.18em] bg-[linear-gradient(135deg,#f1ece1,#cfc7b6)] p-[0.5em] text-[4cqw] shadow-[0_0.7em_1.4em_-0.6em_rgba(0,0,0,0.75)]">
<div className="relative shadow-[inset_0_0_0_0.06em_rgba(0,0,0,0.5)]">
<span aria-hidden className="absolute inset-x-[-0.12em] top-0 z-20 block h-[0.72em] rounded-[0.08em] bg-[linear-gradient(180deg,#f7f3ea,#b9b1a0_70%,#8e8675)] shadow-[0_0.1em_0.2em_rgba(0,0,0,0.5)]" />
<button
ref={stage}
type="button"
aria-pressed={held}
aria-label={`${alt}. ${held ? "Let the blinds fall back" : "Keep the blinds open"}`}
onPointerEnter={enter}
onPointerMove={row}
onPointerLeave={() => !held && aim(cord)}
onClick={press}
className="relative block aspect-[4/3] w-full cursor-pointer touch-pan-y overflow-hidden bg-[#10100f] outline-none [perspective:30em] focus-visible:ring-[0.14em] focus-visible:ring-inset focus-visible:ring-[#ffcb6b]"
>
<img src={src} alt="" draggable={false} className="absolute inset-0 size-full object-cover" />
<span aria-hidden className="absolute inset-0 block bg-[radial-gradient(ellipse_at_center,transparent_45%,rgba(0,0,0,0.3))]" />
{Array.from({ length: slats }, (_, i) => (
<span
key={i}
data-slat
aria-hidden
className="absolute inset-x-0 block overflow-hidden shadow-[0_0.05em_0.05em_rgba(0,0,0,0.45)] will-change-transform"
style={{
top: `${((i + 0.5) * 100) / slats}%`,
height: `${108 / slats}%`,
marginTop: `${-54 / slats}%`,
backgroundColor: wood,
backgroundImage: "linear-gradient(180deg,rgba(255,255,255,0.3),rgba(255,255,255,0.02) 38%,rgba(0,0,0,0.26)),repeating-linear-gradient(90deg,rgba(70,35,10,0.1) 0 0.07em,transparent 0.07em 0.33em)",
}}
>
<span data-shade className="absolute inset-0 block bg-black" />
</span>
))}
<span aria-hidden className="absolute inset-y-0 left-[17%] block w-[0.05em] bg-[#efe9dc]/70" />
<span aria-hidden className="absolute inset-y-0 right-[17%] block w-[0.05em] bg-[#efe9dc]/70" />
</button>
<span aria-hidden className="absolute inset-x-0 bottom-0 z-10 block h-[0.34em] bg-[linear-gradient(180deg,#e7e1d3,#a79f8e)] shadow-[0_-0.05em_0.14em_rgba(0,0,0,0.4)]" />
<span
role="slider"
tabIndex={0}
aria-label="Blind cord"
aria-orientation="vertical"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={Math.round(cord * 100)}
onPointerDown={(e) => {
e.currentTarget.setPointerCapture(e.pointerId);
drag(e);
}}
onPointerMove={drag}
onKeyDown={keys}
className="absolute z-30 flex w-[1.5em] -translate-x-1/2 cursor-ns-resize touch-none flex-col items-center outline-none focus-visible:rounded-sm focus-visible:ring-[0.1em] focus-visible:ring-[#ffcb6b]"
style={{ left: "92%", top: "0.5em", height: `calc(${24 + cord * 56}% + 1.1em)` }}
>
<span aria-hidden className="block w-[0.07em] flex-1 bg-[#ece6d8] shadow-[0.04em_0_0.05em_rgba(0,0,0,0.5)]" />
<span aria-hidden className="block h-[1em] w-[0.56em] rounded-[50%_50%_44%_44%/30%_30%_70%_70%] bg-[radial-gradient(circle_at_35%_30%,#f6d9a4,#a4682c_62%,#5f3714)] shadow-[0.04em_0.08em_0.12em_rgba(0,0,0,0.6)]" />
</span>
</div>
</div>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/venetian-blinds.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/venetian-blinds-demo.tsx
import { VenetianBlinds } from "@/components/ui/venetian-blinds"; export default function VenetianBlindsExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#3f4a4c] p-2"> <VenetianBlinds src="/demo/dusk.jpg" alt="Hazy hills at dusk, seen from a window" className="w-[min(112cqh,88cqw,460px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| src | string | — | Picture address. |
| alt | string | — | Text for screen readers. |
| slats | number | 13 | Number of slats. |
| tilt | number | 0 | How far the slats sit tilted at the start, 0 shut to 1 open. |
| peek | number | 0.82 | How far hover opens them, 0 to 1. |
| wood | string | "#c79a63" | Colour of the slats. |
| className | string | "w-[26rem]" | Set the width here. The window stays 4 by 3. |