Mikkel Manniche
Dansk English Deutsch

Template · React · HTML

Footer Sitemap

A dense sitemap footer with five to seven link groups that fold into disclosures on a narrow box, and a region picker.

Preview

React

Install the template with shadcn. It lands in your project as ordinary components that you edit yourself.

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-sitemap.json
npx shadcn@latest add @manniche/footer-sitemap
footer-sitemap.tsx
// A dense sitemap footer for big sites: 5-7 link groups, a brand block and a region or language picker.
// Signature: on a wide box every group is open and carries a mono count of its links; on a narrow box each group
// folds into a disclosure. The switch follows the width of the footer's own box (a ResizeObserver, not the
// viewport), and before it has been measured (server render) the groups are open, so nothing is hidden from a
// reader without scripts.
// Screen readers: a <footer> with a hidden h2; each group is an h3 (a button with aria-expanded when folded, plain
// text when open) over a real list; the picker is a nav with aria-current on the chosen region.
// Reduced motion: a group fades and lifts in when opened; under reduced motion it just appears. Height jumps by design.
import { ChevronDown } from 'lucide-react'
import { MotionConfig, motion } from 'motion/react'
import { useEffect, useId, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'

export type SitemapLink = {
  label: string
  href: string
  /** Opens in a new tab with `rel="noopener noreferrer"` and a hidden "(opens in a new tab)". */
  external?: boolean
}

export type SitemapGroup = {
  /** The group heading: "Product". */
  title: string
  links: SitemapLink[]
}

export type SitemapRegion = {
  /** What the link says: "Deutsch", "Denmark". */
  label: string
  href: string
  /** BCP 47 tag for `hreflang` and `lang` on the link: "de". */
  lang?: string
  /** The one the visitor is on. */
  current?: boolean
}

export type FooterSitemapProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
  /** The wordmark or logo slot. */
  brand: ReactNode
  /** One sentence about the site, under the brand. */
  description?: ReactNode
  /** The link groups, in order. Five to seven read best. */
  groups: SitemapGroup[]
  /** Regions or languages, as links. Left out, the picker is left out. */
  regions?: SitemapRegion[]
  /** The legal line: ©, terms, privacy. */
  legal?: ReactNode
  /** Width of the footer's box, in px, below which the groups fold. Default 640. */
  collapseBelow?: number
  /** Groups that start open when folded. Default none. */
  defaultOpen?: string[]
  /** Interface strings, for translation. */
  labels?: {
    /** Hidden heading of the footer. */
    heading?: string
    /** Name of the region picker. */
    regions?: string
    /** Hidden text after an external link. */
    newTab?: string
  }
}

const L = { heading: 'Sitemap', regions: 'Region and language', newTab: '(opens in a new tab)' }

const focus = 'focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring'

/** True when the element's own box is at least `min` px wide. Null until measured. */
function useWide(min: number) {
  const ref = useRef<HTMLDivElement>(null)
  const [wide, setWide] = useState<boolean | null>(null)
  useEffect(() => {
    const el = ref.current
    if (!el) return
    const ro = new ResizeObserver(([entry]) => setWide(entry.contentRect.width >= min))
    ro.observe(el)
    return () => ro.disconnect()
  }, [min])
  return [ref, wide] as const
}

