Website Sections
WallOfLove
Testimonials as love locks on a chain-link fence at dusk, hung in the shape of a heart. They swing as the pointer passes. Each one opens with a click and its note hangs out on a red thread, so the wall works as the whole crowd and one voice at a time.
Download
- React
- Tailwind CSS v4
- motion
components/ui/wall-of-love.tsx
"use client";
import { useEffect, useId, useRef, useState, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type Love = { quote: string; name: string; role: string; initials?: string };
type WallOfLoveProps = {
/** On the plate above the note. */
title?: string;
/** Who said what. The locks share these out in turn. */
testimonials?: Love[];
/** Ms before the next lock opens by itself. 0 stops that. */
every?: number;
/** Set the width here. Everything scales with it. */
className?: string;
};
const LOVES: Love[] = [
{ quote: "Set it up on a Sunday. By Monday my whole shop was running on it.", name: "Meena R.", role: "Tiffin centre, Madurai" },
{ quote: "My customers ask who made the site. That has never happened before.", name: "Arun K.", role: "Bike garage, Coimbatore" },
{ quote: "Every time I write in with a doubt, a real person answers the same day.", name: "Fathima S.", role: "Tailor, Chennai" },
{ quote: "Bills that took an hour now take five minutes. I go home early.", name: "Joseph D.", role: "Hardware shop, Tuticorin" },
{ quote: "Small price, no tricks, and it simply works. I tell everyone.", name: "Lakshmi V.", role: "Flower stall, Salem" },
{ quote: "My daughter built our page in one evening. It looks expensive.", name: "Ravi P.", role: "Sweet stall, Trichy" },
];
const HEART = [".##.##.", "#######", "#######", ".#####.", "..###..", "...#..."];
const CELL_X = 7.05; // cqw
const CELL_Y = 8.5;
const LOCKS = HEART.flatMap((row, y) =>
Array.from(row).flatMap((c, x) => (c === "#" ? [{ row: y, col: x, x: 2.6 + x * CELL_X, y: 9 + y * CELL_Y }] : [])),
);
const COUNT = LOCKS.length;
const FINISH = [
{ body: "#c99a3f", edge: "#7b5716", shine: "#f3d78c", ink: "#5e410d" }, // brass
{ body: "#c8321f", edge: "#6d120c", shine: "#f28a79", ink: "#fbe6d8" }, // red enamel
{ body: "#a9adb4", edge: "#555a63", shine: "#e7eaee", ink: "#33373d" }, // steel
{ body: "#16827a", edge: "#0a4641", shine: "#6fd3c8", ink: "#dff5f1" }, // teal enamel
{ body: "#b56a38", edge: "#663417", shine: "#eab58a", ink: "#4d260f" }, // copper
{ body: "#e37a9a", edge: "#8b2f4f", shine: "#fbc5d6", ink: "#55162e" }, // pink enamel
];
const initials = (love: Love) => love.initials ?? love.name.split(/\s+/).map((w) => w[0]).join("").slice(0, 2).toUpperCase();
function Lock({ index, text, open }: { index: number; text: string; open: boolean }) {
const f = FINISH[(index * 7 + Math.floor(index / 4)) % FINISH.length];
const shape = (index * 7) % 3;
return (
<svg viewBox="0 0 40 48" className="block size-full overflow-visible" aria-hidden>
<g style={{ transformOrigin: "29px 22px", transform: open ? "rotate(32deg) translateY(-4px)" : "none", transition: "transform 260ms cubic-bezier(.3,1.6,.5,1)" }}>
<path d="M11 23 V13 a9 9 0 0 1 18 0 V23" fill="none" stroke="#6b7078" strokeWidth="4.4" />
<path d="M11 23 V13 a9 9 0 0 1 18 0 V23" fill="none" stroke="#d6dade" strokeWidth="1.5" />
</g>
{shape === 0 && <rect x="4.5" y="20" width="31" height="26" rx="5" fill={f.body} stroke={f.edge} strokeWidth="1.6" />}
{shape === 1 && <path d="M20 47 C 5 37, 2 30, 3 25 C 4 18, 15 16, 20 23 C 25 16, 36 18, 37 25 C 38 30, 35 37, 20 47 Z" fill={f.body} stroke={f.edge} strokeWidth="1.6" />}
{shape === 2 && <circle cx="20" cy="32" r="15" fill={f.body} stroke={f.edge} strokeWidth="1.6" />}
<path d={shape === 1 ? "M8 25 C 9 22, 13 21, 15 23" : "M9 24 L 16 24"} fill="none" stroke={f.shine} strokeWidth="2" strokeLinecap="round" opacity="0.85" />
<text x="20" y={shape === 1 ? 35 : 36} textAnchor="middle" fontSize="11" fontWeight="800" fontFamily="ui-monospace, monospace" fill={f.ink}>{text}</text>
</svg>
);
}
/* WallOfLove: testimonials as love locks on a chain-link fence at dusk, hung in the shape of a heart. They swing as the pointer passes. Each one opens with a click and its note hangs out on a red thread, so the wall works as both the whole crowd and one voice at a time. */
export function WallOfLove({ title = "Wall of love", testimonials = LOVES, every = 5400, className = "w-full max-w-3xl" }: WallOfLoveProps) {
const reduce = useReducedMotion();
const id = useId().replace(/[^a-zA-Z0-9]/g, "");
const box = useRef<HTMLDivElement>(null);
const stage = useRef<HTMLDivElement>(null);
const seen = useInView(box, { once: true, amount: 0.35 });
const n = testimonials.length;
const [active, setActive] = useState(3);
const [chosen, setChosen] = useState(false);
const [paused, setPaused] = useState(false);
const swings = useRef(LOCKS.map(() => ({ a: 0, w: 0 })));
const raf = useRef(0);
const last = useRef(0);
const prev = useRef<{ x: number; t: number } | null>(null);
const runSwing = () => {
if (raf.current) return;
last.current = performance.now();
const tick = (now: number) => {
const dt = Math.min(0.033, (now - last.current) / 1000);
last.current = now;
const root = stage.current;
if (!root) return;
let moving = false;
swings.current.forEach((s, i) => {
s.w += (-90 * s.a - 2.6 * s.w) * dt;
s.a += s.w * dt;
if (Math.abs(s.a) > 0.05 || Math.abs(s.w) > 0.4) moving = true;
else {
s.a = 0;
s.w = 0;
}
const el = root.querySelector<HTMLElement>(`[data-swing="${i}"]`);
if (el) el.style.transform = `rotate(${s.a.toFixed(2)}deg)`;
});
raf.current = moving ? requestAnimationFrame(tick) : 0;
};
raf.current = requestAnimationFrame(tick);
};
useEffect(
() => () => {
cancelAnimationFrame(raf.current);
raf.current = 0;
},
[],
);
const pull = (i: number, push: number) => {
if (reduce) return;
const s = swings.current[i];
if (!s) return;
s.w = Math.max(-150, Math.min(150, s.w + push));
runSwing();
};
useEffect(() => {
if (!seen || !every || chosen || paused || reduce) return;
const t = setTimeout(() => {
setActive((a) => (a + 11) % COUNT);
}, every);
return () => clearTimeout(t);
}, [seen, every, chosen, paused, reduce, active]);
useEffect(() => {
pull(active, 62);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [active]);
const brush = (e: PointerEvent<HTMLDivElement>) => {
const root = stage.current;
if (!root || reduce) return;
const r = root.getBoundingClientRect();
const scale = 100 / r.width;
const x = (e.clientX - r.left) * scale;
const y = (e.clientY - r.top) * scale;
const before = prev.current;
prev.current = { x, t: e.timeStamp };
if (!before || e.timeStamp - before.t > 80) return;
const speed = (x - before.x) * 8;
LOCKS.forEach((lock, i) => {
const dx = lock.x + 3.5 - x;
const dy = lock.y + 4 - y;
if (dx * dx + dy * dy < 42) pull(i, speed);
});
};
const choose = (i: number) => {
setChosen(true);
setActive(i);
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const here = LOCKS[active];
if (!here) return;
let to = -1;
if (e.key === "ArrowRight") to = (active + 1) % COUNT;
else if (e.key === "ArrowLeft") to = (active + COUNT - 1) % COUNT;
else if (e.key === "Home") to = 0;
else if (e.key === "End") to = COUNT - 1;
else if (e.key === "ArrowUp" || e.key === "ArrowDown") {
const row = here.row + (e.key === "ArrowUp" ? -1 : 1);
let far = Infinity;
LOCKS.forEach((l, i) => {
const d = Math.abs(l.col - here.col);
if (l.row === row && d < far) {
far = d;
to = i;
}
});
}
if (to < 0) return;
e.preventDefault();
choose(to);
e.currentTarget.querySelector<HTMLElement>(`[data-lock="${to}"]`)?.focus();
};
const love = testimonials[active % n];
const lock = LOCKS[active];
const hookX = 60.5;
const hookY = 15.4;
const startX = (lock?.x ?? 0) + 3.5;
const startY = (lock?.y ?? 0) + 7;
return (
<div ref={box} className={`@container ${className}`}>
<div
ref={stage}
onPointerMove={brush}
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => {
setPaused(false);
prev.current = null;
}}
onFocusCapture={() => setPaused(true)}
onBlurCapture={() => setPaused(false)}
className="relative overflow-hidden rounded-[1.6cqw] select-none"
style={{ paddingTop: "66cqw", background: "linear-gradient(180deg, #16193a, #2c2752 46%, #7a3f63 78%, #c96d5f)" }}
>
{/* Lights far off across the water. */}
<div
className="absolute inset-0"
style={{ background: "radial-gradient(1cqw 1cqw at 12% 74%, #ffd98a, transparent), radial-gradient(1.2cqw 1.2cqw at 30% 80%, #ffe6b0, transparent), radial-gradient(0.9cqw 0.9cqw at 52% 76%, #ffc978, transparent), radial-gradient(1.2cqw 1.2cqw at 71% 83%, #ffd98a, transparent), radial-gradient(0.9cqw 0.9cqw at 91% 75%, #ffe6b0, transparent)", filter: "blur(0.2cqw)" }}
aria-hidden
/>
{/* Chain link. */}
<svg className="absolute inset-0 size-full" aria-hidden>
<defs>
<pattern id={`${id}w`} width="24" height="24" patternUnits="userSpaceOnUse">
<path d="M0 12 L12 0 L24 12 L12 24 Z" fill="none" stroke="#c6ccd8" strokeOpacity="0.3" strokeWidth="1.1" />
<path d="M0 12 L12 0" fill="none" stroke="#f4f6fa" strokeOpacity="0.28" strokeWidth="1.1" />
</pattern>
</defs>
<rect width="100%" height="100%" fill={`url(#${id}w)`} />
</svg>
<div className="absolute inset-x-0 top-0 h-[2.2cqw]" style={{ background: "linear-gradient(180deg, #8d94a1, #3f4450)" }} aria-hidden />
<div className="absolute inset-x-0 bottom-0 h-[1.8cqw]" style={{ background: "linear-gradient(180deg, #5b606c, #2b2e38)" }} aria-hidden />
<div className="absolute inset-0 z-10" role="group" aria-label="Choose a customer to read" onKeyDown={onKey}>
{LOCKS.map((l, i) => {
const who = testimonials[i % n];
const on = i === active;
return (
<button
key={i}
type="button"
data-lock={i}
tabIndex={on ? 0 : -1}
aria-pressed={on}
aria-label={`${who?.name}, ${who?.role}`}
onClick={() => choose(i)}
className="absolute cursor-pointer rounded-[1cqw] outline-offset-[0.2cqw]"
style={{ left: `${l.x}cqw`, top: `${l.y}cqw`, width: "7cqw", height: "8.4cqw" }}
>
<span data-swing={i} className="block size-full origin-top" style={{ filter: on ? "drop-shadow(0 0 1cqw #ffd98a)" : "drop-shadow(0 0.5cqw 0.4cqw #0008)" }}>
<Lock index={i} text={who ? initials(who) : ""} open={on} />
</span>
</button>
);
})}
</div>
{/* Red thread from the open lock to the note. */}
<svg viewBox="0 0 100 66" className="pointer-events-none absolute inset-0 z-20 size-full overflow-visible" aria-hidden>
<motion.path
key={active}
d={`M${startX} ${startY} C ${startX + 12} ${startY + 12}, ${hookX - 14} ${hookY + 16}, ${hookX} ${hookY}`}
fill="none"
stroke="#e23a2c"
strokeWidth="0.42"
strokeLinecap="round"
initial={{ pathLength: reduce ? 1 : 0 }}
animate={{ pathLength: 1 }}
transition={{ duration: 0.42, ease: "easeOut" }}
/>
</svg>
<div className="absolute top-[4.2cqw] right-[3cqw] z-10 rounded-[0.5cqw] px-[2cqw] py-[0.8cqw] font-mono text-[2.5cqw] font-bold tracking-[0.16em] uppercase" style={{ background: "linear-gradient(180deg, #3a3f4b, #252933)", color: "#f4dfae", boxShadow: "0 0.3cqw 0.6cqw #0007" }}>
{title}
</div>
<motion.figure
key={active % n}
className="absolute top-[11cqw] right-[3cqw] z-30 m-0 w-[38cqw] origin-[10%_0%] rounded-[0.8cqw] px-[3cqw] pt-[4.6cqw] pb-[2.6cqw]"
style={{ background: "linear-gradient(180deg, #fff9ea, #f5ebd2)", color: "#2a211a", boxShadow: "0 1cqw 2.2cqw #0008" }}
initial={reduce ? false : { rotate: 5, y: "-1.4cqw", opacity: 0 }}
animate={{ rotate: -1, y: "0cqw", opacity: 1 }}
transition={{ type: "spring", stiffness: 220, damping: 11 }}
aria-live={chosen ? "polite" : "off"}
>
<span className="absolute top-[1.6cqw] left-[2.8cqw] block size-[2.2cqw] rounded-full" style={{ background: "#3a2f55", boxShadow: "inset 0 0 0 0.45cqw #c99a3f" }} aria-hidden />
<blockquote className="m-0 text-[3.25cqw] leading-[1.3] font-medium" style={{ fontFamily: "Georgia, 'Times New Roman', serif" }}>
{love?.quote}
</blockquote>
<figcaption className="mt-[1.6cqw] text-[2.7cqw] leading-[1.2]">
<b className="block">{love?.name}</b>
<span style={{ color: "#6b5d4b" }}>{love?.role}</span>
</figcaption>
</motion.figure>
</div>
<ul className="sr-only">
{testimonials.map((t) => (
<li key={t.name}>
{t.quote} {t.name}, {t.role}.
</li>
))}
</ul>
</div>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/wall-of-love.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/wall-of-love-demo.tsx
import { WallOfLove } from "@/components/ui/wall-of-love"; export default function WallOfLoveExample() { return ( <div className="@container-size grid h-full place-items-center overflow-hidden bg-[#0f1026] p-2"> <WallOfLove className="w-[min(146cqh,96cqw,820px)]" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| title | string | "Wall of love" | On the plate above the note. |
| testimonials | { quote: string; name: string; role: string; initials?: string }[] | 6 people | The 27 locks share these out in turn. Keep each quote under about 90 letters. |
| every | number | 5400 | Ms before the next lock opens by itself. 0 stops that. Stops once a visitor picks a lock. |
| className | string | "w-full max-w-3xl" | Set the width here. Everything scales with it. |