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.jsonnpx shadcn@latest add @manniche/footer-statusfooter-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.
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