Mikkel Manniche
Dansk English Deutsch

Template · React · HTML

Footer Status

A product footer with a service status chip in words and shape, the version, a few links and a way back to the top.

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-status.json
npx shadcn@latest add @manniche/footer-status
footer-status.tsx
// A footer for products: how the service is doing right now, which version you are on, a few links, and a way
// back to the top. Signature: the status chip, which says it in words and a distinct shape (never colour alone)
// and carries a mono "Updated 14:20" that you pass in.
// Screen readers: a <footer> with a visually hidden h2; the chip is a polite status region ("Operational. Updated
// 14:20"); links are a real list. The back-to-top button scrolls and then moves focus to `focusTarget`, so the
// next Tab starts at the top of the page, not where the footer was.
// Reduced motion: the scroll is instant, the press feedback is dropped.
import { ArrowUp, CircleAlert, CircleCheck, CircleX } from 'lucide-react'
import type { ComponentPropsWithoutRef, ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { DataTile } from '@/registry/manniche/data-tile/data-tile'

export type ServiceStatus = 'operational' | 'degraded' | 'outage'

export type FooterStatusLink = {
  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 FooterStatusProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
  /** How the service is doing. */
  status: ServiceStatus
  /** When the status was last checked, as text you format: "14:20". Shown in mono after "Updated". */
  updated?: string
  /** Where the full status page lives. Makes the chip a link. */
  statusHref?: string
  /** The running version, and the changelog it links to. */
  version?: { label: string; href: string }
  /** A short list of links. */
  links?: FooterStatusLink[]
  /** The id of the element at the top of the page that receives focus after "Back to top". */
  focusTarget?: string
  /** A quiet line at the end: © and the like. */
  legal?: ReactNode
  /** Interface strings, for translation. */
  labels?: {
    /** Hidden heading of the footer. */
    heading?: string
    operational?: string
    degraded?: string
    outage?: string
    updated?: string
    version?: string
    backToTop?: string
    /** Hidden text after an external link. */
    newTab?: string
    linksLabel?: string
  }
}

const L = {
  heading: 'Product status and links',
  operational: 'All systems operational',
  degraded: 'Degraded performance',
  outage: 'Service outage',
  updated: 'Updated',
  version: 'Version',
  backToTop: 'Back to top',
  newTab: '(opens in a new tab)',
  linksLabel: 'Product links',
}

const ICON = { operational: CircleCheck, degraded: CircleAlert, outage: CircleX }
// The colour only supports the icon and the words. Honey is weak on a light card, so the text stays foreground.
const DOT = { operational: 'text-success', degraded: 'text-chart-2', outage: 'text-destructive' }

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

export function FooterStatus({ status, updated, statusHref, version, links = [], focusTarget, legal, labels, className, ...rest }: FooterStatusProps) {
  const t = { ...L, ...labels }
  const Icon = ICON[status]

  const toTop = () => {
    const reduce = window.matchMedia('(prefers-reduced-motion: reduce)').matches
    window.scrollTo({ top: 0, behavior: reduce ? 'auto' : 'smooth' })
    const target = focusTarget ? document.getElementById(focusTarget) : null
    if (target) {
      if (!target.hasAttribute('tabindex')) target.setAttribute('tabindex', '-1')
      target.focus({ preventScroll: true })
    }
  }

  const chip = (
    <>
      <Icon aria-hidden className={cn('size-4 flex-none', DOT[status])} />
      <span className="text-sm font-medium">{t[status]}</span>
      {updated && (
        <span className="font-mono text-[12px] text-muted-foreground tabular-nums">
          {t.updated} {updated}
        </span>
      )}
    </>
  )
  const chipClass = 'inline-flex min-h-11 max-w-full flex-wrap items-center gap-x-2.5 gap-y-0.5 rounded-full bg-muted px-4 py-1.5'

  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>
      <div className="@container">
        <DataTile density="compact">
          <div className="flex flex-wrap items-center gap-x-6 gap-y-4 pb-1">
            <div role="status" className="min-w-0">
              {statusHref ? (
                <a href={statusHref} className={cn(chipClass, 'transition-[opacity] duration-200 ease-out-quint hover:opacity-80', focus)}>
                  {chip}
                </a>
              ) : (
                <p className={chipClass}>{chip}</p>
              )}
            </div>

            {version && (
              <a
                href={version.href}
                className={cn('inline-flex min-h-11 items-center gap-2 rounded-full px-1 text-sm text-muted-foreground transition-[opacity] duration-200 ease-out-quint hover:text-foreground', focus)}
              >
                {t.version}
                <span className="rounded-full border px-2.5 py-0.5 font-mono text-[12px] text-foreground tabular-nums">{version.label}</span>
              </a>
            )}

            {links.length > 0 && (
              <nav aria-label={t.linksLabel} className="min-w-0 @min-[48rem]:ml-auto">
                <ul className="flex flex-wrap gap-x-5">
                  {links.map((l) => (
                    <li key={l.href + l.label}>
                      <a
                        href={l.href}
                        {...(l.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
                        className={cn('inline-flex min-h-11 items-center rounded-sm text-sm text-muted-foreground underline-offset-4 hover:text-foreground hover:underline', focus)}
                      >
                        {l.label}
                        {l.external && <span className="sr-only"> {t.newTab}</span>}
                      </a>
                    </li>
                  ))}
                </ul>
              </nav>
            )}

            <button
              type="button"
              onClick={toTop}
              className={cn(
                'inline-flex min-h-11 cursor-pointer items-center gap-2 rounded-full border px-4 text-sm font-medium transition-[transform] duration-200 ease-out-quint hover:bg-muted active:scale-[0.97] motion-reduce:active:scale-100',
                focus,
                links.length === 0 && '@min-[48rem]:ml-auto',
                links.length > 0 && 'ml-auto',
              )}
            >
              <ArrowUp aria-hidden className="size-4" />
              {t.backToTop}
            </button>
          </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-status-demo.tsx
import { useState } from 'react'
import { FooterStatus, type ServiceStatus } from '@/components/footer-status'
import { Pills } from '@/components/chart-kit'

export default function FooterStatusDemo() {
  const [status, setStatus] = useState<ServiceStatus>('operational')
  return (
    // The id that Back to top moves focus to.
    <div id="top" 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">Status</span>
        <Pills
          label="Service status"
          value={status}
          onChange={(id) => setStatus(id as ServiceStatus)}
          options={[
            { id: 'operational', label: 'Up' },
            { id: 'degraded', label: 'Slow' },
            { id: 'outage', label: 'Down' },
          ]}
        />
      </div>
      <FooterStatus
        status={status}
        updated="14:20"
        statusHref="https://example.com/status"
        version={{ label: 'v2.4.1', href: 'https://example.com/changelog' }}
        links={[
          { label: 'Docs', href: 'https://example.com/docs' },
          { label: 'API', href: 'https://example.com/api', external: true },
          { label: 'Support', href: 'https://example.com/support' },
          { label: 'Privacy', href: 'https://example.com/privacy' },
        ]}
        focusTarget="top"
        legal={<span>© 2026 Halden Studio</span>}
      />
      {/* Narrowest form: no links, no version. */}
      <FooterStatus status="operational" updated="14:20" />
    </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-status.json

Dependencies

Files

  • footer-status.tsx
  • footer-status.html

Manniche Lab · open source

Free tools for AI agents and web development.