Glass Tint Footer
A liquid-glass footer slab that rises over the section above it and re-tints its frost to that section's colour.
- Category
- Sections
- Added
- 2026-09-26
- Deps
- none
- Updated
- 2026-09-26
Ready when you are.
Free for your first three projects. No card needed.
Props
"use client";
import { useEffect, useId, useRef, useState } from "react";
import type { FormEvent } from "react";
/**
* GlassTintFooter — a footer of liquid glass that takes its tint from the
* section above it.
*
* The footer is a thick glass slab that rises over the bottom of the
* previous section, so that section shows through the frosted top edge.
* Its tint is not a fixed brand colour: on mount it reads the section above
* (a `data-glass-tint="#rrggbb"` attribute, or failing that its computed
* background colour) and mixes that colour into the glass, strongest along
* the top where the two meet and fading toward the bottom. Change the
* section above (or swap in a different one) and the footer re-tints over
* 600ms, the way Apple's glass adapts to what sits behind it. It reads its
* IMMEDIATE previous sibling, so place it directly after that section; in
* development it warns when there is nothing to read.
*
* In Chromium the slab also runs a displacement map sized to it inside
* backdrop-filter, so the section above visibly bends along the rim. Safari
* and Firefox get the same slab with blur, saturation and the graduated rim.
* A light follows the pointer across the slab, brightening the nearest rim.
*
* Link columns, an optional newsletter capsule (you pass onSubscribe; it does
* not post anywhere by itself), legal links and a note. Hrefs are sanitised.
* The layout follows the footer's own width (container queries).
*
* Needs Tailwind v4 (container queries are built in). No dependencies beyond
* React.
*/
type Tone = "dark" | "light";
export type FooterLink = { label: string; href: string };
export type FooterColumn = { title: string; links: FooterLink[] };
/** Allow relative paths, fragments, http(s), mailto and tel; anything else becomes "#". */
function safeHref(href: string): string {
const v = href.replace(/[\t\n\r]/g, "").trim();
if (v.indexOf(String.fromCharCode(92)) !== -1) return "#";
if (/^(https?:|mailto:|tel:)/i.test(v)) return v;
if (/^[/#?]/.test(v) && !/^\/\//.test(v)) return v;
return "#";
}
/** "#rrggbb" to "r,g,b", or null. Anything else is ignored, never interpolated. */
function rgbOf(hex?: string | null): string | null {
const m = /^#([0-9a-f]{6})$/i.exec(hex || "");
if (!m) return null;
const n = parseInt(m[1], 16);
return ((n >> 16) & 255) + "," + ((n >> 8) & 255) + "," + (n & 255);
}
/** "rgb(r, g, b)" or an opaque-enough "rgba(...)" to "r,g,b"; transparent gives null. */
function rgbFromComputed(color: string): string | null {
const m = /^rgba?\(\s*(\d+)[,\s]+(\d+)[,\s]+(\d+)(?:[,\s/]+([\d.]+))?\s*\)$/.exec(color);
if (!m) return null;
if (m[4] !== undefined && parseFloat(m[4]) < 0.2) return null;
return m[1] + "," + m[2] + "," + m[3];
}
function isChromium(): boolean {
const ua = (navigator as Navigator & { userAgentData?: { brands?: { brand: string }[] } }).userAgentData;
return !!ua && !!ua.brands && ua.brands.some((b) => /Chromium/.test(b.brand));
}
/**
* A light that eases after the pointer (about 180ms) and fades when it
* leaves. Writes <prefix>x and <prefix>y (layout pixels) and <prefix>o on the
* element; the loop sleeps once the light arrives. Returns a cleanup.
*/
function followPointer(el: HTMLElement, prefix: string): () => void {
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)");
const L = { x: 0, y: 0, tx: 0, ty: 0, o: 0, to: 0, raf: 0, last: 0, placed: false };
function step(now: number) {
L.raf = 0;
const dt = L.last ? Math.min(1 / 30, (now - L.last) / 1000) : 1 / 60;
L.last = now;
const kp = reduce.matches ? 1 : 1 - Math.exp(-dt / 0.18);
const ko = reduce.matches ? 1 : 1 - Math.exp(-dt / 0.22);
L.x += (L.tx - L.x) * kp;
L.y += (L.ty - L.y) * kp;
L.o += (L.to - L.o) * ko;
el.style.setProperty(prefix + "x", L.x.toFixed(1) + "px");
el.style.setProperty(prefix + "y", L.y.toFixed(1) + "px");
el.style.setProperty(prefix + "o", L.o.toFixed(3));
if (Math.abs(L.tx - L.x) > 0.3 || Math.abs(L.ty - L.y) > 0.3 || Math.abs(L.to - L.o) > 0.004) {
L.raf = requestAnimationFrame(step);
} else L.last = 0;
}
const kick = () => {
if (!L.raf) L.raf = requestAnimationFrame(step);
};
function onMove(e: PointerEvent) {
// Layout pixels, even inside a transform-scaled container.
const r = el.getBoundingClientRect();
const k = el.offsetWidth / (r.width || 1);
L.tx = (e.clientX - r.left) * k;
L.ty = (e.clientY - r.top) * k;
L.to = 1;
if (!L.placed) {
L.x = L.tx;
L.y = L.ty;
L.placed = true;
}
kick();
}
function onLeave() {
L.to = 0;
kick();
}
el.addEventListener("pointermove", onMove);
el.addEventListener("pointerdown", onMove);
el.addEventListener("pointerleave", onLeave);
return () => {
el.removeEventListener("pointermove", onMove);
el.removeEventListener("pointerdown", onMove);
el.removeEventListener("pointerleave", onLeave);
if (L.raf) cancelAnimationFrame(L.raf);
};
}
/**
* Displacement map for a slab with rounded TOP corners only (the bottom runs
* off the page): red = x, green = y, 128 = none. Mapped at half resolution;
* the filter stretches it back.
*/
function slabMap(w: number, h: number, r: number, bezel: number): string {
const k = 0.5;
const mw = Math.max(1, Math.round(w * k));
const mh = Math.max(1, Math.round(h * k));
const c = document.createElement("canvas");
c.width = mw;
c.height = mh;
const ctx = c.getContext("2d");
if (!ctx) return "";
const img = ctx.createImageData(mw, mh);
const hw = w / 2;
// Extend the box far below so only the top edge and corners bend.
const hh = h;
const sd = (x: number, y: number) => {
const qx = Math.abs(x) - (hw - r);
const qy = Math.abs(y) - (hh - r);
return Math.hypot(Math.max(qx, 0), Math.max(qy, 0)) + Math.min(Math.max(qx, qy), 0) - r;
};
for (let j = 0; j < mh; j++) {
for (let i = 0; i < mw; i++) {
const x = (i + 0.5) / k - hw;
const y = (j + 0.5) / k - hh;
const t = -sd(x, y);
let dx = 0;
let dy = 0;
if (t > 0 && t < bezel) {
const nx = sd(x + 0.5, y) - sd(x - 0.5, y);
const ny = sd(x, y + 0.5) - sd(x, y - 0.5);
const len = Math.hypot(nx, ny) || 1;
const m = Math.pow(1 - t / bezel, 2);
dx = (-nx / len) * m;
dy = (-ny / len) * m;
}
const o = (j * mw + i) * 4;
img.data[o] = Math.round(128 + 127 * dx);
img.data[o + 1] = Math.round(128 + 127 * dy);
img.data[o + 2] = 128;
img.data[o + 3] = 255;
}
}
ctx.putImageData(img, 0, 0);
return c.toDataURL();
}
export function GlassTintFooter({
brand,
tagline,
columns,
legal = [],
note,
newsletter = true,
onSubscribe,
tint,
sample = true,
tone = "dark",
refraction = 1,
overlap = 56,
className = "",
}: {
brand: string;
tagline?: string;
columns: FooterColumn[];
legal?: FooterLink[];
/** The small print, e.g. "© 2026 Studio". */
note?: string;
/** Show the email capsule. */
newsletter?: boolean;
/** Called with the email on submit. Wire it to your own endpoint. */
onSubscribe?: (email: string) => void;
/** Fallback (or fixed, with sample off) tint, "#rrggbb". */
tint?: string;
/** Read the tint from the section above. */
sample?: boolean;
tone?: Tone;
/** Rim refraction, 0–2 (Chromium only). */
refraction?: number;
/** How far the slab rises over the section above, px. */
overlap?: number;
className?: string;
}) {
const ref = useRef<HTMLElement>(null);
const slabRef = useRef<HTMLDivElement>(null);
const mapRef = useRef<SVGFEImageElement>(null);
const dispRef = useRef<SVGFEDisplacementMapElement>(null);
const uid = useId().replace(/[^a-zA-Z0-9]/g, "");
const filterId = "gtf" + uid;
const emailId = "gtfe" + uid;
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
const dark = tone === "dark";
const fallback = rgbOf(tint) || "124,92,255";
const base = "blur(20px) saturate(180%) brightness(" + (dark ? 1.08 : 1.04) + ")";
// Sample the section above, and follow it: its attributes changing, or a
// different element taking its place.
useEffect(() => {
const el = ref.current;
if (!el) return;
let watched: Element | null = null;
let warned = false;
const attrs = new MutationObserver(() => read());
const read = () => {
const prev = el.previousElementSibling as HTMLElement | null;
let rgb: string | null = null;
if (sample && prev) {
rgb = rgbOf(prev.getAttribute("data-glass-tint")) || rgbFromComputed(getComputedStyle(prev).backgroundColor);
}
if (sample && !rgb && !warned && process.env.NODE_ENV !== "production") {
warned = true;
console.warn(
"GlassTintFooter: nothing to sample. Put the footer directly after the section it should borrow from, and give that section data-glass-tint=\"#rrggbb\" or an opaque background colour."
);
}
el.style.setProperty("--gtf-rgb", rgb || fallback);
if (sample && prev !== watched) {
attrs.disconnect();
watched = prev;
if (prev) attrs.observe(prev, { attributes: true, attributeFilter: ["data-glass-tint", "style", "class"] });
}
};
read();
const parent = el.parentElement;
const kids = new MutationObserver(() => read());
if (sample && parent) kids.observe(parent, { childList: true });
return () => {
attrs.disconnect();
kids.disconnect();
};
}, [sample, fallback]);
// A light that follows the pointer across the slab.
useEffect(() => {
const slab = slabRef.current;
return slab ? followPointer(slab, "--gtf-l") : undefined;
}, []);
// The slab's lens, sized to it, where the engine can run it.
useEffect(() => {
const slab = slabRef.current;
if (!slab) return;
if (!isChromium() || refraction <= 0) {
slab.style.backdropFilter = base;
return;
}
let key = "";
const apply = () => {
const w = Math.round(slab.offsetWidth);
const h = Math.round(slab.offsetHeight);
if (!w || !h || !mapRef.current || !dispRef.current) return;
// Refraction up to 1 strengthens the bend; above 1 it widens the bent
// band instead, so the peak offset never passes 0.4 of the bezel.
const bezel = Math.min(44 * Math.max(1, refraction), h * 0.3);
const k = w + "x" + h + ":" + bezel.toFixed(1);
if (k !== key) {
key = k;
mapRef.current.setAttribute("href", slabMap(w, h, 28, bezel));
mapRef.current.setAttribute("width", String(w));
mapRef.current.setAttribute("height", String(h));
}
dispRef.current.setAttribute("scale", String(Math.round(0.8 * bezel * Math.min(1, refraction))));
slab.style.backdropFilter = "url(#" + filterId + ") " + base;
};
apply();
const ro = new ResizeObserver(apply);
ro.observe(slab);
return () => ro.disconnect();
}, [filterId, base, refraction]);
function submit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (!email.trim()) return;
onSubscribe?.(email.trim());
setSent(true);
}
const ink = dark ? "#ffffff" : "#111111";
const soft = dark ? "rgba(255,255,255,0.66)" : "rgba(17,17,17,0.66)";
return (
<footer
ref={ref}
className={"@container relative z-10 " + className}
style={{ marginTop: -overlap, color: ink }}
>
<div
ref={slabRef}
className="relative isolate overflow-hidden"
style={{
borderRadius: "28px 28px 0 0",
background: dark ? "rgba(14,14,20,0.42)" : "rgba(255,255,255,0.5)",
backdropFilter: base,
WebkitBackdropFilter: base,
boxShadow: "inset 0 1px 0 rgba(255,255,255," + (dark ? 0.6 : 0.95) + "), 0 -12px 40px rgba(0,0,0," + (dark ? 0.22 : 0.08) + ")",
}}
>
{/* The borrowed tint: strongest where the footer meets the section,
fading down. background-color (not a gradient) so it transitions. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0 transition-[background-color] duration-[600ms] ease-out"
style={{
backgroundColor: "rgba(var(--gtf-rgb, " + fallback + "), " + (dark ? 0.34 : 0.26) + ")",
WebkitMaskImage: "linear-gradient(180deg, #000 0%, rgba(0,0,0,0.45) 38%, rgba(0,0,0,0.12) 100%)",
maskImage: "linear-gradient(180deg, #000 0%, rgba(0,0,0,0.45) 38%, rgba(0,0,0,0.12) 100%)",
}}
/>
{/* The graduated rim along the top edge and corners. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
borderRadius: "28px 28px 0 0",
padding: "1px 1px 0 1px",
background:
"linear-gradient(180deg, rgba(255,255,255," + (dark ? 0.55 : 0.9) + ") 0%, rgba(255,255,255,0.1) 30%, rgba(255,255,255,0) 60%)",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
}}
/>
{/* The pointer's light: the nearby rim brightens, a soft specular below. */}
<span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
borderRadius: "28px 28px 0 0",
padding: "1px 1px 0 1px",
opacity: "var(--gtf-lo, 0)",
background:
"radial-gradient(280px circle at var(--gtf-lx, -999px) var(--gtf-ly, -999px), rgba(255,255,255,0.95), rgba(255,255,255,0) 70%)",
WebkitMask: "linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0)",
WebkitMaskComposite: "xor",
mask: "linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0)",
}}
/>
<span
aria-hidden
className="pointer-events-none absolute inset-0"
style={{
opacity: "var(--gtf-lo, 0)",
background:
"radial-gradient(420px circle at var(--gtf-lx, -999px) var(--gtf-ly, -999px), rgba(255,255,255," + (dark ? 0.12 : 0.28) + "), rgba(255,255,255,0) 65%)",
mixBlendMode: dark ? "screen" : "normal",
}}
/>
<div className="relative mx-auto max-w-[1180px] px-6 pb-8 pt-14 @3xl:px-12 @3xl:pt-16">
<div className="grid gap-10 @3xl:grid-cols-[minmax(0,1.3fr)_minmax(0,2fr)]">
<div className="max-w-[360px]">
<p className="text-[22px] font-semibold tracking-[-0.02em]">{brand}</p>
{tagline && (
<p className="mt-2 text-[15px] leading-relaxed" style={{ color: soft }}>
{tagline}
</p>
)}
{newsletter && (
<form onSubmit={submit} className="mt-6">
<label htmlFor={emailId} className="text-[13px] font-semibold" style={{ color: soft }}>
{sent ? "You're subscribed." : "One email a month"}
</label>
<div
className="mt-2 flex items-center gap-1 rounded-full p-1 pl-4 has-[input:focus-visible]:outline has-[input:focus-visible]:outline-2 has-[input:focus-visible]:outline-white/70"
style={{
background: dark ? "rgba(255,255,255,0.08)" : "rgba(255,255,255,0.5)",
boxShadow: "inset 0 1px 0 rgba(255,255,255,0.3), inset 0 -1px 0 rgba(255,255,255,0.08)",
}}
>
<input
id={emailId}
type="email"
required
autoComplete="email"
placeholder="you@studio.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
className="min-w-0 flex-1 bg-transparent text-[15px] outline-none placeholder:opacity-50"
style={{ color: ink }}
/>
<button
type="submit"
className="h-10 shrink-0 rounded-full px-4 text-[14px] font-semibold outline-none transition-transform duration-300 ease-[cubic-bezier(0.34,1.4,0.64,1)] focus-visible:ring-2 focus-visible:ring-white/80 active:scale-[0.97]"
style={{ background: dark ? "#ffffff" : "#111111", color: dark ? "#111111" : "#ffffff" }}
>
Subscribe
</button>
</div>
</form>
)}
</div>
<nav aria-label="Footer" className="grid grid-cols-2 gap-8 @xl:grid-cols-3">
{columns.map((c) => (
<div key={c.title}>
<p className="text-[13px] font-semibold" style={{ color: soft }}>
{c.title}
</p>
<ul className="mt-3 space-y-2.5">
{c.links.map((l) => (
<li key={l.label}>
<a
href={safeHref(l.href)}
className="text-[15px] outline-none transition-opacity duration-200 hover:opacity-70 focus-visible:underline"
>
{l.label}
</a>
</li>
))}
</ul>
</div>
))}
</nav>
</div>
<div
className="mt-12 flex flex-col gap-3 pt-6 text-[13px] @2xl:flex-row @2xl:items-center @2xl:justify-between"
style={{ borderTop: "1px solid " + (dark ? "rgba(255,255,255,0.12)" : "rgba(17,17,17,0.1)"), color: soft }}
>
{note && <p>{note}</p>}
{legal.length > 0 && (
<ul className="flex flex-wrap gap-x-5 gap-y-1">
{legal.map((l) => (
<li key={l.label}>
<a href={safeHref(l.href)} className="outline-none hover:opacity-70 focus-visible:underline">
{l.label}
</a>
</li>
))}
</ul>
)}
</div>
</div>
<svg aria-hidden width="0" height="0" style={{ position: "absolute" }}>
<filter id={filterId} x="0" y="0" width="100%" height="100%" colorInterpolationFilters="sRGB">
<feImage ref={mapRef} x="0" y="0" preserveAspectRatio="none" result="map" />
<feDisplacementMap ref={dispRef} in="SourceGraphic" in2="map" scale="0" xChannelSelector="R" yChannelSelector="G" />
</filter>
</svg>
</div>
</footer>
);
}
About this section
A footer is usually a flat band that ignores whatever came before it. This one is a thick slab of liquid glass that rises over the bottom of the previous section, so that section shows through its frosted top edge, and it takes its tint from that section rather than from a fixed brand colour. On mount it reads the section above, from a data-glass-tint attribute or, failing that, its background colour, and mixes it into the glass, strongest where the two meet and fading toward the bottom. Change the section and the footer re-tints over 600ms, the way glass adapts to what sits behind it. In Chromium the slab's top rim also bends the section above. Inside are link columns, an optional newsletter capsule that hands the email to your own handler, legal links and a note, and the layout follows the footer's own width.
Curator’s note
Part of the Glassmorphism kit, drop 3. Free: a footer section that reads one colour from its neighbour, with no state machine and no rendered artwork of its own.
Pairs well with
Matched on shared tags and category — the entries most likely to be used alongside this one.
Glass Bento Wall
sectionsA feature grid cut from one sheet of liquid glass: each tile is as thick as it is big, and one pointer light moves across every rim.
Glass Pricing Tiers
sectionsA pricing section where each plan is a thicker pane of glass than the last, and the plan you choose puts a lens over its price.
Bento Footer Grid
sectionsA site footer set as bento tiles: the brand as the 2x2, sitemap columns, a status figure, back to top, a newsletter and a wordmark cropped by its tile.