"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(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("[data-slat]")]; const shades = [...box.querySelectorAll("[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) { const box = e.currentTarget.getBoundingClientRect(); live.current.row = ((e.clientY - box.top) / box.height) * slats; } function enter(e: PointerEvent) { 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) { 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 (
{ 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)` }} >
); }