Vorlage · React · HTML
Footer Tiles
Ein Seitenfuß aus vier Kacheln: Adresse, Öffnungszeiten mit „jetzt geöffnet“ in der eigenen Zeitzone des Ortes, Links und soziale Medien.
Vorschau
React
Installiere die Vorlage mit shadcn. Sie landet als normale Komponenten in deinem Projekt, die du selbst anpasst.
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-tiles.jsonnpx shadcn@latest add @manniche/footer-tilesfooter-tiles.tsx
// The footer as a small bento of four tiles: address, opening hours, links and social links, with a legal row under
// them. Signature: the open-now dot is the only --primary on the page. Whether the place is open is computed from the
// weekly schedule, the time zone and `now`, so a visitor in another zone still sees the shop's own time. The dot is
// never the only signal: the words "Open now" or "Closed, opens at 09:00" sit beside it, and a closed place shows a
// hollow ring. Screen readers get a <footer> landmark, an h2 (hidden), one h3 per tile, <address>, real lists, and
// opening hours as a list of day ranges with today marked in text. The ©-year and the status are computed after mount
// unless `now` is passed; until then the tile shows a neutral "Opening hours" line. When `now` is absent the status
// refreshes every 30 seconds. Under reduced motion nothing animates (the only motion is colour-free opacity on hover).
import { ArrowUpRight } from 'lucide-react'
import { useId, useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'
export type FooterTilesLink = {
label: string
href: string
/** Opens in a new tab with rel="noopener noreferrer" and a hidden "(opens in a new tab)". */
external?: boolean
}
/** 0 is Sunday, 1 Monday, up to 6 Saturday. */
export type Weekday = 0 | 1 | 2 | 3 | 4 | 5 | 6
export type OpeningRange = {
day: Weekday
/** 24-hour "HH:MM" in the place's own time zone. */
open: string
/** 24-hour "HH:MM", later than `open` on the same day. A day may have several ranges (a lunch break). */
close: string
}
export type FooterTilesAddress = {
/** The place's name, e.g. "Halden Studio, Munich". */
name: string
/** Street, postcode and city, one entry per line. */
lines: string[]
/** Link for the "Directions" action, e.g. a map URL. */
directionsHref: string
}
export type FooterTilesHours = {
/** An IANA time zone, e.g. "Europe/Berlin". The schedule is read in this zone. */
timeZone: string
schedule: OpeningRange[]
}
export type FooterTilesSocial = {
label: string
href: string
/** A small icon, decorative: the label carries the meaning. */
icon: ReactNode
}
export type FooterTilesLabels = {
heading?: string
navigation?: string
address?: string
directions?: string
hours?: string
links?: string
social?: string
openNow?: string
closed?: string
/** `{time}` is replaced with the closing time. */
closesAt?: string
/** `{time}` is replaced with the opening time (later today). */
opensAt?: string
/** `{day}` and `{time}` are replaced with the next opening. */
opensOn?: string
today?: string
/** `{zone}` is replaced with the time zone. */
zoneNote?: string
/** Shown before the status is known (server render, first paint). */
unknown?: string
/** Seven short day names, Sunday first. */
days?: [string, string, string, string, string, string, string]
shut?: string
newTab?: string
}
export type FooterTilesProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
address: FooterTilesAddress
hours: FooterTilesHours
/** Links tile: a short list of pages. */
links: FooterTilesLink[]
/** Social tile: each with an icon and text. */
social: FooterTilesSocial[]
/** The name after the © sign. */
owner: string
/** Legal links under the tiles. */
legalLinks?: FooterTilesLink[]
/** The moment to judge "open now" by. When absent the real time is used after mount and refreshed every 30 s. */
now?: Date
/** UI strings, for translation. */
labels?: FooterTilesLabels
}
const DEFAULT_LABELS: Required<FooterTilesLabels> = {
heading: 'Footer',
navigation: 'Footer links',
address: 'Visit',
directions: 'Directions',
hours: 'Opening hours',
links: 'Explore',
social: 'Follow',
openNow: 'Open now',
closed: 'Closed',
closesAt: 'closes {time}',
opensAt: 'opens at {time}',
opensOn: 'opens {day} {time}',
today: 'Today',
zoneNote: 'Times in {zone}.',
unknown: 'Opening hours',
days: ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'],
shut: 'Closed',
newTab: '(opens in a new tab)',
}
// Time logic ----------------------------------------------------------------------------------------------------
const WEEKDAYS: Record<string, Weekday> = { Sun: 0, Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6 }
const toMinutes = (hhmm: string) => {
const [h, m] = hhmm.split(':').map(Number)
return (h || 0) * 60 + (m || 0)
}
/** The weekday and minute of the day in the given zone, or null for a zone the browser does not know. */
function zonedParts(date: Date, timeZone: string): { day: Weekday; minutes: number } | null {
try {
const parts = new Intl.DateTimeFormat('en-US', { timeZone, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }).formatToParts(date)
const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ''
const day = WEEKDAYS[get('weekday')]
if (day === undefined) return null
return { day, minutes: (Number(get('hour')) % 24) * 60 + Number(get('minute')) }
} catch {
return null
}
}
type Status =
| { open: true; day: Weekday; closes: string }
| { open: false; day: Weekday; next?: { day: Weekday; time: string; today: boolean } }
/** Is the place open at `date`, and if not, when does it open next? Null when the zone is unknown. */
function openStatus(hours: FooterTilesHours, date: Date): Status | null {
const at = zonedParts(date, hours.timeZone)
if (!at) return null
const forDay = (d: Weekday) => hours.schedule.filter((r) => r.day === d).sort((a, b) => toMinutes(a.open) - toMinutes(b.open))
const current = forDay(at.day).find((r) => toMinutes(r.open) <= at.minutes && at.minutes < toMinutes(r.close))
if (current) return { open: true, day: at.day, closes: current.close }
for (let offset = 0; offset <= 7; offset++) {
const day = ((at.day + offset) % 7) as Weekday
const later = forDay(day).find((r) => offset > 0 || toMinutes(r.open) > at.minutes)
if (later) return { open: false, day: at.day, next: { day, time: later.open, today: offset === 0 } }
}
return { open: false, day: at.day }
}
const noopSubscribe = () => () => {}
const subscribeMinute = (onChange: () => void) => {
const id = setInterval(onChange, 30_000)
return () => clearInterval(id)
}
// A whole-minute timestamp, so the snapshot only changes when the minute does. Null on the server and first render.
const minuteNow = () => Math.floor(Date.now() / 60_000) * 60_000
function useNow(now?: Date): Date | null {
const live = useSyncExternalStore(now ? noopSubscribe : subscribeMinute, minuteNow, () => null)
if (now) return now
return live === null ? null : new Date(live)
}
const noopYearSubscribe = () => () => {}
function useYear(now: Date | null) {
const clientYear = useSyncExternalStore(noopYearSubscribe, () => new Date().getFullYear(), () => null)
return now ? now.getFullYear() : clientYear
}
/** Merges consecutive days with the same hours into one line: "Mon–Fri 09:00–18:00". Monday first. */
function dayGroups(schedule: OpeningRange[], days: string[], shut: string) {
const order: Weekday[] = [1, 2, 3, 4, 5, 6, 0]
const text = (d: Weekday) =>
schedule
.filter((r) => r.day === d)
.sort((a, b) => toMinutes(a.open) - toMinutes(b.open))
.map((r) => `${r.open}–${r.close}`)
.join(', ') || shut
const groups: { first: Weekday; last: Weekday; days: Weekday[]; text: string }[] = []
for (const d of order) {
const t = text(d)
const prev = groups[groups.length - 1]
if (prev && prev.text === t) {
prev.last = d
prev.days.push(d)
} else groups.push({ first: d, last: d, days: [d], text: t })
}
return groups.map((g) => ({
key: `${g.first}-${g.last}`,
label: g.first === g.last ? days[g.first] : `${days[g.first]}–${days[g.last]}`,
days: g.days,
text: g.text,
shut: g.text === shut,
}))
}
// Pieces --------------------------------------------------------------------------------------------------------
const ROW =
'relative flex min-h-11 items-center justify-between gap-3 rounded-md text-sm text-foreground transition-[opacity] duration-200 ease-out-quint hover:opacity-70 focus-visible:opacity-100 focus-visible:outline-offset-0'
function ext(external?: boolean) {
return external ? { target: '_blank', rel: 'noopener noreferrer' } : {}
}
function fill(template: string, values: Record<string, string>) {
return template.replace(/\{(\w+)\}/g, (_, k: string) => values[k] ?? '')
}
function HoursTile({ hours, now, labels }: { hours: FooterTilesHours; now: Date | null; labels: Required<FooterTilesLabels> }) {
const status = now ? openStatus(hours, now) : null
const groups = dayGroups(hours.schedule, labels.days, labels.shut)
// Closed with no next opening only happens for an empty schedule: then "Closed" stands alone.
let line: string = status && !status.open && !status.next ? '' : labels.unknown
if (status?.open) line = fill(labels.closesAt, { time: status.closes })
else if (status?.next) {
line = status.next.today
? fill(labels.opensAt, { time: status.next.time })
: fill(labels.opensOn, { day: labels.days[status.next.day], time: status.next.time })
}
return (
<DataTile
title={labels.hours}
footer={<span className="font-mono text-xs tabular-nums">{fill(labels.zoneNote, { zone: hours.timeZone })}</span>}
>
<p className="flex min-h-6 items-center gap-2.5 text-sm font-medium">
{status ? (
<>
<span
aria-hidden
className={cn('size-2.5 flex-none rounded-full', status.open ? 'bg-primary' : 'border-[1.5px] border-muted-foreground')}
/>
<span>
{status.open ? labels.openNow : labels.closed}
{line && (
<span className="font-normal text-muted-foreground">
{status.open ? ' · ' : ', '}
<span className="font-mono text-[13px] tabular-nums">{line}</span>
</span>
)}
</span>
</>
) : (
<span className="text-muted-foreground">{line}</span>
)}
</p>
<ul className="mt-4 grid gap-1.5 border-t border-border pt-4">
{groups.map((g) => {
const isToday = status !== null && g.days.includes(status.day)
return (
<li key={g.key} className="flex items-baseline justify-between gap-3 text-[13.5px]">
<span className={cn('flex items-baseline gap-2 whitespace-nowrap', isToday ? 'font-medium text-foreground' : 'text-muted-foreground')}>
{g.label}
{isToday && <span className="rounded-full bg-muted px-2 py-px text-[11px] font-medium text-foreground">{labels.today}</span>}
</span>
<span className={cn('font-mono text-xs tabular-nums', isToday ? 'text-foreground' : 'text-muted-foreground', g.shut && 'font-sans text-[13.5px]')}>
{g.text}
</span>
</li>
)
})}
</ul>
</DataTile>
)
}
/** A four-tile footer: address, opening hours with an open-now signal, links and social links. */
export function FooterTiles({ address, hours, links, social, owner, legalLinks = [], now, labels, className, ...rest }: FooterTilesProps) {
const l = { ...DEFAULT_LABELS, ...labels }
const clock = useNow(now)
const year = useYear(clock)
const headingId = useId()
return (
<footer {...rest} aria-labelledby={headingId} className={cn('@container w-full bg-background text-foreground', className)}>
<div className="mx-auto w-full max-w-6xl px-4 py-12 @min-[40rem]:py-16 sm:px-6">
<h2 id={headingId} className="sr-only">
{l.heading}
</h2>
<div className="grid gap-3 @min-[36rem]:grid-cols-2 @min-[64rem]:grid-cols-4 @min-[64rem]:gap-4">
<DataTile
title={l.address}
footer={
<a
href={address.directionsHref}
target="_blank"
rel="noopener noreferrer"
className="-my-1 inline-flex min-h-11 items-center gap-1.5 rounded-md font-medium text-foreground transition-[opacity] duration-200 hover:opacity-70 focus-visible:outline-offset-0"
>
{l.directions}
<span className="sr-only">
{' '}
{l.newTab}
</span>
<ArrowUpRight className="size-4" aria-hidden />
</a>
}
>
<address className="text-sm leading-relaxed not-italic">
<span className="block font-medium">{address.name}</span>
{address.lines.map((line) => (
<span key={line} className="block text-muted-foreground">
{line}
</span>
))}
</address>
</DataTile>
<HoursTile hours={hours} now={clock} labels={l} />
<DataTile title={l.links}>
<nav aria-label={l.navigation}>
<ul className="-my-1.5">
{links.map((link) => (
<li key={link.href + link.label}>
<a href={link.href} {...ext(link.external)} className={ROW}>
{link.label}
{link.external && <span className="sr-only"> {l.newTab}</span>}
</a>
</li>
))}
</ul>
</nav>
</DataTile>
<DataTile title={l.social}>
<ul className="-my-1.5">
{social.map((s) => (
<li key={s.href + s.label}>
<a href={s.href} target="_blank" rel="noopener noreferrer" className={ROW}>
<span className="flex items-center gap-3">
<span aria-hidden className="grid size-5 flex-none place-items-center text-muted-foreground [&>svg]:size-[18px]">
{s.icon}
</span>
{s.label}
<span className="sr-only"> {l.newTab}</span>
</span>
<ArrowUpRight className="size-4 flex-none text-muted-foreground" aria-hidden />
</a>
</li>
))}
</ul>
</DataTile>
</div>
<div className="mt-4 flex flex-col gap-x-6 gap-y-0 px-1 @min-[40rem]:flex-row @min-[40rem]:items-center @min-[40rem]:justify-between @min-[40rem]:px-2">
<p className="min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums">
© {year ?? ''}
{year ? ' ' : ''}
{owner}
</p>
{legalLinks.length > 0 && (
<ul className="flex flex-wrap gap-x-5">
{legalLinks.map((link) => (
<li key={link.href + link.label}>
<a
href={link.href}
{...ext(link.external)}
className="relative inline-flex min-h-11 items-center rounded-md text-[13px] text-muted-foreground transition-[color] duration-200 ease-out-quint hover:text-foreground focus-visible:text-foreground focus-visible:outline-offset-0"
>
{link.label}
{link.external && <span className="sr-only"> {l.newTab}</span>}
</a>
</li>
))}
</ul>
)}
</div>
</div>
</footer>
)
}footer-tiles-demo.tsx
import { FooterTiles, type OpeningRange, type Weekday } from '@/components/footer-tiles'
// Halden Studio is an invented company. Every link goes to example.com; the address is made up.
const WEEK: Weekday[] = [1, 2, 3, 4, 5]
const SCHEDULE: OpeningRange[] = WEEK.flatMap((day) => [
{ day, open: '09:00', close: '12:30' },
{ day, open: '13:30', close: '18:00' },
])
const Glyph = ({ d }: { d: string }) => (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
<path d={d} />
</svg>
)
export default function FooterTilesDemo() {
return (
<FooterTiles
address={{
name: 'Halden Studio, Munich',
lines: ['Example Strasse 12', '80331 Munich', 'Germany'],
directionsHref: 'https://example.com/map/munich',
}}
hours={{ timeZone: 'Europe/Berlin', schedule: SCHEDULE }}
links={[
{ label: 'Work', href: 'https://example.com/work' },
{ label: 'Studio', href: 'https://example.com/about' },
{ label: 'Journal', href: 'https://example.com/journal' },
{ label: 'Contact', href: 'https://example.com/contact' },
]}
social={[
{ label: 'LinkedIn', href: 'https://example.com/linkedin', icon: <Glyph d="M4 9h4v11H4zM6 4a2 2 0 1 1 0 4 2 2 0 0 1 0-4zM11 9h4v2c.7-1.3 2-2.2 4-2.2 3 0 4 2 4 5V20h-4v-5.5c0-1.5-.5-2.5-2-2.5s-2 1-2 2.5V20h-4z" /> },
{ label: 'Mastodon', href: 'https://example.com/mastodon', icon: <Glyph d="M4 12c0-5 1-8 8-8s8 3 8 8-3 7-8 7c-2 0-4-.3-5-1l-3 2 1-4c-1-1-1-2-1-4zM9 9v5M12 14V9l3 5V9" /> },
{ label: 'RSS feed', href: 'https://example.com/feed.xml', icon: <Glyph d="M5 5a14 14 0 0 1 14 14M5 11a8 8 0 0 1 8 8M6 19h.01" /> },
]}
owner="Halden Studio"
legalLinks={[
{ label: 'Privacy', href: 'https://example.com/privacy' },
{ label: 'Imprint', href: 'https://example.com/imprint' },
]}
/>
)
}HTML
Die ganze Seite als eine Datei mit eingebautem CSS und ohne JavaScript. Umschalter, Tabs und Filter funktionieren allein mit HTML und CSS.
Die Farbe in deiner eigenen App
Wenn shadcn bereits eingerichtet ist, ergeben diese Variablen dieselbe Farbe. Hell in :root, dunkel in .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);
}Installieren
npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-tiles.json