Skip to content

Bento Footer Grid

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.

FreeBento Boxminimal
Category
Sections
Added
2026-09-26
Deps
none
Updated
2026-09-26
Preview

Props

Wordmark
No runtime dependencies
"use client";

import { useEffect, useId, useRef, useState } from "react";
import type { CSSProperties, FormEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent, ReactNode } from "react";

/**
 * BentoFooterGrid — a site footer set as bento tiles.
 *
 * The brand is the one 2x2: mark, a line about what you make, and your
 * social links. Each sitemap column is a 1x2 tile, the status of your
 * service is a 1x1 figure (uptime, with a coloured dot), and a 1x1 takes the
 * reader back to the top. The newsletter and the legal line are wide tiles
 * along the bottom, and an optional wordmark closes the page, set huge and
 * cropped by its tile. Depth is luminance and a 1px hairline; a light that
 * follows the pointer catches the hairlines near it. The subscribe button is
 * the only accent.
 *
 * The newsletter calls your `onSubscribe(email)`; resolve when the address
 * is stored, throw to show the error line. Nothing is sent anywhere else.
 *
 * Needs Tailwind v4 (container queries are built in). No dependencies beyond
 * React.
 */

export type FooterLink = { label: string; href: string };
export type FooterColumn = { title: string; links: FooterLink[] };

export type BentoFooterGridProps = {
  brand: { name: string; pitch?: string; href?: string; mark?: ReactNode };
  socials?: FooterLink[];
  /** Up to five links per column fit the 1x2 tile; more make it grow. */
  columns: FooterColumn[];
  status?: { label: string; value?: string; caption?: string; href?: string; tone?: "ok" | "busy" | "down" };
  newsletter?: {
    title: string;
    placeholder?: string;
    cta?: string;
    /** Store the address. Resolve on success; throw to show the error line. */
    onSubscribe: (email: string) => Promise<void> | void;
  };
  legal?: { owner: string; year?: number; links?: FooterLink[] };
  backToTop?: boolean;
  /** Close the page with the brand name set huge and cropped. */
  wordmark?: boolean;
  accent?: string;
  className?: string;
};

const MONO = "var(--font-mono, var(--font-geist-mono, ui-monospace, SFMono-Regular, Menlo, Consolas, monospace))";
const OUT = "cubic-bezier(0.16, 1, 0.3, 1)";
const TONES = { ok: "#2bd576", busy: "#ffb020", down: "#ff5a5a" };
const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/;

/** 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" if valid, otherwise the fallback. Never interpolated unchecked. */
function safeHex(hex: string | undefined, fallback: string): string {
  return /^#[0-9a-f]{6}$/i.test(hex || "") ? (hex as string) : fallback;
}

function isLight(hex: string): boolean {
  const n = parseInt(hex.slice(1), 16);
  const c = [(n >> 16) & 255, (n >> 8) & 255, n & 255].map((v) => {
    const s = v / 255;
    return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4);
  });
  return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2] > 0.18;
}

/** The nearest scrolling ancestor, or null for the window. */
function scroller(el: HTMLElement): HTMLElement | null {
  let p = el.parentElement;
  while (p) {
    const oy = getComputedStyle(p).overflowY;
    if ((oy === "auto" || oy === "scroll") && p.scrollHeight > p.clientHeight) return p;
    p = p.parentElement;
  }
  return null;
}

const HAIR: CSSProperties = { background: "#0f1012", boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.08)" };
const LABEL = "text-[11px] uppercase tracking-[0.12em] text-white/50";
const FOCUS = "outline-none focus-visible:ring-2 focus-visible:ring-white/80";

