A holding page for a radio station inside a rounded dark screen with faint scanlines. Four badges, a station pill, a round on-air stamp, a show card and a waveform tile, travel in straight lines and bounce off the edges of the screen like an old DVD screensaver. Each wall hit moves the badge to the next colour in the palette and adds one to a counter in the corner. The headline, copy and two buttons sit in the centre above the badges. The stage keeps its height on phones, where the badges simply have less room and bounce more often.
// Adapted from Fancy Components (https://fancycomponents.dev/docs/components/blocks/screensaver)
// Copyright (c) 2024 Daniel Petho. MIT License: https://we.inc/components/licenses
import { useEffect, useRef } from "react";
import type { ReactNode } from "react";
import { useInView, useReducedMotion } from "framer-motion";
// Colours a badge cycles through each time it hits a wall. Text on them is near black.
const PALETTE = ["#ff4d6d", "#ffd166", "#06d6a0", "#4cc9f0", "#b388ff", "#ff9f1c"];
const SPEED = 130; // pixels per second
// Each badge: where it starts (as a share of the free space), the direction it leaves in (degrees) and what it shows.
const badges: { start: [number, number]; angle: number; color: number; shape: string; body: ReactNode }[] = [
{ start: [0.03, 0.06], angle: 14, color: 0, shape: "rounded-full px-7 py-4", body: <span className="text-2xl font-black uppercase tracking-tight sm:text-3xl">Koji FM 98.4</span> },
{
start: [0.97, 0.1],
angle: 112,
color: 1,
shape: "h-28 w-28 rounded-full sm:h-32 sm:w-32",
body: (
<span className="text-center font-mono text-xs font-bold uppercase leading-tight tracking-widest">
On air
<br />
soon
</span>
),
},
{
start: [0.04, 0.95],
angle: -18,
color: 2,
shape: "px-5 py-4",
body: (
<span className="font-mono text-xs font-bold uppercase leading-relaxed tracking-widest">
Late Tapes
<br />
22:00 to 01:00
</span>
),
},
{
start: [0.9, 0.92],
angle: -150,
color: 3,
shape: "h-20 w-20 rounded-2xl",
body: (
<svg aria-hidden="true" viewBox="0 0 32 32" className="h-10 w-10" fill="currentColor">
<rect x="3" y="12" width="4" height="8" rx="2" />
<rect x="10" y="6" width="4" height="20" rx="2" />
<rect x="17" y="10" width="4" height="12" rx="2" />
<rect x="24" y="3" width="4" height="26" rx="2" />
</svg>
),
},
];
export default function BouncingScreensaverBadgesBehindHoldingPage() {
const reduce = useReducedMotion();
const stageRef = useRef<HTMLDivElement>(null);
const badgeRefs = useRef<(HTMLDivElement | null)[]>([]);
const countRef = useRef<HTMLSpanElement>(null);
const inView = useInView(stageRef, { amount: 0.1 });
useEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const els = badgeRefs.current.filter((el): el is HTMLDivElement => !!el);
let box = { w: stage.clientWidth, h: stage.clientHeight };
const state = els.map((el, i) => {
const a = (badges[i].angle * Math.PI) / 180;
return { x: badges[i].start[0] * (box.w - el.offsetWidth), y: badges[i].start[1] * (box.h - el.offsetHeight), vx: Math.cos(a) * SPEED, vy: Math.sin(a) * SPEED, w: el.offsetWidth, h: el.offsetHeight, color: badges[i].color };
});
const place = () => els.forEach((el, i) => (el.style.transform = `translate3d(${state[i].x.toFixed(1)}px, ${state[i].y.toFixed(1)}px, 0)`));
place();
const observer = new ResizeObserver(() => {
box = { w: stage.clientWidth, h: stage.clientHeight };
state.forEach((s, i) => {
s.w = els[i].offsetWidth;
s.h = els[i].offsetHeight;
s.x = Math.min(s.x, box.w - s.w);
s.y = Math.min(s.y, box.h - s.h);
});
place();
});
observer.observe(stage);
if (reduce || !inView) return () => observer.disconnect();
let bounces = Number(countRef.current?.textContent) || 0;
let raf = 0;
let last = performance.now();
const loop = (now: number) => {
raf = requestAnimationFrame(loop);
const dt = Math.min(now - last, 50) / 1000;
last = now;
if (document.hidden) return;
state.forEach((s, i) => {
s.x += s.vx * dt;
s.y += s.vy * dt;
let hit = false;
// Reflect off the walls and keep the badge inside the stage.
if (s.x <= 0 || s.x + s.w >= box.w) {
s.vx = s.x <= 0 ? Math.abs(s.vx) : -Math.abs(s.vx);
s.x = Math.max(0, Math.min(s.x, box.w - s.w));
hit = true;
}
if (s.y <= 0 || s.y + s.h >= box.h) {
s.vy = s.y <= 0 ? Math.abs(s.vy) : -Math.abs(s.vy);
s.y = Math.max(0, Math.min(s.y, box.h - s.h));
hit = true;
}
if (hit) {
s.color = (s.color + 1) % PALETTE.length;
els[i].style.backgroundColor = PALETTE[s.color];
bounces++;
}
});
place();
if (countRef.current) countRef.current.textContent = String(bounces);
};
raf = requestAnimationFrame(loop);
return () => {
cancelAnimationFrame(raf);
observer.disconnect();
};
}, [reduce, inView]);
return (
<section className="bg-[#0b0b0f] p-4 text-white sm:p-6">
<div ref={stageRef} className="relative mx-auto h-[640px] max-w-7xl overflow-hidden rounded-3xl border border-white/10 bg-[#121218]">
{/* Scanlines */}
<div aria-hidden="true" className="pointer-events-none absolute inset-0 opacity-40" style={{ backgroundImage: "repeating-linear-gradient(0deg, rgba(255,255,255,0.05) 0 1px, transparent 1px 4px)" }} />
<div aria-hidden="true" className="absolute inset-0">
{badges.map((b, i) => (
<div
key={i}
ref={(el) => {
badgeRefs.current[i] = el;
}}
className={`absolute left-0 top-0 flex items-center justify-center text-[#0b0b0f] transition-colors duration-300 will-change-transform ${b.shape}`}
style={{ backgroundColor: PALETTE[b.color] }}
>
{b.body}
</div>
))}
</div>
<div className="pointer-events-none relative flex h-full flex-col items-center justify-center px-6 text-center">
<p className="flex items-center gap-2 rounded-full border border-white/40 px-4 py-2 font-mono text-xs uppercase tracking-widest text-white mix-blend-difference">
<span className="h-2 w-2 rounded-full bg-amber-400" />
Between shows
</p>
<h2 className="mt-6 max-w-3xl text-5xl font-black leading-[1.02] tracking-tight text-white mix-blend-difference sm:text-7xl">
Back on air in a few minutes.
</h2>
<p className="mt-5 max-w-md text-base font-medium leading-relaxed text-white mix-blend-difference sm:text-lg">The studio is swapping hosts. Until the next show starts, the archive has six years of late nights to dig through.</p>
<div className="pointer-events-auto mt-8 flex flex-wrap items-center justify-center gap-3">
<a href="#" className="inline-flex h-12 items-center rounded-full bg-white px-7 text-sm font-bold text-[#0b0b0f] transition hover:bg-white/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#121218]">
Open the archive
</a>
<a href="#" className="inline-flex h-12 items-center rounded-full border border-white/30 bg-[#121218] px-7 text-sm font-bold text-white transition hover:border-white focus:outline-none focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-offset-2 focus-visible:ring-offset-[#121218]">
See the schedule
</a>
</div>
</div>
<p aria-hidden="true" className="absolute bottom-4 right-5 font-mono text-xs uppercase tracking-widest text-white/60">
Wall hits <span ref={countRef} className="tabular-nums text-white">0</span>
</p>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/bouncing-screensaver-badges-behind-holding-page.json
Each badge has a position and a velocity. Every frame the position moves by velocity times elapsed time. If the badge would cross the left or right edge the horizontal velocity is flipped, and the same for top and bottom. The position is then clamped so the badge can never escape after a resize.
One `requestAnimationFrame` loop updates all badges by writing a `transform` to each element. There is no React state in the loop, so nothing re-renders, and it costs well under a millisecond a frame.
Rarely, and that is the charm of the original screensaver. Whether it happens depends on the stage size, badge size and angle. If you want to celebrate it, check for both a horizontal and a vertical hit in the same frame inside the loop.
Not in this version, they pass over one another. For a few rectangles you could add a simple overlap test and swap velocities, but for real collisions a physics library is the better tool.
No. The loop only runs while the stage is on screen, skips frames when the tab is hidden and is cancelled on unmount together with the resize observer.
Adapted from Screensaver in Fancy Components, used under its open-source licence. Licences and credits
Related: Starfield Background with Depth and Shooting Stars, Particle Network Background with Connecting Lines, Aurora Background with Drifting Colour Bands.
More animated backgrounds 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.