Mikkel Manniche
Dansk English Deutsch

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

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

Dependencies

    Files

    • footer-slim.tsx
    • footer-slim.html

    Manniche Lab · open source

    Free tools for AI agents and web development.