function Lights() {
  return (
    <>
      <span
        aria-hidden
        className="pointer-events-none absolute inset-0 rounded-[20px] transition-opacity duration-200"
        style={{
          opacity: "var(--bfg-o, 0)",
          padding: 1,
          background: "radial-gradient(180px circle at var(--bfg-x, -999px) var(--bfg-y, -999px), rgba(255,255,255,0.7), 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 rounded-[20px] transition-opacity duration-200"
        style={{
          opacity: "var(--bfg-o, 0)",
          background: "radial-gradient(260px circle at var(--bfg-x, -999px) var(--bfg-y, -999px), rgba(255,255,255,0.07), rgba(255,255,255,0) 70%)",
        }}
      />
    </>
  );
}

function Newsletter({ data, tint, ink }: { data: NonNullable<BentoFooterGridProps["newsletter"]>; tint: string; ink: string }) {
  const id = useId();
  const [email, setEmail] = useState("");
  const [state, setState] = useState<"idle" | "busy" | "done" | "invalid" | "error">("idle");

  async function submit(e: FormEvent) {
    e.preventDefault();
    const v = email.trim();
    if (!EMAIL.test(v)) {
      setState("invalid");
      return;
    }
    setState("busy");
    try {
      await data.onSubscribe(v);
      setState("done");
    } catch {
      setState("error");
    }
  }

  const message =
    state === "invalid"
      ? "Enter a full email address, like name@company.com."
      : state === "error"
        ? "That didn't go through. Try again in a moment."
        : state === "done"
          ? "You're on the list. The next issue lands at the start of the month."
          : "";

  return (
    <div className="relative flex h-full flex-col justify-center gap-3 @4xl:flex-row @4xl:items-center @4xl:justify-between @4xl:gap-6">
      <p className="text-[15px] font-semibold leading-snug text-white @4xl:max-w-[22ch]">{data.title}</p>
      {state === "done" ? (
        <p className="flex items-center gap-2 text-[14px] text-white/80" role="status">
          <svg aria-hidden width="14" height="14" viewBox="0 0 14 14">
            <path d="M2.5 7.5 5.5 10.5 11.5 3.5" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" />
          </svg>
          {message}
        </p>
      ) : (
        <form noValidate onSubmit={submit} className="flex w-full min-w-0 flex-col gap-1.5 @4xl:max-w-[400px]">
          <div
            className="flex h-11 min-w-0 items-center rounded-[14px] bg-[#131417] p-1 transition-shadow duration-200 focus-within:shadow-[inset_0_0_0_1px_rgba(255,255,255,0.4)]"
            style={{ boxShadow: state === "invalid" ? "inset 0 0 0 1px rgba(255,90,90,0.7)" : undefined }}
          >
            <label htmlFor={id} className="sr-only">
              Email address
            </label>
            <input
              id={id}
              type="email"
              inputMode="email"
              autoComplete="email"
              value={email}
              onChange={(e) => {
                setEmail(e.target.value);
                if (state === "invalid" || state === "error") setState("idle");
              }}
              placeholder={data.placeholder ?? "you@company.com"}
              aria-invalid={state === "invalid"}
              aria-describedby={id + "-msg"}
              className="h-full min-w-0 flex-1 bg-transparent px-3 text-[14px] text-white outline-none placeholder:text-white/35"
            />
            <button
              type="submit"
              disabled={state === "busy"}
              className={
                "h-full shrink-0 rounded-[10px] px-4 text-[13px] font-semibold transition-[filter,opacity] duration-200 hover:brightness-110 disabled:opacity-60 motion-reduce:transition-none " +
                FOCUS +
                " focus-visible:ring-offset-2 focus-visible:ring-offset-[#131417]"
              }
              style={{ background: tint, color: ink }}
            >
              {state === "busy" ? "Joining…" : (data.cta ?? "Subscribe")}
            </button>
          </div>
          <p id={id + "-msg"} role="status" className={"min-h-4 text-[12px] " + (state === "invalid" || state === "error" ? "text-[#ff8a8a]" : "text-white/45")}>
            {message}
          </p>
        </form>
      )}
    </div>
  );
}

export function BentoFooterGrid({
  brand,
  socials,
  columns,
  status,
  newsletter,
  legal,
  backToTop = true,
  wordmark = false,
  accent,
  className = "",
}: BentoFooterGridProps) {
  const tint = safeHex(accent, "#ff5a1f");
  const ink = isLight(tint) ? "#0b0b0c" : "#ffffff";
  const light = useRef({ x: 0, y: 0, raf: 0 });
  const year = legal?.year ?? new Date().getFullYear();

  useEffect(() => {
    const l = light.current;
    return () => cancelAnimationFrame(l.raf);
  }, []);

  // One light for the grid, coalesced to a write per animation frame.
  function onMove(e: ReactPointerEvent<HTMLUListElement>) {
    const grid = e.currentTarget;
    const r = grid.getBoundingClientRect();
    const k = grid.offsetWidth / (r.width || 1);
    light.current.x = (e.clientX - r.left) * k;
    light.current.y = (e.clientY - r.top) * k;
    if (light.current.raf) return;
    light.current.raf = requestAnimationFrame(() => {
      light.current.raf = 0;
      Array.from(grid.children).forEach((c) => {
        const el = c as HTMLElement;
        el.style.setProperty("--bfg-x", (light.current.x - el.offsetLeft).toFixed(1) + "px");
        el.style.setProperty("--bfg-y", (light.current.y - el.offsetTop).toFixed(1) + "px");
      });
      grid.style.setProperty("--bfg-o", "1");
    });
  }

  function toTop(e: ReactMouseEvent<HTMLButtonElement>) {
    const smooth = !window.matchMedia("(prefers-reduced-motion: reduce)").matches;
    const s = scroller(e.currentTarget);
    (s ?? window).scrollTo({ top: 0, behavior: smooth ? "smooth" : "auto" });
  }

  const tile = "relative overflow-hidden rounded-[20px]";
  const tone = TONES[status?.tone ?? "ok"];
  const statusInner = status && (
    <>
      <span className={"flex items-center gap-2 " + LABEL} style={{ fontFamily: MONO }}>
        <span aria-hidden className="size-1.5 shrink-0 rounded-full" style={{ background: tone }} />
        {status.label}
      </span>
      {status.value && (
        <span className="flex flex-col gap-1.5">
          <span className="text-[28px] font-bold leading-none tracking-[-0.03em]" style={{ fontVariantNumeric: "tabular-nums" }}>
            {status.value}
          </span>
          {status.caption && (
            <span className="truncate text-[11px] leading-tight text-white/45" style={{ fontFamily: MONO }}>
              {status.caption}
            </span>
          )}
        </span>
      )}
    </>
  );
  // Interactive tiles hover on a layer 1px inside, so the hairline stays.
  const statusCls = "absolute inset-px flex flex-col justify-between rounded-[19px] p-[15px]";

  return (
    <footer className={"@container relative w-full text-white " + className} style={{ background: "#08090a" }}>
      <div className="mx-auto max-w-[1180px] px-5 py-12 @3xl:px-12">
        <ul
          role="list"
          onPointerMove={onMove}
          onPointerLeave={(e) => {
            cancelAnimationFrame(light.current.raf);
            light.current.raf = 0;
            e.currentTarget.style.setProperty("--bfg-o", "0");
          }}
          className="relative grid grid-cols-2 gap-3 @4xl:grid-cols-6"
          style={{ gridAutoRows: "minmax(104px, auto)", gridAutoFlow: "dense", "--bfg-o": "0" } as CSSProperties}
        >
          {/* The brand: the one 2x2. */}
          <li className={tile + " col-span-2 row-span-2 flex flex-col justify-between p-6"} style={HAIR}>
            <Lights />
            <div className="relative">
              <a href={safeHref(brand.href ?? "/")} className={"inline-flex items-center gap-2.5 rounded-[8px] text-[17px] font-semibold " + FOCUS}>
                {brand.mark ?? (
                  <span aria-hidden className="grid size-7 place-items-center rounded-[8px] bg-white text-[13px] font-bold text-black">
                    {brand.name.slice(0, 1)}
                  </span>
                )}
                {brand.name}
              </a>
              {brand.pitch && <p className="mt-3 max-w-[34ch] text-[15px] leading-snug text-white/60">{brand.pitch}</p>}
            </div>
            {socials && socials.length > 0 && (
              <ul role="list" aria-label="Social" className="relative mt-6 flex flex-wrap gap-2">
                {socials.map((s) => (
                  <li key={s.label}>
                    <a
                      href={safeHref(s.href)}
                      className={
                        "inline-flex h-8 items-center rounded-[10px] px-3 text-[12px] text-white/70 transition-colors duration-200 hover:bg-[#16171a] hover:text-white motion-reduce:transition-none " +
                        FOCUS
                      }
                      style={{ boxShadow: "inset 0 0 0 1px rgba(255,255,255,0.12)" }}
                    >
                      {s.label}
                    </a>
                  </li>
                ))}
              </ul>
            )}
          </li>

          {/* The sitemap: a 1x2 per column. */}
          {columns.map((col) => (
            <li key={col.title} className={tile + " row-span-2 p-5"} style={HAIR}>
              <Lights />
              <nav aria-label={col.title} className="relative">
                <p className={LABEL} style={{ fontFamily: MONO }}>
                  {col.title}
                </p>
                <ul role="list" className="mt-3 space-y-1">
                  {col.links.map((l) => (
                    <li key={l.label}>
                      <a
                        href={safeHref(l.href)}
                        className={
                          "-mx-1.5 inline-block rounded-[8px] px-1.5 py-0.5 text-[14px] text-white/65 transition-colors duration-200 hover:text-white motion-reduce:transition-none " +
                          FOCUS
                        }
                      >
                        {l.label}
                      </a>
                    </li>
                  ))}
                </ul>
              </nav>
            </li>
          ))}

          {/* Status: a figure and a dot. */}
          {status && (
            <li className={tile} style={HAIR}>
              {status.href ? (
                <a
                  href={safeHref(status.href)}
                  className={statusCls + " transition-colors duration-200 hover:bg-[#16171a] motion-reduce:transition-none " + FOCUS + " focus-visible:ring-inset"}
                >
                  {statusInner}
                </a>
              ) : (
                <div className={statusCls}>{statusInner}</div>
              )}
              <Lights />
            </li>
          )}

          {/* Back to the top. */}
          {backToTop && (
            <li className={tile} style={HAIR}>
              <button
                type="button"
                onClick={toTop}
                className={
                  "group absolute inset-px flex flex-col justify-between rounded-[19px] p-[15px] text-left transition-colors duration-200 hover:bg-[#16171a] motion-reduce:transition-none " +
                  FOCUS +
                  " focus-visible:ring-inset"
                }
              >
                <span className={LABEL} style={{ fontFamily: MONO }}>
                  Back to top
                </span>
                <span
                  aria-hidden
                  className="text-[28px] font-bold leading-none transition-transform duration-300 group-hover:-translate-y-1 motion-reduce:transition-none"
                  style={{ transitionTimingFunction: OUT }}
                >
                  ↑
                </span>
              </button>
              <Lights />
            </li>
          )}

          {/* The newsletter and the legal line, wide along the bottom. */}
          {newsletter && (
            <li className={tile + " col-span-2 p-4 @4xl:col-span-3 @4xl:px-5"} style={HAIR}>
              <Lights />
              <Newsletter data={newsletter} tint={tint} ink={ink} />
            </li>
          )}
          {legal && (
            <li
              className={tile + " col-span-2 flex flex-col justify-center gap-3 p-5 @4xl:col-span-3 @4xl:flex-row @4xl:items-center @4xl:justify-between"}
              style={HAIR}
            >
              <Lights />
              <p className="relative text-[12px] text-white/50" style={{ fontFamily: MONO }}>
                {"© " + year + " " + legal.owner}
              </p>
              {legal.links && legal.links.length > 0 && (
                <ul role="list" className="relative flex flex-wrap gap-x-4 gap-y-1">
                  {legal.links.map((l) => (
                    <li key={l.label}>
                      <a
                        href={safeHref(l.href)}
                        className={"rounded-[6px] text-[13px] text-white/60 transition-colors duration-200 hover:text-white motion-reduce:transition-none " + FOCUS}
                      >
                        {l.label}
                      </a>
                    </li>
                  ))}
                </ul>
              )}
            </li>
          )}

          {/* The wordmark, set huge and cropped by its tile. */}
          {wordmark && (
            <li aria-hidden className={tile + " col-span-full @container"} style={HAIR}>
              <p
                className="select-none whitespace-nowrap px-[3cqw] font-bold leading-[0.8] tracking-[-0.06em] text-[#16171a]"
                style={{ fontSize: "min(" + Math.min(40, 150 / Math.max(3, brand.name.length)).toFixed(1) + "cqw, 420px)", transform: "translateY(18%)", marginTop: "2cqw" }}
              >
                {brand.name}
              </p>
            </li>
          )}
        </ul>
      </div>
    </footer>
  );
}
Notes

About this section

Footers are usually four columns of grey links on a strip. This one is a bento. Your brand is the one 2x2, with a line about what you make and your social links. Each sitemap column is a tall tile, your service status is a single figure with a coloured dot, and a small tile takes the reader back to the top. The newsletter and the legal line run along the bottom as wide tiles, and an optional wordmark closes the page, your name set huge and cropped by its tile. The subscribe button is the only colour. Depth is luminance and a one-pixel hairline, never shadows, and a light that follows the pointer catches the hairlines near it. The newsletter calls your own function with the address and shows the result; it sends nothing anywhere by itself.

Curator’s note

Part of the Bento Box kit, drop 2, phase 6. Free: the footer is a slot every page needs, so the kit gives it away, held hard to the kit's tokens so it reads as bento rather than rounded cards.

Related

Pairs well with

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

New

A liquid-glass footer slab that rises over the section above it and re-tints its frost to that section's colour.

footersectionglass
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

Pricing set as a bento grid: the recommended plan is the one 2x2 tile with the biggest price and the only accent, surrounded by plans, reassurances and a sales tile.

pricingbentoplans