"use client"; /** * GrainFooter: a drop-in website footer whose image band gets a grainy, matte print effect. * One file, zero dependencies besides React. MIT, from grainfoot (https://github.com/VedSoni-dev/grainfoot). * * * * One image shows still. Several crossfade on a timer and/or move on when the band is hovered. * The grain is an SVG feTurbulence tile blended over the image, plus a faint horizontal weave. * Motion stops for people who ask for reduced motion. */ import { useEffect, useId, useMemo, useState, type CSSProperties } from "react"; export type SocialType = "x" | "linkedin" | "youtube" | "github" | "instagram" | "link"; export interface GrainFooterConfig { brand: { name: string; logo?: string; tagline?: string; href?: string }; columns?: { title: string; links: { label: string; href: string }[] }[]; /** Your images, in order. Paths or URLs: ["/footer/a.jpg", "/footer/b.jpg"]. */ images: string[]; imageAlt?: string; status?: { label: string; ok?: boolean } | null; socials?: { type: SocialType; href: string }[]; /** Defaults to "© . All rights reserved." */ copyright?: string; credit?: { label: string; name: string; href?: string } | null; grain?: { /** 0–1, how strong the grain is. Default 0.55. */ amount?: number; /** 1–4, size of a grain. Default 1.6. */ size?: number; /** 0–1, the faint horizontal weave lines. Default 0.35. */ weave?: number; /** A colour washed over the image for a matte print look. Default "#f3e9dc". */ tint?: string; /** Let the grain shimmer. Default false. */ animate?: boolean; }; rotate?: { /** Milliseconds between images when there are several. 0 = no timer. Default 5000. */ intervalMs?: number; /** Hovering the band shows the next image. Default true. */ onHover?: boolean; }; /** Band height as width / height. Default 4.4 (2.2 on phones). */ aspect?: number; theme?: "light" | "dark"; } const clamp = (n: number, a: number, b: number) => Math.min(b, Math.max(a, n)); /** The grain tile: fractal noise, greyscale, as a data URL. */ export function noiseUrl(size = 1.6): string { const f = (0.95 / clamp(size, 0.5, 6)).toFixed(3); const svg = ``; return `url("data:image/svg+xml;utf8,${encodeURIComponent(svg)}")`; } /** CSS variables that drive the effect, for the band element. */ export function grainVars(g: GrainFooterConfig["grain"] = {}): Record { const amount = clamp(g.amount ?? 0.55, 0, 1); const weave = clamp(g.weave ?? 0.35, 0, 1); return { "--gf-noise": noiseUrl(g.size ?? 1.6), "--gf-amount": amount.toFixed(2), "--gf-amount-soft": (amount * 0.6).toFixed(2), "--gf-weave": (weave * 0.5).toFixed(2), "--gf-tint": g.tint ?? "#f3e9dc", "--gf-tint-amount": (0.08 + amount * 0.14).toFixed(2), "--gf-sat": (1 - amount * 0.22).toFixed(2), }; } export const GRAINFOOT_CSS = ` .gf{--gf-bg:#fff;--gf-ink:#111;--gf-muted:#5f5f5f;--gf-line:#ececec;box-sizing:border-box;background:var(--gf-bg);color:var(--gf-ink);font-family:inherit;padding:clamp(28px,5vw,64px) clamp(16px,4vw,64px) clamp(16px,3vw,40px)} .gf[data-theme=dark]{--gf-bg:#0f0f0e;--gf-ink:#f2f0ea;--gf-muted:#9c988f;--gf-line:#262624} .gf *{box-sizing:border-box} .gf-top{display:flex;flex-wrap:wrap;justify-content:space-between;gap:40px 48px;margin-bottom:clamp(32px,5vw,64px)} .gf-brand{max-width:340px} .gf-mark{display:inline-flex;align-items:center;gap:10px;color:inherit;text-decoration:none;font-size:22px;font-weight:500;letter-spacing:-.02em} .gf-logo{width:32px;height:32px;border-radius:50%;object-fit:cover;background:var(--gf-line);display:grid;place-items:center;font-size:15px;font-weight:600} .gf-tagline{margin:16px 0 0;color:var(--gf-muted);font-size:15px;line-height:1.5} .gf-cols{display:flex;flex-wrap:wrap;gap:32px clamp(32px,6vw,96px)} .gf-col h3{margin:0 0 18px;font-size:15px;font-weight:500;color:var(--gf-ink)} .gf-col ul{list-style:none;margin:0;padding:0;display:grid;gap:14px} .gf-col a{color:var(--gf-muted);text-decoration:none;font-size:15px;transition:color .2s} .gf-col a:hover{color:var(--gf-ink)} .gf-band{position:relative;overflow:hidden;border-radius:4px;aspect-ratio:var(--gf-aspect,4.4);background:#d9d4cc;isolation:isolate;color:#fff} .gf-img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;opacity:0;transform:scale(1.04);filter:saturate(var(--gf-sat)) contrast(1.06) brightness(1.02);transition:opacity 1.1s ease,transform 6s ease} .gf-img[data-on]{opacity:1;transform:scale(1)} .gf-fx{position:absolute;inset:0;pointer-events:none} .gf-tint{background:var(--gf-tint);mix-blend-mode:soft-light;opacity:var(--gf-tint-amount)} .gf-grain{inset:-50%;background-image:var(--gf-noise);background-size:240px 240px;mix-blend-mode:overlay;opacity:var(--gf-amount)} .gf-grain2{background-image:var(--gf-noise);background-size:240px 240px;background-position:120px 60px;mix-blend-mode:soft-light;opacity:var(--gf-amount-soft)} .gf-weave{background:repeating-linear-gradient(0deg,rgba(0,0,0,.55) 0 1px,transparent 1px 3px),repeating-linear-gradient(90deg,rgba(255,255,255,.25) 0 1px,transparent 1px 5px);mix-blend-mode:overlay;opacity:var(--gf-weave)} .gf-shade{background:linear-gradient(180deg,rgba(0,0,0,.22),transparent 28%,transparent 68%,rgba(0,0,0,.28))} .gf[data-animate] .gf-grain{animation:gf-shimmer .9s steps(5) infinite} @keyframes gf-shimmer{0%{transform:translate(0,0)}20%{transform:translate(-6%,4%)}40%{transform:translate(5%,-7%)}60%{transform:translate(-3%,8%)}80%{transform:translate(8%,2%)}100%{transform:translate(0,0)}} .gf-over{position:absolute;inset:0;display:flex;flex-direction:column;justify-content:space-between;padding:clamp(12px,2vw,22px) clamp(14px,2vw,24px);font-size:clamp(12px,1.2vw,15px);text-shadow:0 1px 8px rgba(0,0,0,.25)} .gf-row{display:flex;align-items:center;justify-content:space-between;gap:12px} .gf-status{display:inline-flex;align-items:center;gap:8px} .gf-dot{width:8px;height:8px;border-radius:50%;background:#3ee06a;box-shadow:0 0 0 3px rgba(62,224,106,.25)} .gf-dot[data-bad]{background:#ffb02e;box-shadow:0 0 0 3px rgba(255,176,46,.25)} .gf-socials{display:flex;gap:14px} .gf-socials a{color:#fff;display:inline-flex;opacity:.92;transition:opacity .2s,transform .2s} .gf-socials a:hover{opacity:1;transform:translateY(-1px)} .gf-credit a{color:#fff;text-underline-offset:3px} .gf-dots{display:flex;gap:6px;position:absolute;left:50%;bottom:clamp(12px,2vw,22px);transform:translateX(-50%)} .gf-dots button{width:6px;height:6px;padding:0;border:0;border-radius:3px;background:rgba(255,255,255,.5);cursor:pointer;transition:width .3s,background .3s} .gf-dots button[aria-current=true]{width:18px;background:#fff} .gf a:focus-visible,.gf button:focus-visible{outline:2px solid currentColor;outline-offset:2px} @media (max-width:640px){.gf-band{aspect-ratio:min(var(--gf-aspect,4.4),2.2)}.gf-over .gf-row:last-child{flex-direction:column;align-items:flex-start;gap:4px}.gf-dots{display:none}} @media (prefers-reduced-motion:reduce){.gf-img{transition:opacity .01s}.gf[data-animate] .gf-grain{animation:none}} `; function Icon({ type }: { type: SocialType }) { const p = { width: 18, height: 18, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 1.6, strokeLinecap: "round" as const, strokeLinejoin: "round" as const, "aria-hidden": true }; switch (type) { case "x": return ; case "linkedin": return ; case "youtube": return ; case "github": return ; case "instagram": return ; default: return ; } } function useReducedMotion() { const [reduced, setReduced] = useState(false); useEffect(() => { const m = window.matchMedia("(prefers-reduced-motion: reduce)"); setReduced(m.matches); const on = () => setReduced(m.matches); m.addEventListener("change", on); return () => m.removeEventListener("change", on); }, []); return reduced; } export function GrainFooter({ config, className, style }: { config: GrainFooterConfig; className?: string; style?: CSSProperties }) { const c = config; const images = c.images.filter(Boolean); const [active, setActive] = useState(0); const [hovering, setHovering] = useState(false); const reduced = useReducedMotion(); const label = useId(); const interval = c.rotate?.intervalMs ?? 5000; const onHover = c.rotate?.onHover ?? true; const many = images.length > 1; useEffect(() => { if (active >= images.length) setActive(0); }, [images.length, active]); useEffect(() => { if (!many || reduced || interval <= 0 || hovering) return; const t = setInterval(() => setActive((i) => (i + 1) % images.length), Math.max(1200, interval)); return () => clearInterval(t); }, [many, reduced, interval, hovering, images.length]); const vars = useMemo(() => ({ ...grainVars(c.grain), "--gf-aspect": String(c.aspect ?? 4.4) }) as CSSProperties, [c.grain, c.aspect]); const year = new Date().getFullYear(); const copyright = c.copyright ?? `©${year} ${c.brand.name}. All rights reserved`; const animate = !!c.grain?.animate && !reduced; return ( ); } export default GrainFooter;