Office Clocks Footer
Footer with live office clocks: analog dials show local time and open or closed state, plus a UTC ruler and newsletter.
- Footers
- Architecture
- React only · 0 deps
- 1 file
Install. One command, or copy the file.
$
npx shadcn@latest add https://freedesignmd.com/r/office-clocks-footer.json- Byte-identical to the preview
- No Tailwind or config needed
- SSR-safe · keyboard accessible
- Respects reduced motion & transparency
components/footers/office-clocks-footer.tsx
1/**2 * OfficeClocksFooter, a light footer for a practice with studios on four3 * continents. Every studio shows its live local time on a small analog dial4 * and in digits, whether it is open right now, its address and phone. A 24-hour5 * UTC ruler underneath draws each studio's working day as a band with a "now"6 * line, so you can see at a glance who is still at their desk. Hovering a7 * studio highlights its band. Sitemap links and a quarterly newsletter sit8 * above.9 *10 * Props: name, offices (city, timeZone, open/close "HH:MM", days 0-6 with11 * 0 = Sunday, address, phone), columns, legal.12 * CSS vars: --fd-ocf-bg, --fd-ocf-fg, --fd-ocf-accent.13 *14 * Time: computed with Intl.DateTimeFormat for each IANA zone, refreshed on15 * every minute boundary and when the tab becomes visible again.16 * SSR: renders "--:--" placeholders, real times appear after mount (no17 * hydration mismatch).18 * Accessibility: each studio is a labelled group, open state is text, the19 * ruler is aria-hidden (its content repeats the text). Newsletter has a real20 * label, inline error tied with aria-describedby and a polite success message.21 * Reduced motion: hands jump instead of sweeping.22 */23import { useEffect, useId, useMemo, useState } from "react";24import type { FormEvent } from "react";25 26export interface OcfOffice {27 city: string;28 country: string;29 timeZone: string;30 open: string;31 close: string;32 days?: number[];33 address: string[];34 phone: string;35}36export interface OcfColumn { title: string; links: { label: string; href: string }[] }37 38export interface OfficeClocksFooterProps {39 name?: string;40 offices?: OcfOffice[];41 columns?: OcfColumn[];42 legal?: string[];43 className?: string;44}45 46const DEFAULT_OFFICES: OcfOffice[] = [47 { city: "Copenhagen", country: "Denmark", timeZone: "Europe/Copenhagen", open: "09:00", close: "18:00", address: ["Strandgade 27B", "1401 København K"], phone: "+45 32 57 81 00" },48 { city: "Lagos", country: "Nigeria", timeZone: "Africa/Lagos", open: "08:30", close: "17:30", address: ["14 Idowu Taylor Street", "Victoria Island"], phone: "+234 1 271 4402" },49 { city: "Singapore", country: "Singapore", timeZone: "Asia/Singapore", open: "09:00", close: "18:30", address: ["61 Duxton Road, #03-01", "Singapore 089525"], phone: "+65 6221 0938" },50 { city: "Mexico City", country: "Mexico", timeZone: "America/Mexico_City", open: "09:30", close: "18:30", address: ["Colima 284, Roma Norte", "06700 CDMX"], phone: "+52 55 5584 2271" },51];52 53const DEFAULT_COLUMNS: OcfColumn[] = [54 { title: "Practice", links: [{ label: "Approach", href: "#" }, { label: "People", href: "#" }, { label: "Research", href: "#" }, { label: "Awards", href: "#" }] },55 { title: "Work", links: [{ label: "Civic", href: "#" }, { label: "Housing", href: "#" }, { label: "Education", href: "#" }, { label: "Adaptive reuse", href: "#" }] },56 { title: "Join", links: [{ label: "Open roles (6)", href: "#" }, { label: "Internships", href: "#" }, { label: "Press kit", href: "#" }, { label: "Contact", href: "#" }] },57];58 59const WD = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];60const toMin = (s: string) => { const [h, m] = s.split(":").map(Number); return h * 60 + m; };61const hhmm = (m: number) => `${String(Math.floor(((m % 1440) + 1440) % 1440 / 60)).padStart(2, "0")}:${String(((m % 60) + 60) % 60).padStart(2, "0")}`;62 63interface Local { mins: number; day: number; offset: number; abbr: string }64 65function localOf(now: number, timeZone: string): Local {66 const parts = new Intl.DateTimeFormat("en-US", { timeZone, hourCycle: "h23", weekday: "short", hour: "2-digit", minute: "2-digit", year: "numeric", month: "2-digit", day: "2-digit", timeZoneName: "short" }).formatToParts(new Date(now));67 const get = (t: string) => parts.find((p) => p.type === t)?.value ?? "";68 const hour = Number(get("hour")) % 24, minute = Number(get("minute"));69 const asUtc = Date.UTC(Number(get("year")), Number(get("month")) - 1, Number(get("day")), hour, minute);70 const offset = Math.round((asUtc - Math.floor(now / 60000) * 60000) / 60000);71 return { mins: hour * 60 + minute, day: WD.indexOf(get("weekday")), offset, abbr: get("timeZoneName") };72}73 74function statusOf(o: OcfOffice, l: Local) {75 const days = o.days ?? [1, 2, 3, 4, 5];76 const a = toMin(o.open), b = toMin(o.close);77 if (days.includes(l.day) && l.mins >= a && l.mins < b) {78 const left = b - l.mins;79 return { open: true, text: left <= 60 ? `Open, closes in ${left} min` : `Open until ${o.close}` };80 }81 if (days.includes(l.day) && l.mins < a) return { open: false, text: `Closed, opens ${o.open}` };82 for (let k = 1; k <= 7; k++) {83 const d = (l.day + k) % 7;84 if (days.includes(d)) return { open: false, text: `Closed, opens ${k === 1 ? "tomorrow" : WD[d]} ${o.open}` };85 }86 return { open: false, text: "Closed" };87}88 89const utcLabel = (off: number) => {90 if (off === 0) return "UTC";91 const s = off > 0 ? "+" : "−";92 const a = Math.abs(off);93 return `UTC${s}${Math.floor(a / 60)}${a % 60 ? ":" + String(a % 60).padStart(2, "0") : ""}`;94};95 96function useMinuteClock() {97 const [now, setNow] = useState<number | null>(null);98 useEffect(() => {99 let t: ReturnType<typeof setTimeout>;100 const tick = () => {101 const n = Date.now();102 setNow(n);103 t = setTimeout(tick, 60000 - (n % 60000) + 30);104 };105 tick();106 const vis = () => { if (!document.hidden) { clearTimeout(t); tick(); } };107 document.addEventListener("visibilitychange", vis);108 return () => { clearTimeout(t); document.removeEventListener("visibilitychange", vis); };109 }, []);110 return now;111}112 113function Dial({ total, open }: { total: number | null; open: boolean }) {114 const m = total ?? 0;115 return (116 <svg className="fd-ocf-dial" viewBox="0 0 64 64" aria-hidden="true" data-open={open ? "" : undefined}>117 <circle cx="32" cy="32" r="30.5" className="fd-ocf-face" />118 {Array.from({ length: 12 }, (_, i) => (119 <line key={i} x1="32" y1={i % 3 === 0 ? 4.5 : 5.5} x2="32" y2={i % 3 === 0 ? 10 : 8} transform={`rotate(${i * 30} 32 32)`} className="fd-ocf-tick" />120 ))}121 <g className="fd-ocf-hand" style={{ transform: `rotate(${total === null ? 0 : m * 0.5}deg)` }}>122 <line x1="32" y1="35" x2="32" y2="17" className="fd-ocf-hh" />123 </g>124 <g className="fd-ocf-hand" style={{ transform: `rotate(${total === null ? 0 : m * 6}deg)` }}>125 <line x1="32" y1="37" x2="32" y2="9" className="fd-ocf-mh" />126 </g>127 <circle cx="32" cy="32" r="2.4" className="fd-ocf-pin" />128 </svg>129 );130}131 132export function OfficeClocksFooter({133 name = "Hallberg Adeyemi",134 offices = DEFAULT_OFFICES,135 columns = DEFAULT_COLUMNS,136 legal = ["© 2026 Hallberg Adeyemi Arkitekter ApS", "CVR 41228790"],137 className,138}: OfficeClocksFooterProps) {139 const now = useMinuteClock();140 const uid = useId();141 const [hot, setHot] = useState<number | null>(null);142 const [email, setEmail] = useState("");143 const [state, setState] = useState<"idle" | "error" | "sent">("idle");144 145 const locals = useMemo(() => (now === null ? null : offices.map((o) => localOf(now, o.timeZone))), [now, offices]);146 const utcMin = now === null ? null : Math.floor((now % 86400000) / 60000);147 const openCount = locals ? offices.filter((o, i) => statusOf(o, locals[i]).open).length : null;148 149 const submit = (e: FormEvent) => {150 e.preventDefault();151 if (!/^[^\s@]+@[^\s@]+\.[^\s@]{2,}$/.test(email.trim())) { setState("error"); return; }152 setState("sent");153 };154 155 return (156 <footer className={`fd-ocf${className ? " " + className : ""}`}>157 <style>{css}</style>158 <div className="fd-ocf-in">159 <div className="fd-ocf-top">160 <div className="fd-ocf-lead">161 <p className="fd-ocf-mark">{name}<span aria-hidden="true">/</span><small>Architects</small></p>162 <p className="fd-ocf-say">Four studios, one drawing set. Someone here is always awake, {openCount === null ? "and" : openCount === 1 ? "one studio is" : `${openCount} studios are`} at their desks right now.</p>163 </div>164 <nav className="fd-ocf-cols" aria-label="Footer">165 {columns.map((c) => (166 <div key={c.title}>167 <h3 className="fd-ocf-lab">{c.title}</h3>168 <ul>{c.links.map((l) => <li key={l.label}><a className="fd-ocf-a" href={l.href}>{l.label}</a></li>)}</ul>169 </div>170 ))}171 </nav>172 <form className="fd-ocf-news" onSubmit={submit} noValidate>173 <label className="fd-ocf-lab" htmlFor={`${uid}-e`}>Field Notes, quarterly</label>174 <p className="fd-ocf-nd">Drawings, site photos and one long essay. Four letters a year, never more.</p>175 {state === "sent" ? (176 <p className="fd-ocf-ok" role="status">177 <svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.6" aria-hidden="true"><path d="M3 8.5l3 3 7-7" /></svg>178 Subscribed. Issue 19 lands on 1 December.179 </p>180 ) : (181 <div className="fd-ocf-field" data-error={state === "error" ? "" : undefined}>182 <input183 id={`${uid}-e`}184 type="email"185 inputMode="email"186 autoComplete="email"187 placeholder="you@studio.com"188 value={email}189 aria-invalid={state === "error"}190 aria-describedby={state === "error" ? `${uid}-err` : undefined}191 onChange={(e) => { setEmail(e.target.value); if (state === "error") setState("idle"); }}192 />193 <button type="submit">Subscribe</button>194 </div>195 )}196 {state === "error" && <p className="fd-ocf-err" id={`${uid}-err`}>That address looks incomplete. Try name@domain.com.</p>}197 </form>198 </div>199 200 <ul className="fd-ocf-offices" aria-label="Studios">201 {offices.map((o, i) => {202 const l = locals?.[i];203 const st = l ? statusOf(o, l) : null;204 const total = now !== null && l ? Math.floor(now / 60000) + l.offset : null;205 return (206 <li207 key={o.city}208 className="fd-ocf-office"209 data-hot={hot === i ? "" : undefined}210 onMouseEnter={() => setHot(i)}211 onMouseLeave={() => setHot(null)}212 onFocus={() => setHot(i)}213 onBlur={() => setHot(null)}214 aria-label={`${o.city} studio`}215 >216 <div className="fd-ocf-oh">217 <div>218 <h3 className="fd-ocf-city">{o.city}</h3>219 <p className="fd-ocf-tz">{o.country}, {l ? utcLabel(l.offset) : "UTC"}</p>220 </div>221 <Dial total={total} open={!!st?.open} />222 </div>223 <p className="fd-ocf-time">224 <time data-pending={l ? undefined : ""}>{l ? hhmm(l.mins) : "00:00"}</time>225 <span>{l ? WD[l.day] : ""}</span>226 </p>227 <p className="fd-ocf-st" data-open={st?.open ? "" : undefined}>228 <i aria-hidden="true" />{st ? st.text : "Checking hours"}229 </p>230 <address className="fd-ocf-addr">231 {o.address.map((a) => <span key={a}>{a}</span>)}232 <a className="fd-ocf-a" href={`tel:${o.phone.replace(/\s/g, "")}`}>{o.phone}</a>233 </address>234 </li>235 );236 })}237 </ul>238 239 <div className="fd-ocf-ruler" aria-hidden="true">240 <div className="fd-ocf-rh"><span>Working hours, UTC</span><span>{utcMin === null ? "" : `Now ${hhmm(utcMin)} UTC`}</span></div>241 <div className="fd-ocf-track">242 {[0, 3, 6, 9, 12, 15, 18, 21, 24].map((h) => (243 <span key={h} className="fd-ocf-hr" data-end={h === 24 ? "" : undefined} style={{ left: `${(h / 24) * 100}%` }}><b>{String(h % 24).padStart(2, "0")}</b></span>244 ))}245 {offices.map((o, i) => {246 const off = locals?.[i]?.offset;247 if (off === undefined) return null;248 const a = toMin(o.open) - off, b = toMin(o.close) - off;249 const segs: [number, number][] = [];250 const s = ((a % 1440) + 1440) % 1440, len = b - a;251 if (s + len <= 1440) segs.push([s, s + len]); else { segs.push([s, 1440]); segs.push([0, s + len - 1440]); }252 const st = locals ? statusOf(o, locals[i]) : null;253 return (254 <div key={o.city} className="fd-ocf-row" style={{ top: `${28 + i * 16}px` }} data-hot={hot === i ? "" : undefined} data-dim={hot !== null && hot !== i ? "" : undefined} data-open={st?.open ? "" : undefined}>255 {segs.map(([x, y], k) => (256 <span key={k} className="fd-ocf-band" data-flip={y / 1440 > 0.8 ? "" : undefined} style={{ left: `${(x / 1440) * 100}%`, width: `${((y - x) / 1440) * 100}%` }}>257 {k === 0 && <em>{o.city}</em>}258 </span>259 ))}260 </div>261 );262 })}263 {utcMin !== null && <span className="fd-ocf-now" style={{ left: `${(utcMin / 1440) * 100}%` }} />}264 </div>265 </div>266 267 <div className="fd-ocf-legal">268 <ul>269 {legal.map((l) => <li key={l}>{l}</li>)}270 <li><a className="fd-ocf-a" href="#">Privacy</a></li>271 <li><a className="fd-ocf-a" href="#">Imprint</a></li>272 </ul>273 <ul>274 <li><a className="fd-ocf-a" href="#">Instagram</a></li>275 <li><a className="fd-ocf-a" href="#">LinkedIn</a></li>276 <li><a className="fd-ocf-a" href="#">Dezeen profile</a></li>277 </ul>278 </div>279 </div>280 </footer>281 );282}283 284const SANS = `ui-sans-serif,-apple-system,BlinkMacSystemFont,"SF Pro Text","Inter","Segoe UI",Roboto,sans-serif`;285const MONO = `ui-monospace,"SF Mono","JetBrains Mono",Menlo,monospace`;286 287const css = `288:where(.fd-ocf,.fd-ocf *){box-sizing:border-box;margin:0;padding:0;border:0;background:none;font:inherit;letter-spacing:inherit;color:inherit;text-decoration:none;list-style:none;font-style:normal}289:where(.fd-ocf,.fd-ocf *)::before,:where(.fd-ocf,.fd-ocf *)::after{box-sizing:border-box}290:where(.fd-ocf svg){display:block;flex-shrink:0}291:where(.fd-ocf button,.fd-ocf input){appearance:none;-webkit-appearance:none;border-radius:0;outline:0}292.fd-ocf{293 --fd-ease-out:cubic-bezier(.22,1,.36,1);--fd-ease-in-out:cubic-bezier(.65,0,.35,1);--fd-spring:cubic-bezier(.34,1.56,.64,1);--fd-dur-1:120ms;--fd-dur-2:240ms;--fd-dur-3:420ms;--fd-dur-4:700ms;294 --fd-ocf-bg:#E8E5DF;--fd-ocf-fg:#171614;--fd-ocf-mut:#6B665E;--fd-ocf-line:rgba(23,22,20,.14);--fd-ocf-line2:rgba(23,22,20,.28);--fd-ocf-accent:#B23B22;--fd-ocf-ok:#2F6B3A;295 display:block;width:100%;background:var(--fd-ocf-bg);color:var(--fd-ocf-fg);font:400 15px/22px ${SANS};letter-spacing:-.005em;-webkit-font-smoothing:antialiased}296.fd-ocf-in{max-width:1440px;margin:0 auto;padding:clamp(48px,6vw,80px) clamp(16px,3.4vw,48px) 28px}297.fd-ocf-top{display:grid;grid-template-columns:minmax(0,1.3fr) minmax(0,1.3fr) minmax(0,1fr);gap:40px clamp(28px,4vw,64px);padding-bottom:48px}298.fd-ocf-mark{display:flex;align-items:baseline;gap:8px;flex-wrap:wrap;font:700 20px/24px ${SANS};letter-spacing:-.02em;text-transform:uppercase}299.fd-ocf-mark span{color:var(--fd-ocf-accent);font-weight:400}300.fd-ocf-mark small{font:500 12px/16px ${MONO};letter-spacing:.04em;color:var(--fd-ocf-mut);text-transform:none}301.fd-ocf-say{margin-top:20px;max-width:420px;font:400 clamp(1.25rem,1.8vw,1.5rem)/1.3 ${SANS};letter-spacing:-.015em;color:var(--fd-ocf-fg)}302.fd-ocf-lab{display:block;font:500 11px/16px ${MONO};letter-spacing:.08em;text-transform:uppercase;color:var(--fd-ocf-mut)}303.fd-ocf-cols{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}304.fd-ocf-cols ul{display:grid;gap:7px;margin-top:14px}305.fd-ocf-a{color:inherit;border-radius:1px;background:linear-gradient(currentColor,currentColor) 0 100%/0 1px no-repeat;transition:background-size var(--fd-dur-3) var(--fd-ease-out),color var(--fd-dur-1)}306.fd-ocf-a:hover{background-size:100% 1px;color:var(--fd-ocf-accent)}307.fd-ocf-a:focus-visible,.fd-ocf-field button:focus-visible{outline:2px solid color-mix(in srgb,var(--fd-ocf-accent) 70%,transparent);outline-offset:2px}308.fd-ocf-nd{margin-top:10px;font:400 14px/20px ${SANS};color:var(--fd-ocf-mut);max-width:320px}309.fd-ocf-field{display:flex;margin-top:14px;height:44px;box-shadow:inset 0 -1px 0 var(--fd-ocf-fg);transition:box-shadow var(--fd-dur-2)}310.fd-ocf-field:focus-within{box-shadow:inset 0 -2px 0 var(--fd-ocf-fg)}311.fd-ocf-field[data-error]{box-shadow:inset 0 -2px 0 var(--fd-ocf-accent)}312.fd-ocf-field input{flex:1;min-width:0;height:44px;font:400 15px/22px ${SANS};color:var(--fd-ocf-fg);background:transparent}313.fd-ocf-field input::placeholder{color:#9A958C}314.fd-ocf-field button{display:inline-flex;align-items:center;height:44px;padding:0 2px 0 12px;font:600 14px/1 ${SANS};color:var(--fd-ocf-fg);cursor:pointer;transition:color var(--fd-dur-1),transform var(--fd-dur-2) var(--fd-spring)}315.fd-ocf-field button:hover{color:var(--fd-ocf-accent)}316.fd-ocf-field button:active{transform:scale(.96);transition-duration:var(--fd-dur-1)}317.fd-ocf-err{margin-top:8px;font:400 13px/18px ${SANS};color:var(--fd-ocf-accent)}318.fd-ocf-ok{display:flex;align-items:center;gap:8px;margin-top:14px;height:44px;font:500 14px/20px ${SANS};color:var(--fd-ocf-ok);box-shadow:inset 0 -1px 0 var(--fd-ocf-line2);animation:fd-ocf-in var(--fd-dur-3) var(--fd-ease-out)}319@keyframes fd-ocf-in{from{opacity:0;transform:translateY(6px)}}320.fd-ocf-offices{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));border-top:1px solid var(--fd-ocf-fg)}321.fd-ocf-office{position:relative;padding:20px clamp(14px,1.6vw,24px) 24px;border-left:1px solid var(--fd-ocf-line);transition:background-color var(--fd-dur-2) var(--fd-ease-out)}322.fd-ocf-office:first-child{border-left:0;padding-left:0}323.fd-ocf-office[data-hot]{background:linear-gradient(rgba(23,22,20,.035),transparent)}324.fd-ocf-oh{display:flex;justify-content:space-between;align-items:flex-start;gap:12px}325.fd-ocf-city{font:600 17px/22px ${SANS};letter-spacing:-.01em}326.fd-ocf-tz{margin-top:2px;font:400 12px/16px ${MONO};color:var(--fd-ocf-mut)}327.fd-ocf-dial{width:52px;height:52px}328.fd-ocf-face{fill:none;stroke:var(--fd-ocf-line2);stroke-width:1}329.fd-ocf-dial[data-open] .fd-ocf-face{fill:#F4F2EE;stroke:var(--fd-ocf-fg)}330.fd-ocf-tick{stroke:var(--fd-ocf-fg);stroke-width:1.2;stroke-linecap:round;opacity:.55}331.fd-ocf-hand{transform-origin:32px 32px;transform-box:view-box;transition:transform var(--fd-dur-4) var(--fd-spring)}332.fd-ocf-hh{stroke:var(--fd-ocf-fg);stroke-width:3;stroke-linecap:round}333.fd-ocf-mh{stroke:var(--fd-ocf-fg);stroke-width:1.6;stroke-linecap:round}334.fd-ocf-dial[data-open] .fd-ocf-mh{stroke:var(--fd-ocf-accent)}335.fd-ocf-pin{fill:var(--fd-ocf-bg);stroke:var(--fd-ocf-fg);stroke-width:1.4}336.fd-ocf-time{display:flex;align-items:baseline;gap:10px;margin-top:18px}337.fd-ocf-time time{font:300 clamp(2.5rem,4.2vw,3.75rem)/1 ${SANS};letter-spacing:-.045em;font-variant-numeric:tabular-nums}338.fd-ocf-time time[data-pending]{opacity:.12}339.fd-ocf-time span{font:500 12px/16px ${MONO};color:var(--fd-ocf-mut);text-transform:uppercase;letter-spacing:.06em}340.fd-ocf-st{display:flex;align-items:center;gap:8px;margin-top:10px;font:500 13px/18px ${SANS};color:var(--fd-ocf-mut)}341.fd-ocf-st i{display:block;width:7px;height:7px;border-radius:9999px;box-shadow:inset 0 0 0 1.4px currentColor}342.fd-ocf-st[data-open]{color:var(--fd-ocf-ok)}343.fd-ocf-st[data-open] i{background:currentColor;box-shadow:0 0 0 3px color-mix(in srgb,var(--fd-ocf-ok) 18%,transparent)}344.fd-ocf-addr{display:flex;flex-direction:column;align-items:flex-start;margin-top:16px;padding-top:14px;border-top:1px dashed var(--fd-ocf-line2);font:400 14px/20px ${SANS};color:var(--fd-ocf-fg)}345.fd-ocf-addr .fd-ocf-a{margin-top:6px;font:400 13px/18px ${MONO};color:var(--fd-ocf-mut)}346.fd-ocf-ruler{margin-top:8px;padding:16px 0 20px;border-top:1px solid var(--fd-ocf-line)}347.fd-ocf-rh{display:flex;justify-content:space-between;font:500 11px/16px ${MONO};letter-spacing:.06em;text-transform:uppercase;color:var(--fd-ocf-mut)}348.fd-ocf-rh span:last-child{color:var(--fd-ocf-accent)}349.fd-ocf-track{position:relative;height:100px;margin-top:10px;overflow:hidden}350.fd-ocf-hr{position:absolute;top:0;bottom:0;width:0;border-left:1px solid var(--fd-ocf-line)}351.fd-ocf-hr b{position:absolute;top:0;left:4px;font:400 10px/12px ${MONO};color:var(--fd-ocf-mut)}352.fd-ocf-hr[data-end] b{left:auto;right:4px}353.fd-ocf-band[data-flip] em{left:auto;right:100%;margin:0 8px 0 0}354.fd-ocf-row{position:absolute;left:0;right:0;height:10px;transition:opacity var(--fd-dur-2)}355.fd-ocf-row[data-dim]{opacity:.3}356.fd-ocf-band{position:absolute;top:0;height:10px;background:var(--fd-ocf-line2);border-radius:2px}357.fd-ocf-row[data-open] .fd-ocf-band{background:var(--fd-ocf-fg)}358.fd-ocf-row[data-hot] .fd-ocf-band{background:var(--fd-ocf-accent)}359.fd-ocf-band em{position:absolute;left:100%;top:-3px;margin-left:8px;font:500 11px/16px ${MONO};color:var(--fd-ocf-mut);white-space:nowrap}360.fd-ocf-now{position:absolute;top:18px;bottom:-2px;width:0;border-left:1.5px solid var(--fd-ocf-accent)}361.fd-ocf-now::before{content:"";position:absolute;left:-4.5px;top:-4px;width:8px;height:8px;border-radius:9999px;background:var(--fd-ocf-accent)}362.fd-ocf-legal{display:flex;justify-content:space-between;gap:12px 32px;flex-wrap:wrap;padding-top:20px;border-top:1px solid var(--fd-ocf-line);font:400 12px/16px ${MONO};color:var(--fd-ocf-mut)}363.fd-ocf-legal ul{display:flex;flex-wrap:wrap;gap:6px 20px}364@media (max-width:1080px){365 .fd-ocf-top{grid-template-columns:1fr 1fr}366 .fd-ocf-lead{grid-column:1/-1}367 .fd-ocf-offices{grid-template-columns:1fr 1fr}368 .fd-ocf-office:nth-child(3){border-left:0;padding-left:0}369 .fd-ocf-office:nth-child(n+3){border-top:1px solid var(--fd-ocf-line)}370}371@media (max-width:640px){372 .fd-ocf-top{grid-template-columns:1fr}373 .fd-ocf-cols{grid-template-columns:1fr 1fr}374 .fd-ocf-cols>div:last-child{grid-column:1/-1}375 .fd-ocf-offices{grid-template-columns:1fr}376 .fd-ocf-office{border-left:0;padding-left:0;padding-right:0}377 .fd-ocf-office+.fd-ocf-office{border-top:1px solid var(--fd-ocf-line)}378 .fd-ocf-time time{font-size:2.75rem}379 .fd-ocf-band em{display:none}380}381@media (prefers-reduced-motion:reduce){.fd-ocf *{transition-duration:1ms!important;animation:none!important}}382@media (prefers-contrast:more){.fd-ocf{--fd-ocf-mut:#45413B;--fd-ocf-line:rgba(23,22,20,.45)}}383`;384 385/* ───────────────────────────── demo ───────────────────────────── */386 387const SPEC: [string, string][] = [388 ["Client", "Aarhus Kommune"],389 ["Programme", "Public library, 3 reading rooms, roof garden"],390 ["Area", "12,400 m²"],391 ["Timber", "Cross-laminated spruce, 2,310 m³"],392 ["Completed", "September 2025"],393 ["Team", "Copenhagen with Lagos"],394];395 396export function Demo() {397 return (398 <div className="fd-ocfd">399 <style>{demoCss}</style>400 <section className="fd-ocfd-end" aria-label="Project data">401 <p className="fd-ocfd-n">Project 112</p>402 <div>403 <h2>Havnehuset Library, Aarhus</h2>404 <dl>405 {SPEC.map(([k, v]) => <div key={k}><dt>{k}</dt><dd>{v}</dd></div>)}406 </dl>407 </div>408 </section>409 <OfficeClocksFooter />410 </div>411 );412}413 414const demoCss = `415:where(.fd-ocfd,.fd-ocfd *){box-sizing:border-box;margin:0;padding:0;border:0;font:inherit;letter-spacing:inherit;color:inherit}416.fd-ocfd{min-height:100vh;background:#F3F1EC;color:#171614;font:400 15px/22px ${SANS};-webkit-font-smoothing:antialiased}417.fd-ocfd-end{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,2.4fr);gap:24px;max-width:1440px;margin:0 auto;padding:56px clamp(16px,3.4vw,48px) 64px}418.fd-ocfd-n{font:500 12px/16px ${MONO};letter-spacing:.06em;text-transform:uppercase;color:#6B665E}419.fd-ocfd-end h2{font:500 clamp(1.75rem,3vw,2.5rem)/1.1 ${SANS};letter-spacing:-.03em}420.fd-ocfd-end dl{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));margin-top:28px;border-top:1px solid rgba(23,22,20,.14)}421.fd-ocfd-end dl div{padding:14px 16px 14px 0;border-bottom:1px solid rgba(23,22,20,.14)}422.fd-ocfd-end dt{font:500 11px/16px ${MONO};letter-spacing:.06em;text-transform:uppercase;color:#6B665E}423.fd-ocfd-end dd{margin-top:4px}424@media (max-width:760px){.fd-ocfd-end{grid-template-columns:1fr}.fd-ocfd-end dl{grid-template-columns:1fr 1fr}}425`;More Footers. Same file contract.
Questions about Office Clocks Footer
Run npx shadcn@latest add https://freedesignmd.com/r/office-clocks-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.
Footer with live office clocks: analog dials show local time and open or closed state, plus a UTC ruler and newsletter. 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 (Intl.DateTimeFormat with IANA time zones).
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.


