Minimal Sign-off Footer
Minimal portfolio footer: name, copy-to-clipboard email with toast, local studio time and a back-to-top progress ring.
- Footers
- Photography
- React only · 0 deps
- 1 file
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/minimal-signoff-footer.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/footers/minimal-signoff-footer.tsx
1/**2 * MinimalSignoffFooter, a one-line sign-off for a photographer's portfolio.3 * Name and practice, an email that copies itself to the clipboard with a4 * small "copied" toast, the local time in the studio's city, and a5 * back-to-top button whose ring fills as you scroll down the page and which6 * scrolls home smoothly.7 *8 * Props: name, practice, email, city, timeZone, note.9 * CSS vars: --fd-msf-bg, --fd-msf-fg, --fd-msf-mut.10 *11 * Browser support: all evergreen browsers. Clipboard uses12 * navigator.clipboard.writeText with an execCommand("copy") fallback.13 * SSR: time renders as a placeholder and fills in after mount; the ring14 * starts empty. Time refreshes on each minute boundary.15 * Accessibility: the email is a button (the address is its label, a16 * mailto link sits beside it for keyboard and screen-reader users); the toast17 * is a polite live region. Back-to-top has an aria-label and moves focus to18 * the top of the document. Reduced motion jumps instead of smooth-scrolling.19 */20import { useEffect, useRef, useState } from "react";21 22export interface MinimalSignoffFooterProps {23 name?: string;24 practice?: string;25 email?: string;26 city?: string;27 timeZone?: string;28 note?: string;29 className?: string;30}31 32export function MinimalSignoffFooter({33 name = "Ilse Varga",34 practice = "Still life and interiors",35 email = "studio@ilsevarga.com",36 city = "Lisbon",37 timeZone = "Europe/Lisbon",38 note = "© 2026",39 className,40}: MinimalSignoffFooterProps) {41 const [time, setTime] = useState<string | null>(null);42 const [copied, setCopied] = useState(false);43 const ringRef = useRef<SVGCircleElement>(null);44 const toastT = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);45 46 useEffect(() => {47 const fmt = new Intl.DateTimeFormat("en-GB", { timeZone, hour: "2-digit", minute: "2-digit", hourCycle: "h23" });48 let t: ReturnType<typeof setTimeout>;49 const tick = () => {50 const n = Date.now();51 setTime(fmt.format(new Date(n)));52 t = setTimeout(tick, 60000 - (n % 60000) + 30);53 };54 tick();55 return () => clearTimeout(t);56 }, [timeZone]);57 58 useEffect(() => {59 const ring = ringRef.current;60 if (!ring) return;61 let raf = 0;62 const update = () => {63 raf = 0;64 const max = document.documentElement.scrollHeight - window.innerHeight;65 const p = max > 0 ? Math.min(1, Math.max(0, window.scrollY / max)) : 0;66 ring.style.strokeDashoffset = String(100 - p * 100);67 };68 const on = () => { if (!raf) raf = requestAnimationFrame(update); };69 update();70 window.addEventListener("scroll", on, { passive: true });71 window.addEventListener("resize", on);72 return () => { window.removeEventListener("scroll", on); window.removeEventListener("resize", on); cancelAnimationFrame(raf); };73 }, []);74 75 useEffect(() => () => clearTimeout(toastT.current), []);76 77 const copy = async () => {78 let ok = false;79 try {80 await navigator.clipboard.writeText(email);81 ok = true;82 } catch {83 const ta = document.createElement("textarea");84 ta.value = email;85 ta.setAttribute("readonly", "");86 ta.style.position = "fixed";87 ta.style.opacity = "0";88 document.body.appendChild(ta);89 ta.select();90 try { ok = document.execCommand("copy"); } catch { ok = false; }91 ta.remove();92 }93 if (!ok) { window.location.href = `mailto:${email}`; return; }94 setCopied(false);95 requestAnimationFrame(() => setCopied(true));96 clearTimeout(toastT.current);97 toastT.current = setTimeout(() => setCopied(false), 1800);98 };99 100 const toTop = () => {101 const reduce = matchMedia("(prefers-reduced-motion: reduce)").matches;102 window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });103 const first = document.querySelector<HTMLElement>("h1, [tabindex='-1'], main");104 if (first) { const had = first.hasAttribute("tabindex"); if (!had) first.setAttribute("tabindex", "-1"); first.focus({ preventScroll: true }); if (!had) first.addEventListener("blur", () => first.removeAttribute("tabindex"), { once: true }); }105 };106 107 return (108 <footer className={`fd-msf${className ? " " + className : ""}`}>109 <style>{css}</style>110 <div className="fd-msf-line">111 <p className="fd-msf-who"><b>{name}</b><span>{practice}</span></p>112 113 <div className="fd-msf-mail">114 <button type="button" className="fd-msf-copy" onClick={copy} aria-describedby={undefined}>115 <span className="fd-msf-addr">{email}</span>116 <span className="fd-msf-hint" aria-hidden="true">{copied ? "copied" : "copy"}</span>117 </button>118 <a className="fd-msf-sr" href={`mailto:${email}`}>Write to {email}</a>119 <span className="fd-msf-toast" role="status" aria-live="polite" data-on={copied ? "" : undefined}>120 {copied ? <><svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true"><path d="M2.5 6.3l2.2 2.2L9.5 3.7" /></svg>Address copied</> : ""}121 </span>122 </div>123 124 <p className="fd-msf-time">125 <span>{city}</span>126 <time data-pending={time ? undefined : ""}>{time ?? "00:00"}</time>127 </p>128 129 <p className="fd-msf-note">{note}</p>130 131 <button type="button" className="fd-msf-top" onClick={toTop} aria-label="Back to top">132 <svg width="40" height="40" viewBox="0 0 40 40" aria-hidden="true">133 <circle cx="20" cy="20" r="18" className="fd-msf-track" />134 <circle ref={ringRef} cx="20" cy="20" r="18" pathLength={100} className="fd-msf-ring" transform="rotate(-90 20 20)" />135 <path d="M20 26V14M15 19l5-5 5 5" className="fd-msf-arrow" />136 </svg>137 </button>138 </div>139 </footer>140 );141}142 143const SERIF = `"New York",ui-serif,"Iowan Old Style",Georgia,serif`;144const SANS = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;145const MONO = `ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace`;146 147const css = `148:where(.fd-msf,.fd-msf *){box-sizing:border-box;margin:0;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none}149:where(.fd-msf,.fd-msf *)::before,:where(.fd-msf,.fd-msf *)::after{box-sizing:border-box}150:where(.fd-msf svg){display:block;flex-shrink:0}151:where(.fd-msf button){appearance:none;-webkit-appearance:none;border-radius:0;outline:0;cursor:pointer;text-align:inherit}152.fd-msf{153 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;154 --fd-msf-bg:#121211;--fd-msf-fg:#E9E5DB;--fd-msf-mut:#88837A;--fd-msf-line:rgba(233,229,219,.12);155 display:block;width:100%;background:var(--fd-msf-bg);color:var(--fd-msf-fg);font:400 14px/20px ${SANS};letter-spacing:0;-webkit-font-smoothing:antialiased}156.fd-msf-line{display:grid;grid-template-columns:minmax(0,1fr) auto minmax(0,1fr) auto auto;align-items:center;gap:16px 40px;padding:28px clamp(16px,3.4vw,48px);border-top:1px solid var(--fd-msf-line)}157.fd-msf-who{display:flex;align-items:baseline;gap:12px;min-width:0;white-space:nowrap}158.fd-msf-who b{font:italic 400 19px/24px ${SERIF};letter-spacing:-.01em;color:var(--fd-msf-fg)}159.fd-msf-who span{overflow:hidden;text-overflow:ellipsis;color:var(--fd-msf-mut)}160.fd-msf-mail{position:relative;justify-self:center}161.fd-msf-copy{position:relative;display:inline-flex;align-items:baseline;gap:10px;padding:6px 0;color:var(--fd-msf-fg);font:400 14px/20px ${SANS};border-radius:2px}162.fd-msf-addr{background:linear-gradient(currentColor,currentColor) 0 100%/100% 1px no-repeat;padding-bottom:2px;transition:background-size var(--fd-dur-3) var(--fd-ease-out)}163.fd-msf-copy:hover .fd-msf-addr{background-size:0 1px;background-position:100% 100%}164.fd-msf-hint{font:400 11px/16px ${MONO};color:var(--fd-msf-mut);opacity:0;transform:translateX(-4px);transition:opacity var(--fd-dur-2),transform var(--fd-dur-2) var(--fd-ease-out)}165.fd-msf-copy:hover .fd-msf-hint,.fd-msf-copy:focus-visible .fd-msf-hint{opacity:1;transform:none}166.fd-msf-copy:active{transform:scale(.98);transition:transform var(--fd-dur-1)}167.fd-msf-copy:focus-visible,.fd-msf-top:focus-visible{outline:2px solid rgba(233,229,219,.7);outline-offset:3px}168.fd-msf-toast{position:absolute;left:50%;bottom:calc(100% + 10px);display:inline-flex;align-items:center;gap:6px;height:28px;padding:0 11px;border-radius:9999px;background:var(--fd-msf-fg);color:var(--fd-msf-bg);font:500 12px/1 ${SANS};white-space:nowrap;pointer-events:none;opacity:0;transform:translate(-50%,6px) scale(.94);transition:opacity var(--fd-dur-2),transform var(--fd-dur-3) var(--fd-spring)}169.fd-msf-toast:empty{padding:0}170.fd-msf-toast[data-on]{opacity:1;transform:translate(-50%,0) scale(1)}171.fd-msf-time{display:flex;align-items:baseline;gap:10px;justify-self:end;color:var(--fd-msf-mut);white-space:nowrap}172.fd-msf-time time{font:400 13px/20px ${MONO};font-variant-numeric:tabular-nums;color:var(--fd-msf-fg)}173.fd-msf-time time[data-pending]{opacity:.15}174.fd-msf-note{color:var(--fd-msf-mut);white-space:nowrap}175.fd-msf-top{display:grid;place-items:center;width:40px;height:40px;border-radius:9999px;color:var(--fd-msf-fg);transition:transform var(--fd-dur-2) var(--fd-spring)}176.fd-msf-top:active{transform:scale(.92);transition-duration:var(--fd-dur-1)}177.fd-msf-track{fill:none;stroke:var(--fd-msf-line);stroke-width:1}178.fd-msf-ring{fill:none;stroke:var(--fd-msf-fg);stroke-width:1.25;stroke-linecap:round;stroke-dasharray:100;stroke-dashoffset:100}179.fd-msf-arrow{fill:none;stroke:currentColor;stroke-width:1.3;stroke-linecap:round;stroke-linejoin:round;transition:transform var(--fd-dur-3) var(--fd-spring)}180.fd-msf-top:hover .fd-msf-arrow{transform:translateY(-2px)}181.fd-msf-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}182.fd-msf-sr:focus{position:static;width:auto;height:auto;clip:auto;display:block;margin-top:4px;font-size:12px;color:var(--fd-msf-mut)}183@media (max-width:860px){184 .fd-msf-line{grid-template-columns:minmax(0,1fr) auto;row-gap:18px}185 .fd-msf-who{grid-column:1/2}186 .fd-msf-time{grid-column:2;grid-row:1}187 .fd-msf-mail{justify-self:start;grid-column:1;grid-row:2}188 .fd-msf-note{display:none}189 .fd-msf-top{grid-column:2;grid-row:2;justify-self:end}190 .fd-msf-who span{display:none}191 .fd-msf-toast{left:0;transform:translate(0,6px) scale(.94);transform-origin:0 100%}192 .fd-msf-toast[data-on]{transform:none}193}194@media (prefers-reduced-motion:reduce){.fd-msf *{transition-duration:1ms!important}}195@media (prefers-contrast:more){.fd-msf{--fd-msf-mut:#C4BFB4;--fd-msf-line:rgba(233,229,219,.45)}}196`;197 198/* ───────────────────────────── demo ───────────────────────────── */199 200const PLATES = [201 { src: "/img/lux/still-vase.webp", n: "22", t: "Two pots, one lemon", d: "Alfama, May 2026" },202 { src: "/img/lux/still-tea.webp", n: "23", t: "Tea after the shoot", d: "Alfama, May 2026" },203 { src: "/img/lux/still-grey.webp", n: "24", t: "Grey morning", d: "Alfama, June 2026" },204];205 206export function Demo() {207 return (208 <div className="fd-msfd">209 <style>{demoCss}</style>210 <main className="fd-msfd-main">211 <header className="fd-msfd-head">212 <h1>Kitchen Table</h1>213 <p>A series of 24 plates, shot with one window and no assistant.</p>214 </header>215 <ol className="fd-msfd-plates">216 {PLATES.map((p) => (217 <li key={p.n}>218 <figure>219 <img src={p.src} alt={p.t} width={900} height={1125} loading="lazy" decoding="async" />220 <figcaption><span>{p.n}/24</span>{p.t}<em>{p.d}</em></figcaption>221 </figure>222 </li>223 ))}224 </ol>225 <p className="fd-msfd-end">End of series. Prints in editions of 12, ask by email.</p>226 </main>227 <MinimalSignoffFooter />228 </div>229 );230}231 232const demoCss = `233:where(.fd-msfd,.fd-msfd *){box-sizing:border-box;margin:0;padding:0;border:0;font:inherit;letter-spacing:inherit;color:inherit;list-style:none;font-style:normal}234:where(.fd-msfd img){display:block}235.fd-msfd{min-height:100vh;background:#121211;color:#E9E5DB;font:400 14px/20px ${SANS};-webkit-font-smoothing:antialiased}236.fd-msfd-main{padding:56px clamp(16px,3.4vw,48px) 96px;outline:0}237.fd-msfd-head{display:flex;justify-content:space-between;align-items:baseline;gap:16px;flex-wrap:wrap}238.fd-msfd-head h1{font:italic 400 clamp(2rem,4vw,3rem)/1 ${SERIF};letter-spacing:-.02em;outline:0}239.fd-msfd-head p{color:#88837A;max-width:360px}240.fd-msfd-plates{display:grid;grid-template-columns:1.25fr 1fr 1fr;gap:clamp(16px,2.4vw,32px);align-items:end;margin-top:40px}241.fd-msfd-plates li:nth-child(2){margin-bottom:12%}242.fd-msfd-plates img{width:100%;height:auto;aspect-ratio:4/5;object-fit:cover}243.fd-msfd-plates figcaption{display:flex;gap:10px;align-items:baseline;flex-wrap:wrap;margin-top:12px;color:#E9E5DB}244.fd-msfd-plates figcaption span{font:400 11px/16px ${MONO};color:#88837A}245.fd-msfd-plates figcaption em{margin-left:auto;color:#88837A}246.fd-msfd-end{margin-top:72px;text-align:center;font:italic 400 17px/24px ${SERIF};color:#88837A}247@media (max-width:760px){.fd-msfd-plates{grid-template-columns:1fr}.fd-msfd-plates li:nth-child(2){margin-bottom:0}}248`;More Footers. Same file contract.
Questions about Minimal Sign-off Footer
Run npx shadcn@latest add https://freedesignmd.com/r/minimal-signoff-footer.json, or open the page and copy the file into your project. It has zero dependencies besides React. What you copy is the exact file that renders the live demo.
Minimal portfolio footer: name, copy-to-clipboard email with toast, local studio time and a back-to-top progress ring. It is a free, MIT-licensed React footers component from the freedesignmd library.
Yes. It is free and MIT-licensed, use, modify and ship it in personal and commercial projects without attribution.
All evergreen browsers. Clipboard API with an execCommand fallback.
Yes. It is a plain React component with its own styles, so it runs in Next.js, Vite, Remix and Lovable projects, and it needs no Tailwind setup. It has zero dependencies besides React.


