Website Sections
FooterSphere
A site footer over a dotted globe rising from the bottom edge. It turns slowly, and the dots near the pointer lift and change colour.
Download
- React
- Tailwind CSS v4
components/ui/footer-sphere.tsx
"use client";
import { useEffect, useRef, type ReactNode } from "react";
export type FooterSphereGroup = {
title: string;
links: { label: string; href: string }[];
};
type FooterSphereProps = {
/** Name or logo. */
brand: ReactNode;
tagline?: string;
groups: FooterSphereGroup[];
/** Bottom line, such as the copyright. */
legal?: ReactNode;
/** Goes under the tagline: a sign-up form or social links. */
children?: ReactNode;
/** Dot colour. */
color?: string;
/** Colour of the dots near the pointer. */
accent?: string;
/** 1 is the default pace; 2 is twice as fast. */
speed?: number;
/** How many dots make the sphere. */
dots?: number;
/** Set the background and text colour here. */
className?: string;
};
const GOLDEN = Math.PI * (3 - Math.sqrt(5));
const TILT = -0.42;
const REACH = 0.28;
/* FooterSphere: a site footer over a dotted globe rising from the bottom edge. It turns slowly, and the dots near the pointer lift and change colour. It stops off screen. */
export function FooterSphere({
brand,
tagline,
groups,
legal,
children,
color = "#8b7fd6",
accent = "#22d3ee",
speed = 1,
dots = 2800,
className = "bg-[#06060e] text-white",
}: FooterSphereProps) {
const box = useRef<HTMLElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
useEffect(() => {
const footer = box.current;
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!footer || !canvas || !ctx) return;
const still = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const points = Array.from({ length: dots }, (_, i) => {
const y = 1 - ((i + 0.5) / dots) * 2;
const ring = Math.sqrt(1 - y * y);
return [Math.cos(i * GOLDEN) * ring, y, Math.sin(i * GOLDEN) * ring];
});
let w = 0;
let h = 0;
let turn = 0;
let last = 0;
let frame = 0;
let seen = false;
let pointer: { x: number; y: number } | null = null;
const draw = () => {
ctx.clearRect(0, 0, w, h);
const r = Math.min(w * 0.6, h * 0.82, 560);
const cx = w / 2;
const cy = h + r * 0.32;
const fade = Math.min(64, h * 0.12);
const cosY = Math.cos(turn);
const sinY = Math.sin(turn);
const cosX = Math.cos(TILT);
const sinX = Math.sin(TILT);
for (const [px, py, pz] of points) {
const x = px * cosY + pz * sinY;
const z1 = pz * cosY - px * sinY;
const y = py * cosX - z1 * sinX;
const z = py * sinX + z1 * cosX;
let sx = cx + x * r;
let sy = cy - y * r;
if (sy > h + 4) continue;
const near = (z + 1) / 2;
let lift = 0;
if (pointer && z > 0) {
const reach = r * REACH;
const gap = Math.hypot(sx - pointer.x, sy - pointer.y);
if (gap < reach) {
lift = (1 - gap / reach) ** 2;
sx += (x * r * lift) / 9;
sy -= (y * r * lift) / 9;
}
}
const edge = Math.min(1, Math.max(0, (h - sy) / fade));
ctx.globalAlpha = Math.min(1, 0.1 + near * near * 0.9 + lift * 0.4) * edge;
ctx.fillStyle = lift > 0.05 ? accent : color;
ctx.beginPath();
ctx.arc(sx, sy, 0.6 + near * 1.1 + lift * 1.2, 0, Math.PI * 2);
ctx.fill();
}
ctx.globalAlpha = 1;
};
const loop = (now: number) => {
const gap = last ? Math.min(now - last, 60) : 16;
last = now;
turn += gap * 0.00012 * speed;
draw();
frame = requestAnimationFrame(loop);
};
const start = () => {
if (still || frame || !seen) return;
last = 0;
frame = requestAnimationFrame(loop);
};
const stop = () => {
cancelAnimationFrame(frame);
frame = 0;
};
const resize = () => {
const scale = Math.min(window.devicePixelRatio || 1, 2);
w = canvas.clientWidth;
h = canvas.clientHeight;
canvas.width = w * scale;
canvas.height = h * scale;
ctx.setTransform(scale, 0, 0, scale, 0, 0);
draw();
};
const move = (e: PointerEvent) => {
const rect = canvas.getBoundingClientRect();
pointer = { x: e.clientX - rect.left, y: e.clientY - rect.top };
};
const leave = () => {
pointer = null;
};
const watcher = new ResizeObserver(resize);
watcher.observe(canvas);
const onScreen = new IntersectionObserver(([entry]) => {
seen = entry.isIntersecting;
if (seen) start();
else stop();
});
onScreen.observe(footer);
if (!still) {
footer.addEventListener("pointermove", move);
footer.addEventListener("pointerleave", leave);
}
return () => {
stop();
watcher.disconnect();
onScreen.disconnect();
footer.removeEventListener("pointermove", move);
footer.removeEventListener("pointerleave", leave);
};
}, [accent, color, dots, speed]);
return (
<footer ref={box} className={`@container relative isolate overflow-hidden ${className}`}>
<canvas ref={canvasRef} aria-hidden className="pointer-events-none absolute inset-0 -z-10 size-full" />
<div className="mx-auto max-w-6xl px-5 pt-12 pb-5">
<div className="grid gap-8 @3xl:grid-cols-[minmax(0,2fr)_minmax(0,3fr)]">
<div>
<div className="text-lg font-semibold tracking-tight">{brand}</div>
{tagline && <p className="mt-2 max-w-xs text-sm text-pretty opacity-70">{tagline}</p>}
{children && <div className="mt-5">{children}</div>}
</div>
<div className="grid grid-cols-[repeat(auto-fit,minmax(8rem,1fr))] gap-6">
{groups.map((group) => (
<nav key={group.title} aria-label={group.title}>
<p className="text-sm font-semibold">{group.title}</p>
<ul className="mt-3 grid gap-2 text-sm">
{group.links.map((link) => (
<li key={link.label}>
<a href={link.href} className="opacity-70 transition-opacity hover:underline hover:opacity-100">
{link.label}
</a>
</li>
))}
</ul>
</nav>
))}
</div>
</div>
<div className="h-[clamp(11rem,28cqw,20rem)]" />
{legal && <div className="text-center text-xs opacity-60">{legal}</div>}
</div>
</footer>
);
}
1. Paste the Code tab into
components/ui/footer-sphere.tsx. The styles are Tailwind CSS v4 classes.2. Use it. This is the preview above:
app/footer-sphere-demo.tsx
import { FooterSphere } from "@/components/ui/footer-sphere"; const GROUPS = [ { title: "Product", links: [{ label: "Pricing", href: "#" }, { label: "Changelog", href: "#" }, { label: "Docs", href: "#" }] }, { title: "Company", links: [{ label: "About", href: "#" }, { label: "Careers", href: "#" }, { label: "Contact", href: "#" }] }, { title: "Legal", links: [{ label: "Terms", href: "#" }, { label: "Privacy", href: "#" }] }, ]; export default function FooterSphereExample() { return ( <div className="h-full overflow-y-auto"> <FooterSphere brand="Northpeak" tagline="Calm tools for small teams, anywhere on the map." groups={GROUPS} legal="© 2026 Northpeak. All rights reserved." className="min-h-full bg-[#06060e] text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| brand | ReactNode | — | Name or logo. |
| tagline | string | — | Line under the brand. |
| groups | FooterSphereGroup[] | — | Link columns: title and links, each link with label and href. |
| legal | ReactNode | — | Bottom line, such as the copyright. |
| children | ReactNode | — | Goes under the tagline: a sign-up form or social links. |
| color | string | "#8b7fd6" | Dot colour. |
| accent | string | "#22d3ee" | Colour of the dots near the pointer. |
| speed | number | 1 | 1 is the default pace; 2 is twice as fast. |
| dots | number | 2800 | How many dots make the sphere. |
| className | string | "bg-[#06060e] text-white" | Set the background and text colour here. |