Template · React · HTML
Footer Slim
A one-row footer for apps and docs: wordmark, links, a light, dark and system switch and the copyright line.
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-slim.jsonnpx shadcn@latest add @manniche/footer-slimfooter-slim.tsx
// The one-row footer for apps and docs: wordmark, inline links, a light / dark / system switch and the © line.
// Signature: it is 56 px tall on a wide box and every control still has a 44 px target. On a narrow box it wraps into
// two centred rows (brand and links, then © and theme) rather than squeezing. It does not touch the DOM theme itself:
// the app owns the theme and passes `theme` and `onThemeChange`. Screen readers get a <footer> landmark, a <nav> list
// for the links, and the theme switch as a labelled radio group (arrow keys, Home and End). The ©-year is computed
// after mount unless `now` is passed. Under reduced motion the switch's pill jumps instead of sliding.
import { useSyncExternalStore, type ComponentPropsWithoutRef, type ReactNode } from 'react'
import { cn } from '@/lib/utils'
import { Pills } from '@/registry/manniche/chart-kit/chart-kit'
export type FooterSlimLink = {
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 FooterSlimTheme = 'light' | 'dark' | 'system'
export type FooterSlimLabels = {
navigation?: string
theme?: string
light?: string
dark?: string
system?: string
newTab?: string
}
export type FooterSlimProps = Omit<ComponentPropsWithoutRef<'footer'>, 'children'> & {
/** The wordmark or logo, rendered as given. */
brand: ReactNode
/** Inline links: docs, status, privacy. Keep to about five. */
links?: FooterSlimLink[]
/** The name after the © sign. */
owner: string
/** The current theme choice. The switch only shows when `onThemeChange` is also given. */
theme?: FooterSlimTheme
/** Called with the picked theme. Apply it to your app yourself; this footer never touches the DOM. */
onThemeChange?: (theme: FooterSlimTheme) => void
/** Pins the © year and keeps the first render pure. When absent the year appears after mount. */
now?: Date
/** UI strings, for translation. */
labels?: FooterSlimLabels
}
const DEFAULT_LABELS: Required<FooterSlimLabels> = {
navigation: 'Footer',
theme: 'Theme',
light: 'Light',
dark: 'Dark',
system: 'System',
newTab: '(opens in a new tab)',
}
const noopSubscribe = () => () => {}
// null on the server and in the first client render, the real year once mounted.
function useYear(now?: Date) {
const clientYear = useSyncExternalStore(noopSubscribe, () => new Date().getFullYear(), () => null)
return now ? now.getFullYear() : clientYear
}
/** A one-row footer for apps and docs, with a theme switch. */
export function FooterSlim({ brand, links = [], owner, theme, onThemeChange, now, labels, className, ...rest }: FooterSlimProps) {
const l = { ...DEFAULT_LABELS, ...labels }
const year = useYear(now)
const options = [
{ id: 'light', label: l.light },
{ id: 'dark', label: l.dark },
{ id: 'system', label: l.system },
]
return (
<footer {...rest} className={cn('@container w-full border-t border-border bg-background text-foreground', className)}>
<div className="mx-auto flex w-full max-w-6xl flex-wrap items-center justify-center gap-x-6 gap-y-1 px-4 py-1 @min-[56rem]:min-h-14 @min-[56rem]:flex-nowrap @min-[56rem]:justify-between sm:px-6">
<div className="flex min-w-0 flex-wrap items-center justify-center gap-x-6 @min-[56rem]:justify-start">
<div className="inline-flex min-h-11 items-center text-[15px] font-semibold tracking-[-0.02em]" style={{ fontFamily: 'var(--font-display, inherit)' }}>
{brand}
</div>
{links.length > 0 && (
<nav aria-label={l.navigation}>
<ul className="flex flex-wrap justify-center gap-x-1 @min-[56rem]:justify-start">
{links.map((link) => (
<li key={link.href + link.label}>
<a
href={link.href}
{...(link.external ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
className="relative inline-flex min-h-11 items-center rounded-md px-2 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>
</nav>
)}
</div>
<div className="flex flex-wrap items-center justify-center gap-x-6 gap-y-1">
<p className="min-h-11 content-center font-mono text-xs text-muted-foreground tabular-nums">
© {year ?? ''}
{year ? ' ' : ''}
{owner}
</p>
{theme && onThemeChange && (
<Pills
label={l.theme}
options={options}
value={theme}
onChange={(id) => onThemeChange(id as FooterSlimTheme)}
/>
)}
</div>
</div>
</footer>
)
}footer-slim-demo.tsx
import { useState } from 'react'
import { FooterSlim, type FooterSlimTheme } from '@/components/footer-slim'
// Halden Studio is an invented company. Every link goes to example.com.
// The switch is controlled but this demo does not change the page theme: the library's own toggle does that.
export default function FooterSlimDemo() {
const [theme, setTheme] = useState<FooterSlimTheme>('system')
return (
<FooterSlim
brand="Halden Docs"
links={[
{ label: 'Guides', href: 'https://example.com/guides' },
{ label: 'API', href: 'https://example.com/api' },
{ label: 'Status', href: 'https://example.com/status', external: true },
{ label: 'Privacy', href: 'https://example.com/privacy' },
]}
owner="Halden Studio"
theme={theme}
onThemeChange={setTheme}
/>
)
}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-slim.json