Mikkel Manniche
Dansk English Deutsch

Skabelon · React · HTML

Footer Tiles

En sidefod i fire fliser: adresse, åbningstider med åben nu i stedets egen tidszone, links og sociale medier.

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-tiles.json
npx shadcn@latest add @manniche/footer-tiles
footer-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">
            &copy; {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

Hele siden som én fil med al CSS indbygget og intet JavaScript. Knapper, faner og filtre virker med HTML og CSS alene.

Åbn i ny fane

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-tiles.json

Afhængigheder

Filer

  • footer-tiles.tsx
  • footer-tiles.html

Manniche Lab · open source

Gratis værktøjer til AI-agenter og webudvikling.