function LinkList({ links, newTab }: { links: SitemapLink[]; newTab: string }) {
  return (
    <ul className="grid">
      {links.map((l) => (
        <li key={l.href + l.label}>
          <a
            href={l.href}
            {...(l.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
            className={cn(
              // The row is the 44 px target, so neighbouring targets never overlap.
              'inline-flex h-11 items-center rounded-sm text-[14.5px] text-muted-foreground underline-offset-4 hover:text-foreground hover:underline',
              focus,
            )}
          >
            {l.label}
            {l.external && <span className="sr-only"> {newTab}</span>}
          </a>
        </li>
      ))}
    </ul>
  )
}

function Group({ group, folded, open, onToggle, newTab }: { group: SitemapGroup; folded: boolean; open: boolean; onToggle: () => void; newTab: string }) {
  const id = useId()
  const count = (
    <span className="font-mono text-[11px] text-muted-foreground tabular-nums" aria-hidden>
      {String(group.links.length).padStart(2, '0')}
    </span>
  )
  if (!folded) {
    return (
      <div className="min-w-0">
        <h3 className="flex min-h-11 items-center gap-2 text-[15px] font-medium tracking-[-0.01em]">
          {group.title}
          {count}
        </h3>
        <LinkList links={group.links} newTab={newTab} />
      </div>
    )
  }
  return (
    <div className="min-w-0 border-t first:border-t-0">
      <h3>
        <button
          type="button"
          aria-expanded={open}
          aria-controls={id}
          onClick={onToggle}
          className={cn('flex min-h-14 w-full cursor-pointer items-center gap-3 rounded-sm text-left text-[15px] font-medium tracking-[-0.01em]', focus)}
        >
          {group.title}
          {count}
          <ChevronDown
            aria-hidden
            className={cn('ml-auto size-4 flex-none text-muted-foreground transition-[transform] duration-200 ease-out-quint', open && 'rotate-180')}
          />
        </button>
      </h3>
      <div id={id} role="region" aria-label={group.title} hidden={!open}>
        {open && (
          <motion.div initial={{ opacity: 0, y: -6 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.24, ease: [0.23, 1, 0.32, 1] }} className="pb-3">
            <LinkList links={group.links} newTab={newTab} />
          </motion.div>
        )}
      </div>
    </div>
  )
}

export function FooterSitemap({ brand, description, groups, regions, legal, collapseBelow = 640, defaultOpen = [], labels, className, ...rest }: FooterSitemapProps) {
  const t = { ...L, ...labels }
  const [ref, wide] = useWide(collapseBelow)
  const folded = wide === false
  const [open, setOpen] = useState<Set<string>>(() => new Set(defaultOpen))
  const toggle = (title: string) =>
    setOpen((prev) => {
      const next = new Set(prev)
      if (!next.delete(title)) next.add(title)
      return next
    })

  return (
    <footer className={cn('mx-auto w-full max-w-6xl px-4 sm:px-6', className)} {...rest}>
      <h2 className="sr-only">{t.heading}</h2>
      <MotionConfig reducedMotion="user">
        <div ref={ref} className="@container">
          <DataTile>
            <div className="grid gap-8 py-1 @min-[64rem]:grid-cols-[minmax(0,15rem)_minmax(0,1fr)] @min-[64rem]:gap-12">
              <div className="grid content-start gap-3">
                <div className="text-lg font-semibold tracking-tight">{brand}</div>
                {description && <p className="max-w-[34ch] text-sm leading-relaxed text-pretty text-muted-foreground">{description}</p>}
              </div>
              <div
                className={cn(
                  'grid min-w-0',
                  folded ? 'grid-cols-1' : 'grid-cols-[repeat(auto-fill,minmax(10.5rem,1fr))] gap-x-6 gap-y-6',
                )}
              >
                {groups.map((g) => (
                  <Group key={g.title} group={g} folded={folded} open={open.has(g.title)} onToggle={() => toggle(g.title)} newTab={t.newTab} />
                ))}
              </div>
            </div>

            {(regions?.length || legal) && (
              <div className="mt-8 flex flex-col gap-4 border-t pt-5 @min-[48rem]:flex-row @min-[48rem]:items-center @min-[48rem]:justify-between">
                {regions && regions.length > 0 && (
                  <nav aria-label={t.regions}>
                    {/* 32 px pills + 12 px row gap: the 44 px hit areas of wrapped rows meet without overlapping. */}
                    <ul className="flex flex-wrap gap-x-1.5 gap-y-3">
                      {regions.map((r) => (
                        <li key={r.href + r.label}>
                          <a
                            href={r.href}
                            hrefLang={r.lang}
                            lang={r.lang}
                            aria-current={r.current ? 'true' : undefined}
                            className={cn(
                              'relative inline-flex h-8 min-w-11 items-center justify-center rounded-full px-3 text-[13.5px] underline-offset-4',
                              'before:absolute before:-inset-y-1.5 before:inset-x-0 before:content-[""]',
                              r.current ? 'bg-foreground text-background' : 'bg-muted text-muted-foreground hover:text-foreground',
                              focus,
                            )}
                          >
                            {r.label}
                          </a>
                        </li>
                      ))}
                    </ul>
                  </nav>
                )}
                {legal && <div className="flex flex-wrap items-center gap-x-5 gap-y-1 text-[13px] text-muted-foreground">{legal}</div>}
              </div>
            )}
          </DataTile>
        </div>
      </MotionConfig>
    </footer>
  )
}
footer-sitemap-demo.tsx
import { useState } from 'react'
import { Pills } from '@/components/chart-kit'
import { FooterSitemap, type SitemapGroup } from '@/components/footer-sitemap'

const link = (label: string, external = false) => ({ label, href: `https://example.com/${label.toLowerCase().replace(/[^a-z]+/g, '-')}`, external })

const groups: SitemapGroup[] = [
  { title: 'Studio', links: ['About', 'Team', 'Careers', 'Press', 'Contact'].map((l) => link(l)) },
  { title: 'Work', links: ['Case studies', 'Product design', 'Prototyping', 'Research'].map((l) => link(l)) },
  { title: 'Services', links: ['Discovery sprint', 'Design systems', 'Front-end builds', 'Workshops', 'Retainers'].map((l) => link(l)) },
  { title: 'Journal', links: ['Latest', 'Essays', 'Field notes', 'Newsletter', 'RSS'].map((l) => link(l)) },
  { title: 'Resources', links: [link('Guides'), link('Templates'), link('Open source', true), link('Brand kit')] },
  { title: 'Offices', links: ['Munich', 'Aalborg', 'Visit us', 'Directions'].map((l) => link(l)) },
  { title: 'Legal', links: ['Privacy', 'Terms', 'Imprint', 'Cookies'].map((l) => link(l)) },
]

const regions = [
  { label: 'English', href: 'https://example.com/en', lang: 'en', current: true },
  { label: 'Deutsch', href: 'https://example.com/de', lang: 'de' },
  { label: 'Dansk', href: 'https://example.com/da', lang: 'da' },
]

export default function FooterSitemapDemo() {
  // The fold follows the footer's own box, so a narrow wrapper shows it without resizing the window.
  const [box, setBox] = useState('wide')
  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">Box</span>
        <Pills
          label="Footer width"
          value={box}
          onChange={setBox}
          options={[
            { id: 'wide', label: 'Wide' },
            { id: 'narrow', label: 'Narrow' },
          ]}
        />
      </div>
      <div className={box === 'narrow' ? 'mx-auto w-full max-w-sm' : 'w-full'}>
        <FooterSitemap
          brand="Halden Studio"
          description="A small product studio in Munich and Aalborg."
          groups={groups}
          regions={regions}
          defaultOpen={['Studio']}
          legal={<span>© 2026 Halden Studio</span>}
        />
      </div>
    </div>
  )
}

HTML

The whole page as one file with all CSS built in and no JavaScript. Toggles, tabs and filters work with HTML and CSS alone.

Open in a new tab

The colour in your own app

If you already have shadcn set up, these variables give the same colour. Light in :root, dark 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);
}

Install

npx shadcn@latest add https://mikkelmanniche.dk/lab/r/footer-sitemap.json

Dependencies

Files

  • footer-sitemap.tsx
  • footer-sitemap.html

Manniche Lab · open source

Free tools for AI agents and web development.