Skip to content

Quote Wall Marquee

Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite directions.

  • Testimonials
  • Developer tools
  • React only · 0 deps
  • 1 file
Full screen

Browser support · All evergreen browsers (CSS animations, mask-image). Reduced motion shows a static grid.

Install. One command, or copy the file.

$npx shadcn@latest add https://freedesignmd.com/r/quote-wall-marquee.json
  • Byte-identical to the preview
  • No Tailwind or config needed
  • SSR-safe · keyboard accessible
  • Respects reduced motion & transparency
components/testimonials/quote-wall-marquee.tsx
1/**
2 * QuoteWallMarquee, a wall of short social posts in two or three rows that
3 * drift in opposite directions. Each post carries a handle, a date and a
4 * code-ish detail (a command, a before/after number).
5 *
6 * Props: posts (Post[]), rows (2 | 3), eyebrow, title, aside, speed (seconds
7 * for one loop of the first row), className.
8 * Post: { name, handle, date, text, code?, before?, after?, avatar?, likes? }
9 *
10 * Behaviour: rows pause on hover and on keyboard focus; a Pause button stops
11 * all motion (WCAG 2.2.2). Edges fade out with a CSS mask.
12 * Reduced motion: the marquee becomes a static grid, duplicates are removed.
13 * Browser support: all evergreen browsers (CSS animations, mask-image).
14 * Accessibility: duplicated posts are aria-hidden, each post is an <article>
15 * with its author and date, the pause control is a real toggle button.
16 */
17import { useState } from "react";
18
19export interface Post {
20 name: string;
21 handle: string;
22 date: string;
23 text: string;
24 /** A command or snippet, rendered as a mono line. */
25 code?: string;
26 /** Before/after stat, e.g. before "14m 02s", after "3m 51s", label "ci". */
27 before?: string;
28 after?: string;
29 label?: string;
30 avatar?: string;
31 likes?: number;
32}
33
34export interface QuoteWallMarqueeProps {
35 posts?: Post[];
36 rows?: 2 | 3;
37 eyebrow?: string;
38 title?: string;
39 aside?: string;
40 /** Before/after pair shown large next to the aside. */
41 stat?: [string, string];
42 speed?: number;
43 className?: string;
44}
45
46const P = "/img/people/";
47
48export const DEFAULT_POSTS: Post[] = [
49 { name: "Nadia Rahman", handle: "nadia_builds", date: "Mar 3", avatar: P + "p6.webp", text: "turned on the remote cache for our turborepo at 11pm out of pure spite. CI went from 14 minutes to under 4. going to bed.", label: "ci", before: "14m 02s", after: "3m 51s", likes: 812 },
50 { name: "Tomasz Wierzbicki", handle: "tomasz.dev", date: "Feb 27", avatar: P + "p7.webp", text: "`ferrule why` just told me a single .env.example was busting the cache for 40 packages. Two years. Two YEARS.", code: "ferrule why apps/web --since main", likes: 1204 },
51 { name: "Grace Oyelaran", handle: "goyelaran", date: "Mar 11", avatar: P + "p9.webp", text: "Our flaky-test quarantine list shrank after we moved to hermetic runners. I did not touch a single test. Slightly offended, honestly.", label: "quarantined", before: "37", after: "4", likes: 356 },
52 { name: "Mei Tanaka", handle: "mei_ships", date: "Mar 9", text: "honest review: the setup docs assume pnpm. we're on yarn 1 like cavemen, so it took 25 min instead of 5. still the best afternoon of the quarter.", code: "yarn@1.22.19 # don't @ me", likes: 97 },
53 { name: "Rafael Silva", handle: "rsilva", date: "Feb 19", avatar: P + "p5.webp", text: "CI bill, February: $4,212. March, with Ferrule: $1,380. Our CFO replied with a thumbs up, which has never once happened.", label: "ci spend", before: "$4,212", after: "$1,380", likes: 2290 },
54 { name: "Hollis Grant", handle: "hollisg", date: "Mar 14", text: "the cache hit chart is the first CI graph I have ever opened on purpose", label: "hit rate", before: "38%", after: "91.4%", likes: 143 },
55 { name: "Kwame Mensah", handle: "kwame.codes", date: "Mar 2", avatar: P + "p4.webp", text: "PR checks now finish before I'm done writing the PR description. That's not a speed bump, that changes how the team works.", label: "p50 pr check", before: "11m 40s", after: "2m 06s", likes: 640 },
56 { name: "Lena Kowalczyk", handle: "lenak", date: "Feb 24", avatar: P + "p1.webp", text: "Migrated 3 repos off self-hosted runners. Deleted 1,100 lines of bash. Nobody has asked where they went.", code: "git rm -r .ci/scripts # -1,104", likes: 1876 },
57 { name: "Karim Haddad", handle: "karimh", date: "Mar 6", avatar: P + "p2.webp", text: "small thing, but replaying a failed CI job on my laptop, same env, same cache, same failure, is actual witchcraft", code: "ferrule replay run_8f21c0", likes: 529 },
58 { name: "Juniper Ames", handle: "junebug_eng", date: "Feb 28", text: "we had one engineer whose whole job was babysitting the Jenkins box. he works on the product now and is happier about it than any of us.", likes: 401 },
59 { name: "Adaeze Okafor", handle: "adaeze", date: "Mar 12", avatar: P + "p3.webp", text: "the free tier is 10 GB of cache, which sounded small until I checked and we were using 3.2 of it", code: "ferrule usage 3.2 GB / 10 GB", likes: 88 },
60 { name: "Ji-ho Park", handle: "jihopark", date: "Mar 15", avatar: P + "p8.webp", text: "first time I saw a 600 MB docker layer come back as a remote hit I laughed out loud in a quiet office", code: "layer sha256:9c1e… HIT (remote) 0.8s", likes: 733 },
61];
62
63function Avatar({ p }: { p: Post }) {
64 if (p.avatar) return <img className="fd-qwm-av" src={p.avatar} alt="" width={36} height={36} loading="lazy" decoding="async" />;
65 const ini = p.name.split(" ").map((w) => w[0]).slice(0, 2).join("");
66 return <span className="fd-qwm-av fd-qwm-ini" aria-hidden="true">{ini}</span>;
67}
68
69function renderText(t: string) {
70 return t.split(/(`[^`]+`)/g).map((s, i) => (s.startsWith("`") ? <code key={i} className="fd-qwm-ic">{s.slice(1, -1)}</code> : s));
71}
72
73function Card({ p, i, hidden }: { p: Post; i: number; hidden?: boolean }) {
74 return (
75 <article className="fd-qwm-card" data-i={i} aria-hidden={hidden || undefined} aria-label={hidden ? undefined : `Post by ${p.name}`}>
76 <header className="fd-qwm-ch">
77 <Avatar p={p} />
78 <span className="fd-qwm-who">
79 <span className="fd-qwm-name">{p.name}</span>
80 <span className="fd-qwm-handle">@{p.handle}</span>
81 </span>
82 <time className="fd-qwm-date">{p.date}</time>
83 </header>
84 <p className="fd-qwm-text">{renderText(p.text)}</p>
85 {p.code && (
86 <div className="fd-qwm-code"><span aria-hidden="true">$</span>{p.code}</div>
87 )}
88 {p.before && p.after && (
89 <div className="fd-qwm-diff" aria-label={`${p.label ?? "value"} went from ${p.before} to ${p.after}`}>
90 <span className="fd-qwm-dl">{p.label}</span>
91 <s>{p.before}</s>
92 <svg width="18" height="10" viewBox="0 0 18 10" aria-hidden="true"><path d="M1 5h15M12 1l4 4-4 4" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round" /></svg>
93 <b>{p.after}</b>
94 </div>
95 )}
96 {p.likes != null && (
97 <footer className="fd-qwm-foot">
98 <svg width="13" height="12" viewBox="0 0 13 12" aria-hidden="true"><path d="M6.5 11S1 7.6 1 4.1C1 2.4 2.3 1 3.9 1c1.1 0 2 .6 2.6 1.5C7.1 1.6 8 1 9.1 1 10.7 1 12 2.4 12 4.1 12 7.6 6.5 11 6.5 11z" fill="none" stroke="currentColor" strokeWidth="1.2" /></svg>
99 <span>{p.likes.toLocaleString("en-US")}</span>
100 </footer>
101 )}
102 </article>
103 );
104}
105
106export function QuoteWallMarquee({
107 posts = DEFAULT_POSTS,
108 rows = 3,
109 eyebrow = "// unpaid, unprompted, mostly typed at night",
110 title = "People stopped watching the progress bar.",
111 aside = "Median CI time across 212 public repos that switched in Q1 2026.",
112 stat = ["11m 40s", "2m 06s"],
113 speed = 70,
114 className,
115}: QuoteWallMarqueeProps) {
116 const [paused, setPaused] = useState(false);
117 const lanes: Post[][] = Array.from({ length: rows }, () => []);
118 posts.forEach((p, i) => lanes[i % rows].push(p));
119 let n = 0;
120 return (
121 <section className={`fd-qwm${className ? " " + className : ""}`} data-paused={paused || undefined} aria-label="What developers say">
122 <style>{css}</style>
123 <div className="fd-qwm-head">
124 <div className="fd-qwm-hl">
125 <p className="fd-qwm-eyebrow">{eyebrow}</p>
126 <h2 className="fd-qwm-title">{title}</h2>
127 </div>
128 <div className="fd-qwm-hr">
129 {stat && (
130 <p className="fd-qwm-stat" aria-label={`${stat[0]} before, ${stat[1]} after`}>
131 <s>{stat[0]}</s><b>{stat[1]}</b>
132 </p>
133 )}
134 <p className="fd-qwm-aside">{aside}</p>
135 <button type="button" className="fd-qwm-pause" style={{ marginTop: 6 }} aria-pressed={paused} onClick={() => setPaused((v) => !v)}>
136 <svg width="10" height="10" viewBox="0 0 10 10" aria-hidden="true">{paused ? <path d="M2 1l7 4-7 4z" fill="currentColor" /> : <path d="M2.5 1v8M7.5 1v8" stroke="currentColor" strokeWidth="2" />}</svg>
137 {paused ? "Play wall" : "Pause wall"}
138 </button>
139 </div>
140 </div>
141 <div className="fd-qwm-rows">
142 {lanes.map((lane, r) => {
143 const start = n;
144 n += lane.length;
145 return (
146 <div className="fd-qwm-row" key={r} data-dir={r % 2 ? "rev" : "fwd"} style={{ ["--d" as string]: `${speed + r * 9}s` }}>
147 <div className="fd-qwm-track">
148 {lane.map((p, i) => <Card key={"a" + i} p={p} i={start + i} />)}
149 {lane.map((p, i) => <Card key={"b" + i} p={p} i={start + i} hidden />)}
150 </div>
151 </div>
152 );
153 })}
154 </div>
155 </section>
156 );
157}
158
159const css = `
160.fd-qwm,.fd-qwm *,.fd-qwm *::before,.fd-qwm *::after{box-sizing:border-box}
161.fd-qwm{--fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;
162 --fd-qwm-bg:#11110F;--fd-qwm-card:#191916;--fd-qwm-fg:#ECE8DF;--fd-qwm-mut:#8C877C;--fd-qwm-line:rgba(236,232,223,.09);--fd-qwm-amber:#F0B43C;
163 position:relative;display:block;margin:0;padding:80px 0 96px;background:var(--fd-qwm-bg);color:var(--fd-qwm-fg);overflow:hidden;
164 font:400 15px/1.5 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.005em;-webkit-font-smoothing:antialiased}
165.fd-qwm-head{display:grid;grid-template-columns:minmax(0,1.5fr) minmax(0,1fr);gap:40px;align-items:end;max-width:1240px;margin:0 auto 48px;padding:0 32px}
166.fd-qwm-eyebrow{margin:0 0 20px;padding:0;font:500 13px/1.4 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-qwm-amber)}
167.fd-qwm-title{margin:0;padding:0;max-width:13ch;font:700 clamp(2.5rem,5.4vw,4.75rem)/.98 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Display","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.045em;color:var(--fd-qwm-fg)}
168.fd-qwm-hr{display:flex;flex-direction:column;align-items:flex-start;gap:14px;padding-bottom:6px;border-left:1px solid var(--fd-qwm-line);padding-left:24px}
169.fd-qwm-stat{display:flex;align-items:baseline;gap:16px;margin:0;padding:0;font:500 clamp(1.5rem,2.6vw,2rem)/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:-.03em;color:var(--fd-qwm-fg)}
170.fd-qwm-stat s{font-size:.6em;color:var(--fd-qwm-mut);text-decoration-color:rgba(236,232,223,.45)}
171.fd-qwm-stat b{font-weight:700;color:var(--fd-qwm-amber)}
172.fd-qwm-aside{margin:0;padding:0;max-width:34ch;font:400 0.9375rem/1.55 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;color:var(--fd-qwm-mut)}
173.fd-qwm-pause{appearance:none;-webkit-appearance:none;display:inline-flex;align-items:center;gap:8px;height:32px;margin:0;padding:0 14px;border:1px solid var(--fd-qwm-line);border-radius:9999px;background:transparent;color:var(--fd-qwm-fg);cursor:pointer;
174 font:500 12px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;transition:border-color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}
175.fd-qwm-pause svg{display:block}
176.fd-qwm-pause:hover{border-color:rgba(236,232,223,.3)}
177.fd-qwm-pause:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}
178.fd-qwm-pause:focus-visible,.fd-qwm-card:focus-visible{outline:2px solid rgba(240,180,60,.7);outline-offset:2px}
179.fd-qwm-rows{display:flex;flex-direction:column;gap:16px}
180.fd-qwm-row{overflow:hidden;-webkit-mask-image:linear-gradient(90deg,transparent,#000 9%,#000 91%,transparent);mask-image:linear-gradient(90deg,transparent,#000 9%,#000 91%,transparent)}
181.fd-qwm-track{display:flex;align-items:stretch;width:max-content;animation:fd-qwm-slide var(--d,70s) linear infinite}
182.fd-qwm-row[data-dir="rev"] .fd-qwm-track{animation-direction:reverse}
183.fd-qwm-row:hover .fd-qwm-track,.fd-qwm-row:focus-within .fd-qwm-track,.fd-qwm[data-paused] .fd-qwm-track{animation-play-state:paused}
184@keyframes fd-qwm-slide{from{transform:translate3d(0,0,0)}to{transform:translate3d(-50%,0,0)}}
185.fd-qwm-card{position:relative;display:flex;flex-direction:column;gap:14px;width:340px;margin:0 16px 0 0;padding:20px 20px 16px;border:1px solid var(--fd-qwm-line);border-radius:14px;background:var(--fd-qwm-card);color:var(--fd-qwm-fg);
186 transition:border-color var(--fd-dur-2) var(--fd-ease-out),background-color var(--fd-dur-2)}
187.fd-qwm-card:nth-child(3n+2){width:376px}.fd-qwm-card:nth-child(4n+3){width:308px}
188.fd-qwm-card:hover{border-color:rgba(240,180,60,.38);background:#1D1C18}
189.fd-qwm-ch{display:flex;align-items:center;gap:10px;margin:0;padding:0}
190.fd-qwm-av{display:block;flex:none;width:36px;height:36px;border-radius:50%;object-fit:cover;object-position:50% 22%;background:#2A2924;filter:grayscale(.15) contrast(1.02)}
191.fd-qwm-ini{display:grid;place-items:center;font:600 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:#11110F;background:#C9C2B2}
192.fd-qwm-who{display:flex;flex-direction:column;min-width:0;flex:1}
193.fd-qwm-name{display:block;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font:600 14px/1.25 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.01em;color:var(--fd-qwm-fg)}
194.fd-qwm-handle,.fd-qwm-date{display:block;font:400 12px/1.4 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-qwm-mut)}
195.fd-qwm-date{align-self:flex-start;flex:none}
196.fd-qwm-text{margin:0;padding:0;font:400 0.9375rem/1.5 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif;letter-spacing:-.006em;color:#DAD5CA;text-wrap:pretty}
197.fd-qwm-ic{display:inline;margin:0;padding:1px 5px;border-radius:5px;background:rgba(240,180,60,.12);font:500 0.8125rem/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-qwm-amber)}
198.fd-qwm-code{display:flex;gap:10px;margin:0;padding:9px 12px;border-radius:8px;background:#0C0C0A;border:1px solid var(--fd-qwm-line);overflow:hidden;white-space:nowrap;text-overflow:ellipsis;
199 font:400 12px/1.4 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:#CFC9BC}
200.fd-qwm-code span{color:var(--fd-qwm-amber)}
201.fd-qwm-diff{display:flex;align-items:center;gap:10px;margin:0;padding:9px 12px;border-radius:8px;border:1px dashed rgba(236,232,223,.16);font:500 13px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-qwm-fg)}
202.fd-qwm-dl{flex:1;min-width:0;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--fd-qwm-mut)}
203.fd-qwm-diff s{text-decoration:line-through;text-decoration-color:rgba(236,232,223,.4);color:var(--fd-qwm-mut)}
204.fd-qwm-diff svg{display:block;color:var(--fd-qwm-amber);transition:transform var(--fd-dur-3) var(--fd-spring)}
205.fd-qwm-card:hover .fd-qwm-diff svg{transform:translateX(3px)}
206.fd-qwm-diff b{font-weight:700;color:var(--fd-qwm-amber)}
207.fd-qwm-foot{display:flex;align-items:center;gap:6px;margin:auto 0 0;padding:10px 0 0;border-top:1px solid var(--fd-qwm-line);font:400 12px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:var(--fd-qwm-mut)}
208.fd-qwm-foot svg{display:block}
209@media (max-width:760px){
210 .fd-qwm{padding:64px 0 72px}
211 .fd-qwm-head{grid-template-columns:1fr;gap:24px;margin-bottom:36px;padding:0 16px}
212 .fd-qwm-hr{border-left:0;padding-left:0;border-top:1px solid var(--fd-qwm-line);padding-top:20px}
213 .fd-qwm-card,.fd-qwm-card:nth-child(3n+2),.fd-qwm-card:nth-child(4n+3){width:286px;margin-right:12px}
214 .fd-qwm-rows{gap:12px}
215}
216@media (prefers-reduced-motion:reduce){
217 .fd-qwm-pause{display:none}
218 .fd-qwm-rows{display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px;max-width:1240px;margin:0 auto;padding:0 32px}
219 .fd-qwm-row,.fd-qwm-track{display:contents}
220 .fd-qwm-card[aria-hidden="true"]{display:none}
221 .fd-qwm-card,.fd-qwm-card:nth-child(3n+2),.fd-qwm-card:nth-child(4n+3){width:auto;margin:0}
222 .fd-qwm-card[data-i="0"]{order:0}
223 .fd-qwm *{transition-duration:1ms!important}
224}
225@media (prefers-reduced-motion:reduce) and (max-width:760px){
226 .fd-qwm-rows{padding:0 16px;grid-template-columns:1fr}
227 .fd-qwm-card[data-i="6"],.fd-qwm-card[data-i="7"],.fd-qwm-card[data-i="8"],.fd-qwm-card[data-i="9"],.fd-qwm-card[data-i="10"],.fd-qwm-card[data-i="11"]{display:none}
228}
229@media (prefers-contrast:more){.fd-qwm-card{border-color:currentColor}}
230`;
231
232export function Demo() {
233 return (
234 <div className="fd-qwmd">
235 <style>{demoCss}</style>
236 <div className="fd-qwmd-bar">
237 <span className="fd-qwmd-logo">
238 <svg width="18" height="18" viewBox="0 0 18 18" aria-hidden="true"><rect x="1" y="1" width="16" height="16" rx="3" fill="none" stroke="#F0B43C" strokeWidth="1.6" /><path d="M5 6h8M5 9h5M5 12h8" stroke="#F0B43C" strokeWidth="1.6" strokeLinecap="round" /></svg>
239 ferrule
240 </span>
241 <span className="fd-qwmd-meta">remote cache + CI runners · v4.2</span>
242 </div>
243 <QuoteWallMarquee />
244 </div>
245 );
246}
247
248const demoCss = `
249.fd-qwmd,.fd-qwmd *{box-sizing:border-box}
250.fd-qwmd{min-height:100vh;margin:0;padding:0;background:#11110F;font:400 14px/1.4 ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif}
251.fd-qwmd-bar{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px;margin:0;padding:0 32px;border-bottom:1px solid rgba(236,232,223,.07)}
252.fd-qwmd-logo{display:inline-flex;align-items:center;gap:10px;font:700 17px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:-.02em;color:#ECE8DF}
253.fd-qwmd-logo svg{display:block}
254.fd-qwmd-meta{font:400 12px/1 ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace;letter-spacing:0;color:#8C877C}
255@media (max-width:760px){.fd-qwmd-bar{padding:0 16px}.fd-qwmd-meta{display:none}}
256`;

More Testimonials. Same file contract.

Portrait Quote Carousel, testimonials preview: Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait…

Portrait Quote Carousel

Testimonial carousel: one large serif guest quote at a time over an arched room photo, with a portrait filmstrip.

Case Study Cards, testimonials preview: Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote

Case Study Cards

Case study cards: customer stories with a counting headline metric, before and after bars and a portrait quote.

Review Grid Summary, testimonials preview: Review grid with rating summary: filtering bars, chips for verified, photos and skin type, masonry cards and…

Review Grid Summary

Review grid with rating summary: filtering bars, chips for verified, photos and skin type, masonry cards and helpful votes.

Questions about Quote Wall Marquee

Run npx shadcn@latest add https://freedesignmd.com/r/quote-wall-marquee.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.

Testimonial wall marquee: rows of developer posts with before and after numbers drifting in opposite directions. It is a free, MIT-licensed React testimonials component from the freedesignmd library.

Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.

All evergreen browsers (CSS animations, mask-image). Reduced motion shows a static grid.

Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.