A dark room with three things in it. On the left, a machined torch with a sentence about a florist set into its barrel. In the middle, a block of glass. On the right, a sheet that you cannot read until light reaches it. The torch throws a thin white beam into the prism, the glass bends it and opens it into five bands running from near white to deep blue, and each band lands on one strip of the sheet: the menu, the headline, the photograph of tulips, a row of three labels and the button. The bands switch on one after another, so the page is assembled from the top down, with dust drifting through the fan. Move the pointer up or down and the prism tips a few degrees, which slides the light off the page so the edges fall back into darkness. On a phone the scene scales to the width, and the sentence is repeated under it at full size next to the switch.
import { useEffect, useRef, useState } from "react";
import { useReducedMotion } from "framer-motion";
/** The sentence that goes in and the page that comes out. `rows` are the bottom edges of the five bands the light paints. */
const site = {
sentence: "A florist in Porto that delivers bouquets on Sundays.",
brand: "Ramo Flores",
links: ["Bouquets", "Weddings", "Visit"],
headline: ["Sundays,", "in bloom."],
photo: { src: "https://images.pexels.com/photos/31074939/pexels-photo-31074939.jpeg?auto=compress&cs=tinysrgb&w=700", alt: "White tulips wrapped in brown paper in a shaft of sunlight" },
chips: ["Tied by hand", "Seasonal stems", "Porto and Gaia"],
cta: "Send a bouquet",
note: "Order by Friday noon",
};
const BLUE = "#4F98FB";
const INK = "#0B0D12";
// Design pixels; the scene is scaled to fit.
const SCENE_W = 900;
const SCENE_H = 520;
const BEAM_Y = 262;
const LENS_X = 232;
const PAGE_X = 604;
const PAGE_Y = 96;
const PAGE_W = 274;
const PAGE_H = 334;
const ROWS = [0, 42, 138, 238, 286, PAGE_H]; // band edges down the page
const TINTS = ["234,243,255", "190,217,254", "140,188,252", "79,152,251", "52,116,224"];
const CX = 372;
const CY = 285;
const SIDE = 204;
const DIM = 0.055; // how much of the page you can make out with the light off
const clamp = (v: number, lo: number, hi: number) => Math.min(hi, Math.max(lo, v));
const ease = (t: number) => 1 - Math.pow(1 - clamp(t, 0, 1), 3);
type P = { x: number; y: number };
function cross(p: P, d: P, a: P, b: P): P {
const ex = b.x - a.x;
const ey = b.y - a.y;
const den = d.x * ey - d.y * ex;
const t = ((a.x - p.x) * ey - (a.y - p.y) * ex) / (den || 1e-6);
return { x: p.x + d.x * t, y: p.y + d.y * t };
}
export default function PrismHomepage() {
const reduce = useReducedMotion();
const wrapRef = useRef<HTMLDivElement>(null);
const sceneRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const pageRef = useRef<HTMLDivElement>(null);
const [lit, setLit] = useState(true);
const sim = useRef({ on: true, since: -900, level: 0.6, rot: 0, trot: 0, next: 0, held: false, user: false, mask: "", redraw: () => {} });
useEffect(() => {
const wrap = wrapRef.current;
const scene = sceneRef.current;
const canvas = canvasRef.current;
const page = pageRef.current;
if (!wrap || !scene || !canvas || !page) return;
const ctx = canvas.getContext("2d");
if (!ctx) return;
const s = sim.current;
const DPR = 2;
canvas.width = SCENE_W * DPR;
canvas.height = SCENE_H * DPR;
let raf = 0;
let visible = true;
let last = performance.now();
s.since = last - 900;
s.next = last + 6400;
const h = SIDE * 0.866;
const motes = Array.from({ length: 44 }, (_, i) => ({ u: (i * 0.618) % 1, v: (i * 0.377 + 0.13) % 1, sp: 0.012 + ((i * 7) % 10) * 0.0022, ph: i * 1.7 }));
const measure = () => {
const sc = clamp((wrap.clientWidth - 20) / SCENE_W, 0.36, 1.24);
wrap.style.height = `${Math.round(SCENE_H * sc)}px`;
scene.style.transform = `translateX(-50%) scale(${sc.toFixed(4)})`;
};
measure();
const ro = new ResizeObserver(measure);
ro.observe(wrap);
const geo = (rot: number) => {
const th = (rot * Math.PI) / 180;
const rotp = (x: number, y: number): P => ({ x: CX + x * Math.cos(th) - y * Math.sin(th), y: CY + x * Math.sin(th) + y * Math.cos(th) });
const A = rotp(0, (-h * 2) / 3);
const B = rotp(-SIDE / 2, h / 3);
const C = rotp(SIDE / 2, h / 3);
const E = cross({ x: LENS_X, y: BEAM_Y }, { x: 1, y: 0 }, A, B);
const inside = (6 + rot * 0.7) * (Math.PI / 180);
const X = cross(E, { x: Math.cos(inside), y: Math.sin(inside) }, A, C);
return { A, B, C, E, X };
};
const X0 = geo(0).X;
const draw = (now: number) => {
const L = s.level;
const { A, B, C, E, X } = geo(s.rot);
// where each edge of the fan lands on the page, and how bright each band is
const lean = s.rot * 2.3 * (Math.PI / 180);
const hits = ROWS.map((r) => {
const a = Math.atan2(PAGE_Y + r - X0.y, PAGE_X - X0.x) + lean;
return X.y + Math.tan(a) * (PAGE_X - X.x);
});
const t = now - s.since;
const alpha = TINTS.map((_, i) => (reduce ? (s.on ? 1 : 0) : s.on ? ease((t - 260 - i * 150) / 620) : 1 - ease(t / 320)));
// the page is only as visible as the light falling on it
const at = (k: number, d: number) => `${(hits[k] - PAGE_Y + d).toFixed(1)}px`;
const lv = alpha.map((a) => (DIM + (1 - DIM) * a).toFixed(3));
const stops: string[] = [`rgba(0,0,0,${DIM}) 0px`, `rgba(0,0,0,${DIM}) ${at(0, -14)}`, `rgba(0,0,0,${lv[0]}) ${at(0, -3)}`];
for (let i = 1; i < 5; i++) stops.push(`rgba(0,0,0,${lv[i - 1]}) ${at(i, -5)}`, `rgba(0,0,0,${lv[i]}) ${at(i, 5)}`);
stops.push(`rgba(0,0,0,${lv[4]}) ${at(5, 3)}`, `rgba(0,0,0,${DIM}) ${at(5, 14)}`);
const mask = `linear-gradient(180deg, ${stops.join(", ")})`;
if (mask !== s.mask) {
s.mask = mask;
page.style.setProperty("-webkit-mask-image", mask);
page.style.setProperty("mask-image", mask);
}
scene.style.setProperty("--L", L.toFixed(3));
ctx.setTransform(DPR, 0, 0, DPR, 0, 0);
ctx.clearRect(0, 0, SCENE_W, SCENE_H);
ctx.lineCap = "round";
// the beam from the torch to the glass
ctx.globalCompositeOperation = "lighter";
const flick = reduce ? 1 : 0.94 + 0.06 * Math.sin(now / 90);
for (const [w, a] of [[26, 0.05], [10, 0.14], [3, 0.98]] as const) {
ctx.strokeStyle = `rgba(226,238,255,${(a * L * flick).toFixed(3)})`;
ctx.lineWidth = w;
ctx.beginPath();
ctx.moveTo(LENS_X, BEAM_Y);
ctx.lineTo(E.x, E.y);
ctx.stroke();
}
// a faint reflection thrown off the first face
const refl = ctx.createLinearGradient(E.x, E.y, E.x - 70, E.y - 150);
refl.addColorStop(0, `rgba(200,222,255,${(0.22 * L).toFixed(3)})`);
refl.addColorStop(1, "rgba(200,222,255,0)");
ctx.strokeStyle = refl;
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(E.x, E.y);
ctx.lineTo(E.x - 70, E.y - 150);
ctx.stroke();
// the fan
for (let i = 0; i < 5; i++) {
if (alpha[i] <= 0.004) continue;
const g = ctx.createLinearGradient(X.x, 0, PAGE_X, 0);
g.addColorStop(0, `rgba(${TINTS[i]},${(0.9 * alpha[i]).toFixed(3)})`);
g.addColorStop(0.3, `rgba(${TINTS[i]},${(0.5 * alpha[i]).toFixed(3)})`);
g.addColorStop(1, `rgba(${TINTS[i]},${(0.3 * alpha[i]).toFixed(3)})`);
ctx.fillStyle = g;
ctx.shadowColor = `rgba(${TINTS[i]},${(0.55 * alpha[i]).toFixed(3)})`;
ctx.shadowBlur = 36;
ctx.beginPath();
ctx.moveTo(X.x, X.y);
ctx.lineTo(PAGE_X, hits[i]);
ctx.lineTo(PAGE_X, hits[i + 1]);
ctx.closePath();
ctx.fill();
ctx.shadowBlur = 0;
ctx.strokeStyle = `rgba(${TINTS[i]},${(0.5 * alpha[i]).toFixed(3)})`;
ctx.lineWidth = 0.8;
ctx.beginPath();
ctx.moveTo(X.x, X.y);
ctx.lineTo(PAGE_X, hits[i]);
ctx.stroke();
// the bright edge where this band meets the sheet
ctx.strokeStyle = `rgba(${TINTS[i]},${(0.9 * alpha[i]).toFixed(3)})`;
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(PAGE_X, hits[i] + 1);
ctx.lineTo(PAGE_X, hits[i + 1] - 1);
ctx.stroke();
}
// dust in the light
for (const m of motes) {
const u = reduce ? m.u : (m.u + (now / 1000) * m.sp) % 1;
const x = X.x + 14 + (PAGE_X - X.x - 18) * u;
const k = (x - X.x) / (PAGE_X - X.x);
const top = X.y + (hits[0] - X.y) * k;
const bot = X.y + (hits[5] - X.y) * k;
const v = clamp(m.v + (reduce ? 0 : 0.03 * Math.sin(now / 1700 + m.ph)), 0.02, 0.98);
const band = Math.min(4, Math.floor(v * 5));
const tw = reduce ? 0.6 : 0.45 + 0.55 * Math.sin(now / 420 + m.ph) ** 2;
ctx.fillStyle = `rgba(240,246,255,${(0.75 * alpha[band] * tw).toFixed(3)})`;
ctx.beginPath();
ctx.arc(x, top + (bot - top) * v, 0.9 + (m.ph % 1) * 0.7, 0, 6.3);
ctx.fill();
}
// the prism: a block of glass with a little depth
ctx.globalCompositeOperation = "source-over";
const off = { x: 24, y: -15 };
const back = [A, B, C].map((p) => ({ x: p.x + off.x, y: p.y + off.y }));
const path = (pts: P[]) => {
ctx.beginPath();
ctx.moveTo(pts[0].x, pts[0].y);
for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i].x, pts[i].y);
ctx.closePath();
};
path(back);
ctx.fillStyle = "rgba(130,165,235,0.09)";
ctx.fill();
ctx.strokeStyle = "rgba(190,214,255,0.22)";
ctx.lineWidth = 1;
ctx.stroke();
// right-hand side face, where the light leaves
path([A, C, back[2], back[0]]);
const side = ctx.createLinearGradient(A.x, A.y, C.x, C.y);
side.addColorStop(0, "rgba(225,238,255,0.42)");
side.addColorStop(0.6, `rgba(120,170,250,${(0.10 + 0.16 * L).toFixed(3)})`);
side.addColorStop(1, "rgba(90,130,210,0.06)");
ctx.fillStyle = side;
ctx.fill();
// front face
path([A, B, C]);
const face = ctx.createLinearGradient(B.x, B.y, (A.x + C.x) / 2 + 30, (A.y + C.y) / 2);
face.addColorStop(0, "rgba(222,235,255,0.34)");
face.addColorStop(0.42, "rgba(110,140,205,0.10)");
face.addColorStop(0.7, "rgba(60,84,140,0.10)");
face.addColorStop(1, "rgba(170,204,255,0.26)");
ctx.fillStyle = face;
ctx.fill();
// facets seen through the glass: the far edges, bent
const mid = (p: P, q: P, k: number): P => ({ x: p.x + (q.x - p.x) * k, y: p.y + (q.y - p.y) * k });
path([mid(A, B, 0.34), mid(B, C, 0.3), mid(B, C, 0.52), mid(A, C, 0.2)]);
ctx.fillStyle = "rgba(255,255,255,0.055)";
ctx.fill();
path([mid(A, C, 0.62), mid(B, C, 0.78), C]);
ctx.fillStyle = "rgba(8,10,18,0.28)";
ctx.fill();
path([B, mid(B, A, 0.2), mid(B, C, 0.16)]);
ctx.fillStyle = "rgba(8,10,18,0.22)";
ctx.fill();
ctx.save();
path([A, B, C]);
ctx.clip();
ctx.globalCompositeOperation = "lighter";
// light spreading on its way through
const thru = ctx.createLinearGradient(E.x, E.y, X.x, X.y);
thru.addColorStop(0, `rgba(240,246,255,${(0.9 * L).toFixed(3)})`);
thru.addColorStop(1, `rgba(120,176,252,${(0.55 * L).toFixed(3)})`);
ctx.fillStyle = thru;
const nx = -(X.y - E.y);
const ny = X.x - E.x;
const nl = Math.hypot(nx, ny) || 1;
ctx.beginPath();
ctx.moveTo(E.x, E.y - 1.4);
ctx.lineTo(X.x + (nx / nl) * 7, X.y + (ny / nl) * 7);
ctx.lineTo(X.x - (nx / nl) * 7, X.y - (ny / nl) * 7);
ctx.lineTo(E.x, E.y + 1.4);
ctx.closePath();
ctx.fill();
for (const [p, r, a] of [[E, 58, 0.5], [X, 74, 0.6]] as const) {
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
g.addColorStop(0, `rgba(190,216,255,${(a * L).toFixed(3)})`);
g.addColorStop(1, "rgba(190,216,255,0)");
ctx.fillStyle = g;
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2);
}
// a soft streak of room light lying along the left face
const sA = { x: A.x + (B.x - A.x) * 0.12 + 13, y: A.y + (B.y - A.y) * 0.12 + 6 };
const sB = { x: A.x + (B.x - A.x) * 0.8 + 13, y: A.y + (B.y - A.y) * 0.8 + 6 };
ctx.strokeStyle = "rgba(255,255,255,0.10)";
ctx.lineWidth = 9;
ctx.beginPath();
ctx.moveTo(sA.x, sA.y);
ctx.lineTo(sB.x, sB.y);
ctx.stroke();
ctx.restore();
ctx.globalCompositeOperation = "source-over";
// chamfer line just inside the front face
const gx = (A.x + B.x + C.x) / 3;
const gy = (A.y + B.y + C.y) / 3;
path([A, B, C].map((p) => ({ x: gx + (p.x - gx) * 0.84, y: gy + (p.y - gy) * 0.84 })));
ctx.strokeStyle = "rgba(255,255,255,0.16)";
ctx.lineWidth = 1;
ctx.stroke();
for (const [p, q] of [[A, back[0]], [C, back[2]], [B, back[1]]] as const) {
ctx.strokeStyle = "rgba(200,220,255,0.3)";
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
}
const edge = (p: P, q: P, c0: string, c1: string, w: number) => {
const g = ctx.createLinearGradient(p.x, p.y, q.x, q.y);
g.addColorStop(0, c0);
g.addColorStop(1, c1);
ctx.strokeStyle = g;
ctx.lineWidth = w;
ctx.beginPath();
ctx.moveTo(p.x, p.y);
ctx.lineTo(q.x, q.y);
ctx.stroke();
};
edge(A, B, "rgba(255,255,255,1)", "rgba(190,214,255,0.55)", 2.2);
edge(A, C, "rgba(255,255,255,0.8)", `rgba(120,176,252,${(0.4 + 0.5 * L).toFixed(3)})`, 1.8);
edge(B, C, "rgba(170,198,245,0.35)", "rgba(220,234,255,0.8)", 2);
ctx.globalCompositeOperation = "lighter";
for (const [p, r, a] of [[A, 16, 0.5], [E, 12, 0.9 * L], [X, 16, 0.9 * L]] as const) {
const g = ctx.createRadialGradient(p.x, p.y, 0, p.x, p.y, r);
g.addColorStop(0, `rgba(255,255,255,${a.toFixed(3)})`);
g.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = g;
ctx.fillRect(p.x - r, p.y - r, r * 2, r * 2);
}
ctx.globalCompositeOperation = "source-over";
};
const tick = (now: number) => {
const dt = Math.min(0.033, (now - last) / 1000);
last = now;
if (reduce) {
s.level = s.on ? 1 : 0;
s.rot = 0;
} else {
s.level += ((s.on ? 1 : 0) - s.level) * Math.min(1, dt * 9);
const idle = Math.sin(now / 2300) * 0.6;
s.rot += (s.trot + idle - s.rot) * Math.min(1, dt * 6);
if (!s.user && !s.held && now > s.next) {
s.on = !s.on;
s.since = now;
s.next = now + (s.on ? 7200 : 1100);
setLit(s.on);
}
}
draw(now);
if (visible && !reduce) raf = requestAnimationFrame(tick);
};
s.redraw = () => tick(performance.now());
const io = new IntersectionObserver(([e]) => {
const was = visible;
visible = e.isIntersecting;
if (visible && !was) {
last = performance.now();
raf = requestAnimationFrame(tick);
}
});
io.observe(wrap);
raf = requestAnimationFrame(tick);
const move = (e: PointerEvent) => {
const r = wrap.getBoundingClientRect();
s.trot = clamp(((e.clientY - r.top) / r.height - 0.5) * 2, -1, 1) * 6;
};
const leave = () => {
s.trot = 0;
};
wrap.addEventListener("pointermove", move);
wrap.addEventListener("pointerdown", move);
wrap.addEventListener("pointerleave", leave);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
wrap.removeEventListener("pointermove", move);
wrap.removeEventListener("pointerdown", move);
wrap.removeEventListener("pointerleave", leave);
};
}, [reduce]);
const toggle = () => {
const s = sim.current;
s.on = !s.on;
s.since = performance.now();
s.user = !s.on;
s.next = s.since + 9000;
setLit(s.on);
if (reduce) s.redraw();
};
const hold = (v: boolean) => () => {
sim.current.held = v;
if (!v) sim.current.next = performance.now() + 3000;
};
const sans = '"Inter Tight", "Inter", ui-sans-serif, system-ui, sans-serif';
const row = (i: number) => ({ top: ROWS[i], height: ROWS[i + 1] - ROWS[i] });
return (
<section className="relative overflow-hidden bg-[#0B0D12]" style={{ fontFamily: sans }} onMouseEnter={hold(true)} onMouseLeave={hold(false)}>
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "radial-gradient(52% 70% at 42% 44%, #1a1f2b 0%, #10131a 48%, #0B0D12 100%)" }} />
<div ref={wrapRef} className="relative mx-auto mt-4 w-full max-w-[1120px] overflow-hidden sm:mt-6" style={{ height: SCENE_H }}>
<div ref={sceneRef} className="absolute left-1/2 top-0 select-none" style={{ width: SCENE_W, height: SCENE_H, transformOrigin: "50% 0", transform: "translateX(-50%)", perspective: "1300px", perspectiveOrigin: "70% 50%", ["--L" as string]: 0.6 }}>
{/* light bouncing off the sheet into the room */}
<div aria-hidden="true" className="absolute rounded-full" style={{ left: PAGE_X - 120, top: PAGE_Y - 70, width: PAGE_W + 240, height: PAGE_H + 140, background: "radial-gradient(closest-side, rgba(79,152,251,0.20), rgba(79,152,251,0))", opacity: "var(--L)" as unknown as number }} />
{/* the torch that carries the sentence */}
<div aria-hidden="true" className="absolute" style={{ left: 4, top: BEAM_Y - 35, width: LENS_X - 4, height: 70 }}>
<div className="absolute inset-0 rounded-full" style={{ background: "linear-gradient(180deg, #5a606c 0%, #343842 9%, #1c1f27 38%, #12141a 62%, #23262f 90%, #0a0b0f 100%)", boxShadow: "inset 0 1px 0 rgba(255,255,255,0.35), inset 0 -1px 0 rgba(0,0,0,0.8), 0 0 0 1px rgba(0,0,0,0.6)" }} />
<div className="absolute inset-y-[9px] left-[12px] right-[56px] overflow-hidden rounded-full" style={{ background: "linear-gradient(180deg, #06070a, #0e1016)", boxShadow: "inset 0 2px 4px rgba(0,0,0,0.9), 0 1px 0 rgba(255,255,255,0.10)" }}>
<p className="absolute inset-y-0 left-[16px] right-[8px] flex items-center text-[11.5px] font-bold leading-[1.2] tracking-[-0.01em] text-white/90">{site.sentence}</p>
</div>
<div className="absolute inset-y-[3px] right-[36px] w-[12px]" style={{ backgroundImage: "repeating-linear-gradient(90deg, rgba(0,0,0,0.7) 0 1.5px, rgba(255,255,255,0.16) 1.5px 3px), linear-gradient(180deg, #6b717d, #1c1f27 40%, #0c0d11 70%, #2b2f39)" }} />
<div className="absolute right-[4px] top-1/2 h-[26px] w-[26px] -translate-y-1/2 rounded-full" style={{ background: "radial-gradient(circle at 50% 50%, #ffffff 0%, #cfe2ff 30%, #4F98FB 62%, #153a78 100%)", boxShadow: "0 0 0 2px #090a0d, 0 0 0 3px rgba(255,255,255,0.22)" }}>
<div className="absolute -inset-5 rounded-full" style={{ background: "radial-gradient(closest-side, rgba(170,205,255,0.75), rgba(79,152,251,0))", opacity: "var(--L)" as unknown as number }} />
<div className="absolute inset-0 rounded-full bg-[#06070a]" style={{ opacity: "calc(0.86 - var(--L) * 0.86)" as unknown as number }} />
</div>
</div>
{/* the sheet the light falls on */}
<div className="absolute" style={{ left: PAGE_X, top: PAGE_Y, width: PAGE_W, height: PAGE_H, transformOrigin: "0 50%", transform: "rotateY(17deg)", transformStyle: "preserve-3d" }}>
<div aria-hidden="true" className="absolute inset-0 rounded-[6px]" style={{ background: "#12151c", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.07), 18px 26px 50px rgba(0,0,0,0.65)" }} />
<div ref={pageRef} className="absolute inset-0 overflow-hidden rounded-[6px] bg-[#F6F4EF]">
<div className="absolute inset-x-[18px] flex items-center justify-between" style={row(0)}>
<span className="flex items-center gap-[6px] text-[11.5px] font-extrabold tracking-[-0.02em]" style={{ color: INK }}>
<span aria-hidden="true" className="h-[8px] w-[8px] rounded-full" style={{ background: BLUE }} />
{site.brand}
</span>
<span className="flex gap-[10px] text-[8.5px] font-bold text-neutral-700">
{site.links.map((l) => (
<span key={l}>{l}</span>
))}
</span>
</div>
<h2 className="absolute inset-x-[18px] flex flex-col justify-center text-[40px] font-black leading-[0.92] tracking-[-0.055em]" style={{ ...row(1), color: INK }}>
{site.headline.map((l) => (
<span key={l} className="block">
{l}
</span>
))}
</h2>
<div className="absolute inset-x-[18px] overflow-hidden rounded-[4px] bg-[#231b12]" style={{ top: ROWS[2] + 3, height: ROWS[3] - ROWS[2] - 6 }}>
<img src={site.photo.src} alt={site.photo.alt} width={232} height={94} draggable={false} className="h-full w-full object-cover" style={{ objectPosition: "60% 58%" }} />
</div>
<ul className="absolute inset-x-[18px] flex items-center gap-[6px]" style={row(3)}>
{site.chips.map((c) => (
<li key={c} className="flex h-[26px] flex-1 items-center justify-center rounded-full border border-neutral-900/80 text-[8px] font-extrabold tracking-[-0.01em]" style={{ color: INK }}>
{c}
</li>
))}
</ul>
<div className="absolute inset-x-[18px] flex items-center gap-[10px]" style={{ ...row(4), paddingBottom: 6 }}>
<span className="inline-flex h-[30px] items-center gap-[6px] rounded-full px-[14px] text-[10.5px] font-extrabold tracking-[-0.01em]" style={{ background: BLUE, color: INK }}>
{site.cta}
<svg aria-hidden="true" width="10" height="10" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="2.8" strokeLinecap="round" strokeLinejoin="round">
<path d="M4 10h12M11 5l5 5-5 5" />
</svg>
</span>
<span className="text-[8.5px] font-bold text-neutral-700">{site.note}</span>
</div>
{/* the light thins out as it crosses the sheet */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0" style={{ background: "linear-gradient(90deg, rgba(190,216,255,0.16) 0%, rgba(255,255,255,0) 22%, rgba(11,13,18,0.12) 70%, rgba(11,13,18,0.34) 100%)" }} />
</div>
</div>
<canvas ref={canvasRef} aria-hidden="true" className="pointer-events-none absolute left-0 top-0" style={{ width: SCENE_W, height: SCENE_H }} />
</div>
</div>
<div className="relative mx-auto flex w-full max-w-[1000px] flex-col items-start gap-4 px-5 pb-7 pt-2 sm:flex-row sm:items-center sm:justify-between sm:px-8 sm:pb-10">
<p className="max-w-[440px] text-[14px] font-semibold leading-snug tracking-[-0.01em] text-white/85">
<span className="mb-1 block font-mono text-[10px] font-bold uppercase tracking-[0.28em]" style={{ color: BLUE }}>
One sentence in
</span>
{site.sentence}
</p>
<button
type="button"
onClick={toggle}
aria-pressed={lit}
className="inline-flex h-11 shrink-0 items-center gap-2 rounded-full border border-white/15 bg-white/[0.06] px-5 text-[13px] font-bold tracking-[-0.01em] text-white transition hover:bg-white/[0.14] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#4F98FB]"
>
<span aria-hidden="true" className="h-2 w-2 rounded-full" style={{ background: lit ? BLUE : "rgba(255,255,255,0.4)", boxShadow: lit ? `0 0 10px ${BLUE}` : "none" }} />
{lit ? "Cut the light" : "Send the light"}
</button>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/glass-prism-that-splits-a-beam-carrying-one-sentence-into-a-homepage-made-of-light.json
Every frame the script works out where the six edges of the fan meet the left side of the sheet, then writes a `mask-image` gradient whose stops sit at those heights. A band at full strength makes its strip opaque; with the beam off the strip drops to the `DIM` level.
The path is: straight in, bent toward the base inside the glass, spread on the way out. The angles are chosen so the fan covers the sheet, which is far wider than real dispersion in glass. Treat it as an illustration of a prism, not a simulation.
The beam, the fan, the dust and the prism are drawn on one 2D canvas with additive blending for the light. The torch, the sheet and the controls are elements, which keeps the page text sharp and selectable.
Yes. `ROWS` needs one more entry than `TINTS`. Add a value to each and place a new block of page content between the two row edges. The mask builder and the fan loop both use a fixed count of five, so change the `5` and `4` in those two loops to match.
One canvas at twice the design size, five filled triangles, about forty dots and a few gradients per frame. The loop pauses when the piece leaves the viewport.
Related: Pop-Up Card Where a Typed Sentence Stands Up as a Layered Paper Homepage, Proof Press with a Steel Cylinder That Rolls a Typed Brief into a Printed Homepage, Portrait Print with a Liquid Glass Bead That Chases the Pointer and Shows a Second Photograph.
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.