A draft and live switch told as the oldest version of the same event: opening the shop. On a dark street a galvanised roller shutter is down, with the word painted across its slats in stencil white. Press Go live, or take hold of the shutter and throw it upward, and the slats run up into their steel box, hit the stop and shiver. A beat later the tubes inside flicker and catch, and the plate glass shows a finished homepage, lit warm from above. Light falls out across the sill and the pavement, and the pilot lamp on the box turns blue beside the word that has changed from draft to live. Pull it back down and it drops under its own weight, lands, and the street goes dark. The slats, rails, bottom rail and box are all gradients, with no images except the photograph in the page. Narrow viewports get the same shopfront zoomed to fit above a full-size button.
import { useEffect, useRef, useState } from "react";
// The site in the window. An invented bakery.
const site = {
name: "Oat Street Bakery",
links: ["Bread", "Pastry", "Find us"],
headline: "Bread out of the oven at seven.",
copy: "Sourdough, rye and seeded loaves, baked through the night on Oat Street.",
cta: "See today's bake",
photo: "https://images.pexels.com/photos/30884563/pexels-photo-30884563.jpeg?auto=compress&cs=tinysrgb&w=1200",
};
const STENCIL = "DRAFT"; // painted on the shutter
const BLUE = "#4F98FB";
const FONT = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
const W = 640; // width of the shutter box
const OW = 584, OH = 420; // the window opening
const SLAT = 24; // height of one slat
const TRAVEL = OH - 22; // the bottom rail stays in sight under the box
const HOLD = 9000; // how long the demo stays open before pulling the shutter down again
type Api = { set: (open: boolean) => void };
export default function SteelRollerShutter() {
const stage = useRef<HTMLDivElement>(null);
const root = useRef<HTMLDivElement>(null);
const curtain = useRef<HTMLDivElement>(null);
const api = useRef<Api | null>(null);
const [zoom, setZoom] = useState(1);
const [live, setLive] = useState(false);
useEffect(() => {
const st = stage.current, el = root.current, cur = curtain.current;
if (!st || !el || !cur) return;
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const scale = () => Math.max(0.46, Math.min(1.08, (st.clientWidth - 16) / (W + 36)));
const fit = () => setZoom(scale());
fit();
const ro = new ResizeObserver(fit);
ro.observe(st);
// p: 0 shut, 1 fully rolled up. lit: how bright the shop lights are.
const s = { p: reduce ? 1 : 0, v: 0, to: reduce ? 1 : 0, drag: false, grabY: 0, grabP: 0, lastY: 0, lastT: 0, on: reduce, onAt: -1e9, lit: reduce ? 1 : 0, shake: 0, touched: -1e9, live: false };
const paint = () => {
el.style.setProperty("--p", s.p.toFixed(4));
el.style.setProperty("--lit", s.lit.toFixed(3));
cur.style.transform = `translate(${s.shake.toFixed(2)}px, ${(-s.p * TRAVEL).toFixed(2)}px)`;
};
const tell = () => { const now = s.to === 1; if (now !== s.live) { s.live = now; setLive(now); } };
let raf = 0, last = 0, seen = true, timer = 0, begin = () => {};
const frame = (now: number) => {
raf = 0;
const dt = Math.max(0.004, Math.min(0.032, (now - last) / 1000 || 0.016)); last = now;
if (!s.drag) {
// sprung roller going up, dead weight coming down, and a stop at each end that it bounces off
const pull = s.to === 1 ? 13 * (1.08 - s.p) + 1 : -5.5 - 16 * s.p;
s.v += (pull - 3.4 * s.v) * dt; s.p += s.v * dt;
if (s.p > 1) { s.p = 1; if (s.v > 0.25) s.shake = 2.6; s.v = s.v > 0.3 ? -s.v * 0.24 : 0; }
if (s.p < 0) { s.p = 0; if (s.v < -0.25) s.shake = 3.4; s.v = s.v < -0.3 ? -s.v * 0.2 : 0; }
}
s.shake *= -0.72; if (Math.abs(s.shake) < 0.08) s.shake = 0;
// the tubes inside catch a moment after the shutter clears them, and go out when it shuts
if (!s.on && s.p > 0.1) { s.on = true; s.onAt = now; }
if (s.on && s.p < 0.015 && s.to === 0) s.on = false;
const t = now - s.onAt;
s.lit = !s.on ? 0 : t < 70 ? 0.55 : t < 150 ? 0.08 : t < 230 ? 0.8 : t < 320 ? 0.3 : t < 380 ? 0.9 : 1;
paint();
const rest = !s.drag && s.shake === 0 && Math.abs(s.v) < 0.004 && (s.p === 0 || s.p === 1 || Math.abs(s.p - s.to) < 0.001) && (!s.on || t > 400);
if (rest) return;
if (seen && !document.hidden) raf = requestAnimationFrame(frame);
};
const wake = () => { if (!raf) { last = performance.now(); raf = requestAnimationFrame(frame); } };
const go = (open: boolean) => {
s.to = open ? 1 : 0; tell();
if (reduce) { s.p = s.to; s.on = open; s.lit = open ? 1 : 0; paint(); return; }
s.v += open ? 0.5 : -0.2; wake();
};
api.current = { set: (open) => { s.touched = performance.now(); go(open); } };
const down = (e: PointerEvent) => {
if (reduce) return;
s.drag = true; s.touched = performance.now(); s.grabY = e.clientY; s.grabP = s.p; s.lastY = e.clientY; s.lastT = performance.now(); s.v = 0;
cur.setPointerCapture(e.pointerId); wake();
};
const move = (e: PointerEvent) => {
if (!s.drag) return;
const now = performance.now(), next = Math.max(0, Math.min(1, s.grabP - (e.clientY - s.grabY) / (TRAVEL * scale())));
s.v = ((next - s.p) / Math.max(0.008, (now - s.lastT) / 1000)) * 0.5; s.p = next; s.lastT = now;
};
const up = () => {
if (!s.drag) return;
s.drag = false; s.touched = performance.now();
s.v = Math.max(-2.5, Math.min(2.5, s.v));
s.to = s.v > 0.5 || (s.v > -0.5 && s.p > 0.4) ? 1 : 0; tell(); wake();
};
cur.addEventListener("pointerdown", down);
cur.addEventListener("pointermove", move);
window.addEventListener("pointerup", up);
window.addEventListener("pointercancel", up);
paint(); tell();
if (reduce) { s.to = 1; tell(); }
else {
// The demo: up, hold, down, wait. It stands down while someone is using the shutter.
const loop = (wait: number, open: boolean) => { timer = window.setTimeout(() => { if (seen && !document.hidden && performance.now() - s.touched > 8000) go(open); loop(open ? HOLD : 2400, !open); }, wait); };
// wait for the photograph, so the first thing anyone sees open is a finished window
begin = () => loop(380, true);
if (document.readyState === "complete") begin(); else window.addEventListener("load", begin, { once: true });
}
const io = new IntersectionObserver(([entry]) => { seen = entry.isIntersecting; if (seen && (s.p !== s.to || s.drag)) wake(); });
io.observe(st);
const vis = () => { if (!document.hidden && s.p !== s.to) wake(); };
document.addEventListener("visibilitychange", vis);
return () => {
cancelAnimationFrame(raf); window.clearTimeout(timer); window.removeEventListener("load", begin); api.current = null;
ro.disconnect(); io.disconnect(); document.removeEventListener("visibilitychange", vis);
cur.removeEventListener("pointerdown", down); cur.removeEventListener("pointermove", move);
window.removeEventListener("pointerup", up); window.removeEventListener("pointercancel", up);
};
}, []);
const BW = W + 36, BH = 56 + OH + 16 + 84; // box the whole shopfront is laid out in
const L = (BW - OW) / 2; // left edge of the opening
return (
<div
className="relative flex min-h-[560px] w-full flex-col items-center justify-center overflow-hidden bg-[#0B0D12] bg-[radial-gradient(80%_60%_at_50%_30%,#171B24_0%,#0B0D12_60%,#06070A_100%)] px-2 py-8 text-[#F6F4EF] sm:min-h-[800px]"
style={{ fontFamily: FONT }}
>
<style>{`
.steel-roller-shutter-root { --p: 0; --lit: 0; }
@media (prefers-reduced-motion: reduce) { .steel-roller-shutter-root { --p: 1; --lit: 1; } }
`}</style>
<div ref={stage} className="relative flex w-full max-w-[1000px] justify-center" style={{ height: BH * zoom }}>
<div ref={root} className="steel-roller-shutter-root relative shrink-0" style={{ width: BW, height: BH, zoom }}>
{/* light the open shop throws on the wall round it and on the pavement */}
<div aria-hidden="true" className="pointer-events-none absolute" style={{ left: L - 150, top: -40, width: OW + 300, height: OH + 220, background: "radial-gradient(closest-side,rgba(255,226,170,0.2),rgba(255,226,170,0.05) 60%,rgba(255,226,170,0))", opacity: "calc(var(--p) * var(--lit))" }} />
<div aria-hidden="true" className="pointer-events-none absolute" style={{ left: L - 90, top: 56 + OH + 16, width: OW + 180, height: 84, clipPath: "polygon(15% 0,85% 0,100% 100%,0 100%)", background: "linear-gradient(180deg,rgba(255,232,186,0.5),rgba(255,232,186,0.12) 55%,rgba(255,232,186,0))", filter: "blur(5px)", opacity: "calc(var(--p) * var(--lit))" }} />
{/* the window: plate glass with the site standing in it */}
<div className="absolute overflow-hidden bg-[#14110c]" style={{ left: L, top: 50, width: OW, height: OH + 6 }}>
<div role="img" aria-label={`The ${site.name} homepage on show in a shop window`} className="absolute inset-0 bg-[#F6F4EF] text-[#0B0D12]">
<div className="absolute left-[26px] right-[26px] top-[36px] flex h-[34px] items-center justify-between">
<span className="flex items-center gap-[7px] whitespace-nowrap text-[14px] font-extrabold tracking-[-0.03em]"><span className="h-[10px] w-[10px] rounded-full" style={{ background: BLUE }} />{site.name}</span>
<span className="flex gap-[18px] text-[11.5px] font-bold text-[#0B0D12]/70">{site.links.map((l) => <span key={l}>{l}</span>)}</span>
</div>
<div className="absolute left-[26px] top-[112px] w-[262px] text-[45px] font-extrabold leading-[0.95] tracking-[-0.05em]">{site.headline}</div>
<div className="absolute left-[26px] top-[264px] w-[240px] text-[13px] font-semibold leading-[1.38] text-[#0B0D12]/70">{site.copy}</div>
<div className="absolute left-[26px] top-[338px] flex h-[42px] items-center rounded-full bg-[#0B0D12] px-[20px] text-[13.5px] font-extrabold tracking-[-0.01em] text-white shadow-[inset_0_1px_0_rgba(255,255,255,0.25),0_10px_16px_-10px_rgba(11,13,18,0.7)]"><span className="mr-[8px] h-[7px] w-[7px] rounded-full" style={{ background: BLUE }} />{site.cta}</div>
<div className="absolute right-[22px] top-[84px] h-[312px] w-[250px] overflow-hidden rounded-[18px] bg-[#8a5a2b]">
<img src={site.photo} alt="" width={1200} height={800} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: "30% 40%" }} />
</div>
</div>
{/* warm tubes overhead, the dark before they catch, and the street in the glass */}
<div className="absolute inset-0" style={{ background: "linear-gradient(180deg,rgba(255,214,150,0.3) 0%,rgba(255,214,150,0.06) 30%,rgba(255,214,150,0) 55%,rgba(40,24,6,0.1) 100%)", mixBlendMode: "multiply" }} />
<div className="absolute inset-0 bg-[#07080b]" style={{ opacity: "calc(0.94 - var(--lit) * 0.94)" }} />
<div className="absolute inset-0" style={{ background: "linear-gradient(104deg,rgba(255,255,255,0) 18%,rgba(190,214,255,0.16) 30%,rgba(255,255,255,0.03) 37%,rgba(255,255,255,0) 52%,rgba(190,214,255,0.1) 70%,rgba(255,255,255,0) 82%)" }} />
<div className="absolute inset-0" style={{ boxShadow: "inset 0 0 0 2px #0a0b0e, inset 0 0 0 5px #2b2f36, inset 0 0 0 6px #0a0b0e, inset 0 26px 30px -18px rgba(0,0,0,0.75)" }} />
{/* the shutter curtain */}
<div ref={curtain} className="absolute left-0 top-0 touch-none select-none" style={{ width: OW, height: OH + 6, cursor: "grab", willChange: "transform" }}>
<div className="absolute inset-x-0 top-0" style={{ height: OH - 22, backgroundColor: "#565b63", backgroundImage: `repeating-linear-gradient(180deg,#2c3037 0px,#a7adb5 2px,#8a9098 5px,#6b7179 11px,#4d525a 17px,#2a2d33 20.5px,#0b0c0f 22px,#08090b 23px,#2c3037 ${SLAT}px)` }}>
{/* wear: long streaks, a soft sheen, and the shadow of the box above */}
<div className="absolute inset-0 opacity-70" style={{ backgroundImage: "repeating-linear-gradient(90deg,rgba(255,255,255,0) 0 31px,rgba(255,255,255,0.05) 31px 33px,rgba(255,255,255,0) 33px 71px,rgba(0,0,0,0.1) 71px 74px,rgba(255,255,255,0) 74px 113px),linear-gradient(100deg,rgba(255,255,255,0) 20%,rgba(200,220,255,0.13) 38%,rgba(255,255,255,0) 54%,rgba(0,0,0,0.14) 100%)" }} />
<div aria-hidden="true" className="absolute inset-x-0 flex items-center justify-center" style={{ top: 96, height: 168, WebkitMaskImage: `repeating-linear-gradient(180deg,#000 0 18px,rgba(0,0,0,0.35) 18px ${SLAT}px)`, maskImage: `repeating-linear-gradient(180deg,#000 0 18px,rgba(0,0,0,0.35) 18px ${SLAT}px)` }}>
<span className="text-[136px] font-black leading-none tracking-[0.02em]" style={{ color: "rgba(246,244,239,0.86)", textShadow: "0 1px 0 rgba(0,0,0,0.35)" }}>{STENCIL}</span>
</div>
</div>
{/* bottom rail with a pull handle and two shoot bolts */}
<div className="absolute inset-x-0" style={{ top: OH - 22, height: 24, background: "linear-gradient(180deg,#c3c8ce 0%,#8d939b 18%,#5d626a 55%,#383c43 86%,#17191d 100%)", boxShadow: "0 8px 12px rgba(0,0,0,0.6), inset 0 1px 0 rgba(255,255,255,0.6)" }}>
<div className="absolute left-1/2 top-[6px] h-[12px] w-[92px] -translate-x-1/2 rounded-full" style={{ background: "linear-gradient(180deg,#0a0b0d,#2a2d33)", boxShadow: "inset 0 2px 3px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.45)" }} />
{[18, OW - 30].map((x) => <span key={x} className="absolute top-[6px] h-[12px] w-[12px] rounded-full" style={{ left: x, background: "radial-gradient(circle at 36% 30%,#f2f4f6,#8a9098 45%,#2e3238)", boxShadow: "0 1px 1px rgba(0,0,0,0.7)" }} />)}
</div>
<div className="absolute inset-x-0 bg-[#050608]" style={{ top: OH + 2, height: 4 }} />
</div>
</div>
{/* guide rails, sill and the box the shutter rolls into */}
{[L - 14, L + OW - 2].map((x) => (
<div key={x} aria-hidden="true" className="absolute" style={{ left: x, top: 50, width: 16, height: OH + 8, background: "linear-gradient(90deg,#1c1f24 0%,#8e949c 22%,#5a5f67 48%,#30343a 70%,#9197a0 86%,#22252a 100%)", boxShadow: "0 0 0 0.5px #000, 4px 0 8px -2px rgba(0,0,0,0.5)" }} />
))}
<div aria-hidden="true" className="absolute" style={{ left: L - 26, top: 56 + OH, width: OW + 52, height: 16, background: "linear-gradient(180deg,#3a3d44 0%,#24262b 40%,#101114 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.22), 0 10px 14px rgba(0,0,0,0.6)" }}>
<div className="absolute inset-0" style={{ background: "linear-gradient(180deg,rgba(255,226,170,0.55),rgba(255,226,170,0.1))", opacity: "calc(var(--p) * var(--lit))" }} />
</div>
<div aria-hidden="true" className="absolute rounded-[5px]" style={{ left: (BW - W) / 2, top: 0, width: W, height: 56, background: "linear-gradient(180deg,#6d737b 0%,#aeb4bc 7%,#858b93 20%,#5b6068 48%,#3a3e45 82%,#1e2025 100%)", boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.75), inset 0 1.5px 0 rgba(255,255,255,0.5), 0 14px 20px -4px rgba(0,0,0,0.75)" }}>
<div className="absolute inset-0 rounded-[5px] opacity-50" style={{ backgroundImage: "repeating-linear-gradient(0deg,rgba(255,255,255,0.1) 0 1px,rgba(0,0,0,0.12) 1px 2px)" }} />
{[0, W - 22].map((x) => <div key={x} className="absolute top-0 h-full w-[22px]" style={{ left: x, background: "linear-gradient(90deg,rgba(0,0,0,0.35),rgba(255,255,255,0.14) 40%,rgba(0,0,0,0.4))", borderRadius: 5 }} />)}
{[34, 220, W - 232, W - 46].map((x) => <span key={x} className="absolute top-[10px] h-[7px] w-[7px] rounded-full" style={{ left: x, background: "radial-gradient(circle at 36% 30%,#f2f4f6,#737981 55%,#22252a)", boxShadow: "0 1px 1px rgba(0,0,0,0.8)" }} />)}
{/* pilot lamp: dark while the site is a draft, blue once it is open */}
<div className="absolute left-1/2 top-[15px] flex h-[26px] -translate-x-1/2 items-center gap-[9px] rounded-full bg-[#0b0c0f] pl-[8px] pr-[13px]" style={{ boxShadow: "inset 0 1px 2px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.3)" }}>
<span className="relative block h-[12px] w-[12px] rounded-full" style={{ background: "radial-gradient(circle at 36% 30%,#3a4352,#12151b 70%)", boxShadow: "inset 0 0 0 1px #000" }}>
<span className="absolute inset-0 rounded-full" style={{ background: "radial-gradient(circle at 36% 30%,#ffffff 0%,#a9d0ff 22%,#4F98FB 55%,#1f63c8 100%)", boxShadow: "0 0 10px 3px rgba(79,152,251,0.75), 0 0 28px 10px rgba(79,152,251,0.3)", opacity: "calc(var(--p) * var(--lit))" }} />
</span>
<span className="text-[10px] font-extrabold uppercase tracking-[0.2em]" style={{ color: live ? "#F6F4EF" : "rgba(246,244,239,0.62)" }}>{live ? "Live" : "Draft"}</span>
</div>
</div>
</div>
</div>
<div className="relative mt-1 flex items-center gap-[10px]">
<button
type="button"
aria-pressed={live}
onClick={() => api.current?.set(!live)}
className="flex h-[52px] items-center gap-[10px] rounded-full bg-[#F6F4EF] pl-[20px] pr-[24px] text-[15px] font-extrabold tracking-[-0.01em] text-[#0B0D12] shadow-[inset_0_-3px_0_rgba(11,13,18,0.14),0_18px_30px_-14px_rgba(0,0,0,0.9)] outline-none transition-transform duration-150 active:scale-[0.96] focus-visible:ring-2 focus-visible:ring-[#4F98FB] focus-visible:ring-offset-2 focus-visible:ring-offset-[#0B0D12] motion-reduce:transition-none"
>
<span aria-hidden="true" className="h-[9px] w-[9px] rounded-full" style={{ background: live ? BLUE : "#0B0D12", boxShadow: live ? "0 0 0 4px rgba(79,152,251,0.25)" : "none" }} />
{live ? "Back to draft" : "Go live"}
</button>
</div>
<p aria-live="polite" className="mt-[12px] text-center text-[12px] font-semibold tracking-[-0.005em] text-[#F6F4EF]/75">
{live ? `${site.name}: shutter up, lights on, open to the street` : "Draft: finished inside, shutter still down"}
</p>
</div>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/steel-roller-shutter-stencilled-draft-that-rattles-up-to-show-a-lit-shop-window-holding-the-live-site.json
The word is ordinary text with a mask made of the same repeating pitch as the slats: opaque over the face of each slat and mostly clear over the groove. The paint appears to skip the gaps the way sprayed lettering does on corrugated steel.
The position is integrated each frame from a force and a drag. When it passes either end it is clamped, a fraction of its speed is reversed, and a sideways shake of a few pixels is set that halves and flips every frame until it is gone.
Call your own function inside `onClick`, and call `api.current?.set(true)` when it resolves. `set(false)` brings the shutter down. The `live` state always holds the side the shutter is heading for.
Speed first, then position. A quick flick upward opens it from anywhere, a quick pull closes it, and a slow release opens it only if the shutter is past two fifths of the way up.
No. One loop writes the shutter position and the light level to two custom properties, and the glow, the spill on the pavement, the sill and the lamp all read them. The loop ends when the shutter is at rest.
Related: Publish Button That Fires a Rolled-Up Site up a Glass Pneumatic Tube, Tri-Fold Screen That Opens Leaf by Leaf from Phone to Tablet to Desktop as One Page Reflows, Framed Homepage Print Hung on a Nail by Picture Wire That Swings, Settles Crooked and Is Straightened.
More we.inc originals or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.