Skip to content
Layout

Layout

SiteFooter

Wrapping site footer with a haloed brand dot, mono headings and a legal row.

npx bezel-add add site-footer

packages/ui/src/layout/SiteFooter.tsx · 167 lines

import type { CSSProperties } from "react";

type FooterColumn = {
  heading: string;
  links: { label: string; href: string }[];
};

type Props = {
  brandName?: string;
  tagline?: string;
  columns?: FooterColumn[];
  copyright?: string;
  publishedBy?: { label: string; href: string };
};

/**
 * Pairs are picked with light-dark() from tokens.css, including the focus
 * ring, which has to change colour with the ground or it stops being visible.
 * A host that declares `color-scheme: dark` needs no prop.
 */
const theme = {
  "--sf-ink": "light-dark(var(--bz-ink, #0a0a0a), var(--bz-void-ink, #ffffff))",
  "--sf-muted": "light-dark(var(--bz-ink-muted, #4a4a4c), rgba(255, 255, 255, 0.78))",
  "--sf-subtle": "light-dark(var(--bz-ink-subtle, #6b6b70), rgba(255, 255, 255, 0.66))",
  "--sf-line": "light-dark(rgba(10, 10, 10, 0.1), rgba(255, 255, 255, 0.13))",
  "--sf-accent": "light-dark(var(--bz-emerald, #047857), var(--bz-emerald-on-void, #34d399))",
  "--sf-halo": "light-dark(rgba(4, 120, 87, 0.16), rgba(52, 211, 153, 0.22))",
  "--sf-ring": "light-dark(var(--bz-focus-ring, #912c22), var(--bz-focus-ring-void, #ffffff))",
} as CSSProperties;

/* Colour stays in classes, not the style attribute, or :hover could never win. */
const linkClass =
  "no-underline transition-colors hover:[color:var(--sf-accent)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--sf-ring)]";

export function SiteFooter({
  brandName = "YASH",
  tagline = "",
  columns = [],
  copyright = `© ${new Date().getFullYear()} YASH. All rights reserved.`,
  publishedBy,
}: Props) {
  return (
    <footer
      style={{
        ...theme,
        borderTop: "1px solid var(--sf-line)",
        marginTop: "2.5rem",
        padding: "clamp(2.5rem, 6vw, 4rem) clamp(1rem, 4vw, 1.5rem) 1.75rem",
      }}
    >
      {/* Two levels of wrapping rather than a fixed track count: the brand and
          the link block break apart first, then the columns break among
          themselves. One column and five both land without a breakpoint.
          The gaps are fixed rather than viewport-relative, so where the
          footer breaks depends on the footer's own width and not on the
          window it is being scaled inside. */}
      <div
        className="mx-auto flex flex-wrap"
        style={{ columnGap: "3rem", maxWidth: "1200px", rowGap: "2.5rem" }}
      >
        <div style={{ flex: "1 1 18rem", minWidth: 0 }}>
          <div
            className="flex items-center"
            style={{
              color: "var(--sf-ink)",
              fontSize: "0.9375rem",
              fontWeight: "var(--bz-weight-semibold, 600)",
              gap: "0.75rem",
              letterSpacing: "0.08em",
            }}
          >
            <span
              aria-hidden
              className="shrink-0 rounded-full"
              style={{
                background: "var(--sf-accent)",
                boxShadow: "0 0 0 4px var(--sf-halo)",
                height: "0.5rem",
                width: "0.5rem",
              }}
            />
            {brandName}
          </div>
          {tagline && (
            <p
              className="mb-0"
              style={{
                color: "var(--sf-muted)",
                fontSize: "var(--bz-text-sm, 0.875rem)",
                lineHeight: "var(--bz-leading-normal, 1.55)",
                marginTop: "0.875rem",
                maxWidth: "34ch",
              }}
            >
              {tagline}
            </p>
          )}
        </div>

        {columns.length > 0 && (
          <div
            className="flex flex-wrap"
            style={{ columnGap: "2rem", flex: "2 1 22rem", rowGap: "2rem" }}
          >
            {columns.map((col) => (
              <div key={col.heading} style={{ flex: "1 1 7.5rem", minWidth: "7rem" }}>
                <h4
                  className="m-0 font-mono uppercase"
                  style={{
                    color: "var(--sf-accent)",
                    fontSize: "var(--bz-text-2xs, 0.6875rem)",
                    fontWeight: "var(--bz-weight-normal, 400)",
                    letterSpacing: "0.24em",
                    lineHeight: 1,
                  }}
                >
                  {col.heading}
                </h4>
                <ul className="m-0 list-none p-0" style={{ marginTop: "0.5rem" }}>
                  {col.links.map((l) => (
                    <li key={l.href}>
                      <a
                        href={l.href}
                        className={`${linkClass} [color:var(--sf-ink)]`}
                        style={{
                          display: "inline-block",
                          fontSize: "var(--bz-text-sm, 0.875rem)",
                          lineHeight: 1.5,
                          padding: "0.4375rem 0",
                        }}
                      >
                        {l.label}
                      </a>
                    </li>
                  ))}
                </ul>
              </div>
            ))}
          </div>
        )}
      </div>

      <div
        className="mx-auto flex flex-wrap items-center justify-between"
        style={{
          borderTop: "1px solid var(--sf-line)",
          color: "var(--sf-subtle)",
          fontSize: "var(--bz-text-xs, 0.75rem)",
          gap: "0.75rem",
          marginTop: "2.5rem",
          maxWidth: "1200px",
          paddingTop: "1.375rem",
        }}
      >
        <span>{copyright}</span>
        {publishedBy && (
          <span>
            Published by{" "}
            <a href={publishedBy.href} className={`${linkClass} [color:var(--sf-muted)]`}>
              {publishedBy.label}
            </a>
          </span>
        )}
      </div>
    </footer>
  );
}