Skip to content

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
Full screen

Browser support · All evergreen browsers. Clipboard API with an execCommand fallback.

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 a
4 * small "copied" toast, the local time in the studio's city, and a
5 * back-to-top button whose ring fills as you scroll down the page and which
6 * 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 uses
12 * navigator.clipboard.writeText with an execCommand("copy") fallback.
13 * SSR: time renders as a placeholder and fills in after mount; the ring
14 * starts empty. Time refreshes on each minute boundary.
15 * Accessibility: the email is a button (the address is its label, a
16 * mailto link sits beside it for keyboard and screen-reader users); the toast
17 * is a polite live region. Back-to-top has an aria-label and moves focus to
18 * 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.

Giant Wordmark Footer, footers preview: Footer with a giant wordmark: dark agency footer with sitemap and contacts, closed by a wordmark that rises…

Giant Wordmark Footer

Footer with a giant wordmark: dark agency footer with sitemap and contacts, closed by a wordmark that rises letter by letter.

Curtain Reveal Footer, footers preview: Curtain reveal footer fixed beneath the page and uncovered as content lifts away, with tables, hours, address…

Curtain Reveal Footer

Curtain reveal footer fixed beneath the page and uncovered as content lifts away, with tables, hours, address and a Book call.

Office Clocks Footer, footers preview: Footer with live office clocks: analog dials show local time and open or closed state, plus a UTC ruler and…

Office Clocks Footer

Footer with live office clocks: analog dials show local time and open or closed state, plus a UTC ruler and newsletter.

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.