Skip to content

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.

FreeGlassmorphismglassminimal
Category
Sections
Added
2026-09-26
Deps
none
Updated
2026-09-26
Preview

Ready when you are.

Free for your first three projects. No card needed.

Props

Newsletter
1
No runtime dependencies
"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>
  );
}
Notes

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.

Related

Pairs well with

Matched on shared tags and category — the entries most likely to be used alongside this one.

NewPro

A 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.

bentofeature gridsection
NewPro

A 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.

pricingsectionplans
New

A 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.

footerbentositemap