Skabelon · React · HTML
Footer Desk
En sidefod som en reception: én mørk flise med en kontaktperson, en måde at nå dem på og kontorure, der skifter på minuttet.
Forhåndsvisning
React
Installér skabelonen med shadcn. Den lægges i dit projekt som almindelige komponenter, som du selv retter i.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-desk.jsonnpx shadcn@latest add @manniche/footer-deskfooter-desk.tsx
// A "front desk" footer: one inverted tile that puts a person, a way to reach them and the time where they are
// in front of the visitor. Signature: the office clocks, set in mono, which tick on the minute (not on a timer
// that drifts) and say "Same time" when every office is in the same offset.
// Screen readers: a <footer> with one h2, the contact person as plain text, the offices as a list where each time
// is a <time> element. The clocks never announce themselves. Copy results go through a polite live region.
// Reduced motion: the copy icon swaps with opacity only, nothing else moves.
import { ArrowUpRight, Check, Copy } from 'lucide-react'
import { useEffect, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
export type DeskOffice = {
/** The place, as shown: "Munich". */
city: string
/** An IANA time zone: "Europe/Berlin". */
timeZone: string
}
export type FooterDeskProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
/** The display line of the section (the h2): "Talk to a person." */
heading: ReactNode
/** One quiet sentence under the heading. */
description?: ReactNode
/** The person at the desk. */
contact: {
name: string
role: string
/** One or two letters for the avatar. Defaults to the initials of the name. */
initials?: string
/** Replaces the initials, for a photo. Give it an empty alt: the name is written out next to it. */
avatar?: ReactNode
}
/** The address that gets the copy button. */
email: string
/** "Book a call". Left out, the link is left out. */
bookCall?: { label: string; href: string; external?: boolean }
/** The offices whose local time is shown, in the order given. */
offices: DeskOffice[]
/** The moment to show. Left out, the clocks read the real time after mount and tick once a minute. */
now?: Date
/** BCP 47 locale for the times. Default: the visitor's. */
locale?: string
/** 12-hour clocks. Default false. */
hour12?: boolean
/** A quiet line under the tile: ©, legal links. */
legal?: ReactNode
/** Interface strings, for translation. */
labels?: {
/** Name of the group of clocks. */
offices?: string
/** Shown when all offices share one offset. */
sameTime?: string
/** Shown instead of a time before the clock has been read. */
pending?: string
copy?: string
copied?: string
copyFailed?: string
/** Hidden text after an external link. */
newTab?: string
}
}
const L = {
offices: 'Local time',
sameTime: 'Same time',
pending: '--:--',
copy: 'Copy email',
copied: 'Copied',
copyFailed: 'Not copied. Select the address instead.',
newTab: '(opens in a new tab)',
}
/** Minutes the zone is ahead of UTC at this moment, or null for a zone the browser does not know. */
function offsetMinutes(at: Date, timeZone: string): number | null {
try {
const parts = new Intl.DateTimeFormat('en-US', {
timeZone,
hourCycle: 'h23',
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).formatToParts(at)
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value)
const wall = Date.UTC(n('year'), n('month') - 1, n('day'), n('hour'), n('minute'), n('second'))
return Math.round((wall - Math.floor(at.getTime() / 1000) * 1000) / 60000)
} catch {
return null
}
}
function clock(at: Date, timeZone: string, locale: string | undefined, hour12: boolean) {
try {
return new Intl.DateTimeFormat(locale, { timeZone, hour: '2-digit', minute: '2-digit', hour12 }).format(at)
} catch {
return null
}
}
/** The current time, read after mount and refreshed exactly on each minute. A fixed `now` is used as given. */
function useMinuteClock(fixed?: Date) {
const [at, setAt] = useState<Date | null>(null)
useEffect(() => {
if (fixed) return
let timer: ReturnType<typeof setTimeout>
const tick = () => {
setAt(new Date())
timer = setTimeout(tick, 60000 - (Date.now() % 60000) + 20)
}
tick()
return () => clearTimeout(timer)
}, [fixed])
return fixed ?? at
}
function initialsOf(name: string) {
const words = name.trim().split(/\s+/)
return ((words[0]?.[0] ?? '') + (words.length > 1 ? (words.at(-1)?.[0] ?? '') : '')).toUpperCase()
}
const pill =
'inline-flex min-h-11 items-center gap-2 rounded-full px-5 text-sm font-medium focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'
export function FooterDesk({
heading,
description,
contact,
email,
bookCall,
offices,
now,
locale,
hour12 = false,
legal,
labels,
className,
...rest
}: FooterDeskProps) {
const t = { ...L, ...labels }
const at = useMinuteClock(now)
const [copy, setCopy] = useState<'idle' | 'copied' | 'failed'>('idle')
const reset = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
useEffect(() => () => clearTimeout(reset.current), [])
const copyEmail = async () => {
try {
await navigator.clipboard.writeText(email)
setCopy('copied')
} catch {
setCopy('failed')
}
clearTimeout(reset.current)
reset.current = setTimeout(() => setCopy('idle'), 2400)
}
const offsets = at ? offices.map((o) => offsetMinutes(at, o.timeZone)) : []
const same = offices.length > 1 && offsets.length > 0 && offsets.every((o) => o !== null && o === offsets[0])
return (
<footer className={cn('mx-auto w-full max-w-6xl px-4 sm:px-6', className)} {...rest}>
<div className="@container">
<DataTile inverted className="overflow-hidden">
<div className="grid gap-10 py-2 @min-[56rem]:grid-cols-[minmax(0,1.35fr)_minmax(0,1fr)] @min-[56rem]:gap-14 @min-[56rem]:py-4">
<div className="flex min-w-0 flex-col gap-8">
<div className="grid gap-4">
<h2
className="text-[clamp(32px,6.4cqi,64px)] leading-[0.98] font-extrabold tracking-[-0.04em] text-balance"
style={{ fontFamily: 'var(--font-display, inherit)', fontStretch: '86%' }}
>
{heading}
</h2>
{description && <p className="max-w-[44ch] text-[15px] leading-relaxed text-pretty text-muted-foreground">{description}</p>}
</div>
<div className="flex items-center gap-3.5">
<span
aria-hidden={contact.avatar ? undefined : true}
className="grid size-12 flex-none place-items-center overflow-hidden rounded-full bg-muted font-mono text-[13px] font-medium tracking-wide"
>
{contact.avatar ?? (contact.initials ?? initialsOf(contact.name))}
</span>
<p className="grid min-w-0 gap-0.5">
<span className="text-[15px] leading-tight font-medium">{contact.name}</span>
<span className="text-sm leading-tight text-muted-foreground">{contact.role}</span>
</p>
</div>
<div className="flex flex-wrap items-center gap-x-3 gap-y-3">
<span className="inline-flex min-h-11 max-w-full items-center rounded-full bg-muted pr-1.5 pl-4">
<a
href={`mailto:${email}`}
className="flex min-h-11 min-w-0 items-center rounded-sm font-mono text-[13px] underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring"
>
<span className="truncate">{email}</span>
</a>
<button
type="button"
onClick={copyEmail}
className="relative ml-2 grid size-9 flex-none cursor-pointer place-items-center rounded-full text-muted-foreground transition-[opacity] duration-200 ease-out-quint hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-0 focus-visible:outline-ring before:absolute before:-inset-1 before:content-['']"
>
<span className="sr-only">{t.copy}</span>
<Copy aria-hidden className={cn('absolute size-4 transition-[opacity] duration-200 ease-out-quint', copy === 'copied' ? 'opacity-0' : 'opacity-100')} />
<Check aria-hidden className={cn('absolute size-4 transition-[opacity] duration-200 ease-out-quint', copy === 'copied' ? 'opacity-100' : 'opacity-0')} />
</button>
</span>
{bookCall && (
<a
href={bookCall.href}
{...(bookCall.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className={cn(pill, 'bg-foreground text-background transition-[transform] duration-200 ease-out-quint active:scale-[0.98]')}
>
{bookCall.label}
<ArrowUpRight aria-hidden className="size-4" />
{bookCall.external && <span className="sr-only"> {t.newTab}</span>}
</a>
)}
<span role="status" className={cn('text-[13px] text-muted-foreground', copy === 'idle' && 'sr-only')}>
{copy === 'copied' ? t.copied : copy === 'failed' ? t.copyFailed : ''}
</span>
</div>
</div>
<div className="grid min-w-0 content-start gap-3">
<div className="flex min-h-6 flex-wrap items-center justify-between gap-x-3 gap-y-1">
<h3 className="font-mono text-[11px] tracking-[0.08em] text-muted-foreground uppercase">{t.offices}</h3>
{same && (
<span className="inline-flex items-center gap-1.5 rounded-full bg-muted px-2.5 py-1 font-mono text-[11px] leading-none">
<Check aria-hidden className="size-3" />
{t.sameTime}
</span>
)}
</div>
<ul className="grid gap-3 @min-[26rem]:grid-cols-2 @min-[56rem]:grid-cols-1">
{offices.map((o, i) => {
const text = at ? clock(at, o.timeZone, locale, hour12) : null
const off = offsets[i]
return (
<li key={`${o.city}-${o.timeZone}`} className="flex items-baseline justify-between gap-4 rounded-[14px] bg-muted px-4 py-3.5">
<span className="grid min-w-0 gap-0.5">
<span className="text-[15px] leading-tight font-medium">{o.city}</span>
<span className="font-mono text-[11px] leading-tight text-muted-foreground tabular-nums">
{off === null || off === undefined ? o.timeZone : `UTC${off >= 0 ? '+' : '−'}${Math.floor(Math.abs(off) / 60)}${Math.abs(off) % 60 ? `:${String(Math.abs(off) % 60).padStart(2, '0')}` : ''}`}
</span>
</span>
<time
dateTime={at ? at.toISOString() : undefined}
className="font-mono text-[clamp(24px,4cqi,32px)] leading-none font-medium tracking-tight tabular-nums"
>
{text ?? t.pending}
</time>
</li>
)
})}
</ul>
</div>
</div>
</DataTile>
{legal && <div className="flex flex-wrap items-center gap-x-5 gap-y-2 px-2 pt-5 pb-8 text-[13px] text-muted-foreground">{legal}</div>}
</div>
</footer>
)
}footer-desk-demo.tsx
import { useState } from 'react'
import { Pills } from '@/components/chart-kit'
import { FooterDesk } from '@/components/footer-desk'
const legal = (
<>
<span>© 2026 Halden Studio</span>
<a href="https://example.com/privacy" className="inline-flex min-h-11 items-center underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring">
Privacy
</a>
<a href="https://example.com/imprint" className="inline-flex min-h-11 items-center underline-offset-4 hover:underline focus-visible:outline-2 focus-visible:outline-ring">
Imprint
</a>
</>
)
const DESKS = {
// Two offices in one offset: the "Same time" chip shows, and the clocks tick.
same: {
heading: 'Talk to a person, not a form.',
description: 'Halden Studio is a small product studio in Munich and Aalborg. Write to Mara and you get Mara.',
contact: { name: 'Mara Lindqvist', role: 'Studio lead' },
email: 'hello@example.com',
bookCall: { label: 'Book a call', href: 'https://example.com/call' },
offices: [
{ city: 'Munich', timeZone: 'Europe/Berlin' },
{ city: 'Aalborg', timeZone: 'Europe/Copenhagen' },
],
},
// Three offsets and a fixed moment, so the layout is stable.
spread: {
heading: 'Ask us anything.',
contact: { name: 'Jonas Albrecht', role: 'Partnerships', initials: 'JA' },
email: 'partners@example.com',
bookCall: { label: 'Book a call', href: 'https://example.com/call', external: true },
offices: [
{ city: 'Munich', timeZone: 'Europe/Berlin' },
{ city: 'Lisbon', timeZone: 'Europe/Lisbon' },
{ city: 'New York', timeZone: 'America/New_York' },
],
now: new Date('2026-10-06T14:20:00Z'),
},
}
export default function FooterDeskDemo() {
const [desk, setDesk] = useState<keyof typeof DESKS>('same')
return (
<div className="grid gap-8 py-8">
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center gap-3 px-4 sm:px-6">
<span className="font-mono text-[11px] tracking-[0.08em] text-muted-foreground uppercase">Offices</span>
<Pills
label="Offices"
value={desk}
onChange={(id) => setDesk(id as keyof typeof DESKS)}
options={[
{ id: 'same', label: 'One zone' },
{ id: 'spread', label: 'Three' },
]}
/>
</div>
<FooterDesk key={desk} {...DESKS[desk]} legal={legal} />
</div>
)
}HTML
Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.
Farven i din egen app
Har du allerede shadcn sat op, giver disse variabler samme farve. Lys i :root, mørk i .dark.
:root {
--primary: oklch(0.52 0.2 262);
--primary-foreground: oklch(0.99 0 0);
--accent: oklch(0.95 0.034 262);
--accent-foreground: oklch(0.36 0.140 262);
--ring: oklch(0.52 0.2 262);
}
.dark {
--primary: oklch(0.74 0.150 262);
--primary-foreground: oklch(0.18 0.024 262);
--accent: oklch(0.29 0.050 262);
--accent-foreground: oklch(0.9 0.050 262);
--ring: oklch(0.74 0.150 262);
}Installér
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-desk.json