{ "$schema": "https://ui.shadcn.com/schema/registry-item.json", "name": "footer", "title": "Footer", "description": "End-of-page contentinfo landmark with acknowledgement of Country, legal links, ownership and social channels, themeable across 13 token-based surface colours.", "dependencies": [ "class-variance-authority", "clsx", "tailwind-merge" ], "registryDependencies": [ "https://ui.digital.nsw.gov.au/registry/r/theme.json", "https://ui.digital.nsw.gov.au/registry/r/button.json", "https://ui.digital.nsw.gov.au/registry/r/link.json", "https://ui.digital.nsw.gov.au/registry/r/separator.json" ], "files": [ { "path": "src/components/footer.tsx", "content": "import { cva, type VariantProps } from 'class-variance-authority'\nimport React from 'react'\n\nimport { cn } from '@/lib/utils'\n\nimport { ButtonLink, type IconSlot } from '@/components/button'\nimport { Link } from '@/components/link'\nimport { Separator } from '@/components/separator'\n\n/**\n * Surface colours the footer can be themed with. Each one names its LIGHT-mode\n * surface; dark mode deepens it onto the same family's dark steps (see the\n * `color` variants below), so `color` expresses tonal weight rather than a\n * literal colour.\n *\n * Every light pair clears WCAG 2.2 AA (1.4.3, 4.5:1) for the footer's smallest\n * text and 11 of the 13 also clear AAA (1.4.6, 7:1). The two AA-only pairs are\n * `primary-600` (4.57:1) and `accent-600` (5.18:1), both with white ink; prefer\n * the `-800` steps when a service is held to AAA. In dark mode all thirteen\n * clear AAA (worst 13.6:1).\n *\n * Unlike `Masthead` — which is restricted to verified AAA pairs because it is\n * legally-mandated identification — the footer carries supporting content, so\n * the full tonal range is offered here.\n */\nconst footerColors = [\n 'primary-800',\n 'primary-600',\n 'primary-400',\n 'primary-200',\n 'grey-800',\n 'grey-600',\n 'grey-400',\n 'grey-200',\n 'accent-800',\n 'accent-600',\n 'accent-400',\n 'accent-200',\n 'white',\n] as const\n\ntype FooterColor = (typeof footerColors)[number]\n\n// Shared by cva's defaultVariants and the data-color attribute, so the two\n// can't drift apart.\nconst DEFAULT_FOOTER_COLOR: FooterColor = 'white'\n\nconst footerVariants = cva(\n [\n 'w-full',\n // Every derived colour in the footer resolves from a single --footer-ink\n // token, so a colour variant only has to declare the surface utilities and\n // that one value. The derivations are written literally here (once) rather\n // than per-variant: Tailwind scans source text for class names, so a\n // template-built arbitrary property would never be emitted.\n //\n // Mirrors the color-mix derivation in link.tsx (--link-halo).\n '[--footer-border:color-mix(in_oklch,var(--footer-ink)_15%,transparent)]',\n '[--footer-halo:color-mix(in_oklch,var(--footer-ink)_10%,transparent)]',\n '[--footer-halo-active:color-mix(in_oklch,var(--footer-ink)_18%,transparent)]',\n ],\n {\n variants: {\n // Ink values use the RAW masterbrand tokens (--primary-800, --grey-800,\n // --accent-800) rather than Tailwind's --color-* bridge aliases. Tailwind\n // v4 tree-shakes an unreferenced @theme key, and referencing one from\n // inside an arbitrary property is not a usage signal — the raw tokens are\n // plain :root declarations from @nswds/tokens and always resolve.\n // (--color-white is safe: `text-white` / `bg-white` below are real\n // utilities, so that key is always emitted.)\n //\n // Note: a bracketed example in a comment here would itself be scanned by\n // Tailwind and emitted as a dead rule — keep such examples out of prose.\n // Dark mode deepens every surface onto the same family's dark steps —\n // -800→-950, -600→-900, -400→-850, -200→-800, white→grey-900 — so the\n // luminance ORDER within a family is preserved: the variant named for the\n // heaviest tone stays the heaviest. Ink goes white on all thirteen, and\n // every dark pair is WCAG 2.2 AAA (worst 13.6:1) — better than light\n // mode, where primary-600 and accent-600 are AA-only.\n color: {\n 'primary-800':\n 'bg-primary-800 text-white [--footer-ink:var(--color-white)] dark:bg-primary-950',\n 'primary-600':\n 'bg-primary-600 text-white [--footer-ink:var(--color-white)] dark:bg-primary-900',\n 'primary-400':\n 'bg-primary-400 text-primary-800 [--footer-ink:var(--primary-800)] dark:bg-primary-850 dark:text-white dark:[--footer-ink:var(--color-white)]',\n 'primary-200':\n 'bg-primary-200 text-primary-800 [--footer-ink:var(--primary-800)] dark:bg-primary-800 dark:text-white dark:[--footer-ink:var(--color-white)]',\n 'grey-800': 'bg-grey-800 text-white [--footer-ink:var(--color-white)] dark:bg-grey-950',\n 'grey-600': 'bg-grey-600 text-white [--footer-ink:var(--color-white)] dark:bg-grey-900',\n 'grey-400':\n 'bg-grey-400 text-grey-800 [--footer-ink:var(--grey-800)] dark:bg-grey-850 dark:text-white dark:[--footer-ink:var(--color-white)]',\n 'grey-200':\n 'bg-grey-200 text-grey-800 [--footer-ink:var(--grey-800)] dark:bg-grey-800 dark:text-white dark:[--footer-ink:var(--color-white)]',\n 'accent-800':\n 'bg-accent-800 text-white [--footer-ink:var(--color-white)] dark:bg-accent-950',\n 'accent-600':\n 'bg-accent-600 text-white [--footer-ink:var(--color-white)] dark:bg-accent-900',\n 'accent-400':\n 'bg-accent-400 text-accent-800 [--footer-ink:var(--accent-800)] dark:bg-accent-850 dark:text-white dark:[--footer-ink:var(--color-white)]',\n 'accent-200':\n 'bg-accent-200 text-accent-800 [--footer-ink:var(--accent-800)] dark:bg-accent-800 dark:text-white dark:[--footer-ink:var(--color-white)]',\n white:\n 'bg-white text-grey-800 [--footer-ink:var(--grey-800)] dark:bg-grey-900 dark:text-white dark:[--footer-ink:var(--color-white)]',\n },\n },\n defaultVariants: {\n color: DEFAULT_FOOTER_COLOR,\n },\n },\n)\n\nconst footerContainerVariants = cva(\n [\n 'mx-auto flex w-full flex-col max-sm:py-6 sm:py-8',\n // Lateral padding funnels through --footer-padding-x so an app can retune\n // it once (via the `style` prop or a utility class) without a new variant.\n // Same mechanism, and the same 16px → 24px → 48px rhythm, as the Masthead.\n 'px-(--footer-padding-x)',\n '[--footer-padding-x:--spacing(4)] sm:[--footer-padding-x:--spacing(6)] lg:[--footer-padding-x:--spacing(12)]',\n ],\n {\n variants: {\n container: {\n // Full-bleed (nswds-app parity). --footer-max-width is still read so a\n // shell app can constrain the inner wrapper without switching variants.\n fluid: 'max-w-[var(--footer-max-width,none)]',\n // Centred content column, legacy nsw-container parity (1200px).\n contained: 'max-w-[var(--footer-max-width,75rem)]',\n },\n },\n defaultVariants: {\n container: 'fluid',\n },\n },\n)\n\n/** A link rendered anywhere in the footer — legal row or site-map column. */\ntype FooterLinkItem = {\n /** Visible link text, e.g. \"Privacy\". */\n name: string\n href: string\n}\n\n/** Alias kept for the legal-links context, where the name reads better. */\ntype FooterLegalLinkItem = FooterLinkItem\n\n/** A social channel rendered as an icon-only link in the small print row. */\ntype FooterSocialLinkItem = {\n /** Channel name, e.g. \"LinkedIn\". Used to build the accessible name. */\n name: string\n href: string\n /**\n * Icon for the channel — the component (`IconLinkedIn`) or an element\n * (``). Brand marks (LinkedIn, X, Facebook…) are not part of\n * the NSWDS icon set — which is Material Symbols — so the mark is supplied by\n * the consuming app. It is rendered through `ButtonLink`'s `leadingVisual`\n * slot, which sizes and colours it.\n *\n * `Footer` is server-compatible but `ButtonLink` is not, so a React Server\n * Component assembling `socialLinks` must use the element form; the component\n * form cannot cross the RSC boundary. See `IconSlot`.\n */\n icon: IconSlot\n /**\n * Accessible name for the link. Defaults to `Follow us on {name}`.\n */\n label?: string\n}\n\n// One link treatment for the whole footer — legal row and site-map columns\n// alike — so a consumer never has to reason about which kind they are styling.\nconst footerLinkClassName = [\n // Negative margin + padding keeps the hover/focus halo from shifting layout.\n '-m-1 rounded-sm p-1 text-(--footer-ink)',\n 'underline decoration-current underline-offset-4',\n 'motion-safe:transition-colors',\n 'hover:bg-(--footer-halo) hover:decoration-2',\n 'active:bg-(--footer-halo-active) active:decoration-2',\n // outline-(--footer-ink) guarantees the indicator contrasts with the surface\n // on every colour variant (2.4.13 Focus Appearance).\n 'focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-(--footer-ink)',\n].join(' ')\n\n// Repoints ButtonLink's ghost-variant tokens at the footer's ink and halos so\n// the social buttons follow the surface colour instead of the button palette.\n// The `dark:` repeat is load-bearing, not redundant: `styles.colors` in\n// button.tsx gives each colour a `dark:[--btn-bg:…]` ink counterpart, which\n// would otherwise outrank the bare declaration here and repaint these glyphs in\n// the palette's ink. Restating it under the same variant makes the two\n// equal-specificity, and tailwind-merge keeps this one because the component's\n// className is merged last. `--footer-ink` already resolves per theme.\nconst footerSocialLinkClassName = [\n '[--btn-bg:var(--footer-ink)] dark:[--btn-bg:var(--footer-ink)]',\n '[--btn-icon:var(--footer-ink)]',\n '[--btn-transparent:transparent]',\n '[--btn-hover-overlay:var(--footer-halo)]',\n '[--btn-active-overlay:var(--footer-halo-active)]',\n].join(' ')\n\ntype FooterSocialLinkProps = Omit<\n React.ComponentPropsWithoutRef,\n 'children' | 'color' | 'leadingVisual' | 'size' | 'variant'\n> & {\n /** Channel mark — component or element. See `IconSlot`. */\n icon: IconSlot\n /** Accessible name, e.g. \"Follow us on LinkedIn\". */\n label: string\n}\n\n/**\n * Icon-only link to a social channel. Renders through `ButtonLink`, so it\n * picks up the framework link component from `LinkProvider` and the 44px touch\n * target. The label is supplied as `aria-label` — there is no visible text.\n */\nfunction FooterSocialLink({ className, icon, label, ...props }: FooterSocialLinkProps) {\n return (\n \n )\n}\n\ntype FooterAcknowledgementProps = React.ComponentPropsWithoutRef<'div'> & {\n ref?: React.Ref\n}\n\n/**\n * Acknowledgement of Country. Renders the standard NSW Government wording\n * unless `children` are supplied — pass your own text when an agency has\n * agreed different wording with the relevant Traditional Custodians.\n */\nfunction FooterAcknowledgement({ className, children, ref, ...props }: FooterAcknowledgementProps) {\n return (\n \n

\n {children ??\n 'We acknowledge the Traditional Custodians of the land on which we work and live, and pay our respects to Elders past, present and emerging.'}\n

\n \n )\n}\n\ntype FooterLegalLinksProps = Omit, 'children'> & {\n legalLinks: FooterLegalLinkItem[]\n ref?: React.Ref\n}\n\n/**\n * Navigation landmark holding the footer's supporting/legal links. Renders\n * nothing when `legalLinks` is empty, so an empty landmark is never exposed to\n * assistive tech.\n */\nfunction FooterLegalLinks({\n className,\n legalLinks,\n 'aria-label': ariaLabel = 'Footer',\n ref,\n ...props\n}: FooterLegalLinksProps) {\n if (legalLinks.length === 0) {\n return null\n }\n\n return (\n \n {legalLinks.map((item) => (\n // variant='unstyled' — the footer supplies its own link treatment,\n // derived from --footer-ink so it tracks the surface colour. Link's own\n // variants are pinned to the primary palette (and flip in dark mode),\n // which would break on a coloured footer.\n \n {item.name}\n \n ))}\n \n )\n}\n\ntype FooterNavLinkProps = Omit, 'variant'>\n\n/**\n * A single site-map link, carrying the same ink-derived treatment as the legal\n * row. Exposed so a bespoke column layout (a mobile accordion, say) can match\n * the built-in columns without copying class strings.\n */\nfunction FooterNavLink({ className, ...props }: FooterNavLinkProps) {\n return (\n \n )\n}\n\ntype FooterNavProps = React.ComponentPropsWithoutRef<'nav'> & {\n ref?: React.Ref\n}\n\n/**\n * Navigation landmark wrapping the footer's site-map columns.\n *\n * One landmark for the whole site map rather than one per column: a large\n * footer would otherwise add five nav landmarks to the page, burying the ones\n * that matter when a screen-reader user lists them. Columns are headed lists\n * inside it. The default four-column grid is overridable — `cn` lets a\n * `lg:grid-cols-3` on `className` win.\n */\nfunction FooterNav({\n className,\n 'aria-label': ariaLabel = 'Site map',\n ref,\n ...props\n}: FooterNavProps) {\n return (\n \n )\n}\n\ntype FooterNavColumnProps = Omit, 'children'> & {\n heading: React.ReactNode\n links: FooterLinkItem[]\n /**\n * Heading level for the column title. Defaults to `2` — correct when the\n * footer sits at the top level of the document outline. Step it down if the\n * surrounding page nests the footer under another heading, so the outline\n * stays in order (WCAG 1.3.1).\n *\n * The two bounds are not symmetric. `6` is simply the deepest heading HTML\n * defines. `1` is excluded on purpose: a site-map column title is never the\n * page's own title, so an `h1` here would be the very outline error the prop\n * exists to avoid.\n */\n headingLevel?: 2 | 3 | 4 | 5 | 6\n ref?: React.Ref\n}\n\n/** A headed column of site-map links. Sits inside `FooterNav`. */\nfunction FooterNavColumn({\n className,\n heading,\n links,\n headingLevel = 2,\n ref,\n ...props\n}: FooterNavColumnProps) {\n const Heading = `h${headingLevel}` as const\n\n return (\n \n {heading}\n
    \n {links.map((item) => (\n
  • \n {item.name}\n
  • \n ))}\n
\n \n )\n}\n\ntype FooterSmallPrintProps = React.ComponentPropsWithoutRef<'div'> & {\n /** Owning agency, e.g. \"Digital NSW, Department of Customer Service\". */\n department?: string\n socialLinks?: FooterSocialLinkItem[]\n /**\n * Copyright year. Defaults to the current year read at render time — which\n * on a server-rendered page is the *server's* year, so a request that\n * straddles new year (or a server in a different timezone to the audience)\n * can hydrate with a mismatch. Pass an explicit year to pin it.\n */\n year?: number\n ref?: React.Ref\n}\n\n/**\n * Copyright line and social channels. `children` replace the generated\n * copyright sentence when an agency needs different wording.\n */\nfunction FooterSmallPrint({\n className,\n department,\n socialLinks,\n year = new Date().getFullYear(),\n children,\n ref,\n ...props\n}: FooterSmallPrintProps) {\n return (\n \n

\n {children ?? (\n <>\n © Copyright {year}\n {department ? ` ${department}` : ''}. All rights reserved.\n \n )}\n

\n {socialLinks && socialLinks.length > 0 && (\n
    \n {socialLinks.map((item) => (\n
  • \n \n
  • \n ))}\n
\n )}\n \n )\n}\n\ntype FooterProps = React.ComponentPropsWithoutRef<'footer'> &\n VariantProps &\n VariantProps & {\n /** Supporting/legal links — privacy, accessibility, copyright, etc. */\n legalLinks?: FooterLegalLinkItem[]\n /** Social channels rendered as icon-only links beside the copyright line. */\n socialLinks?: FooterSocialLinkItem[]\n /** Owning agency, named in the copyright line. */\n department?: string\n /**\n * Acknowledgement of Country. `true` (default) renders the standard\n * wording, `false` omits it, and a node replaces the wording.\n */\n acknowledgement?: boolean | React.ReactNode\n /**\n * Copyright line and social channels. Defaults to `true` — the copyright\n * line renders even with no `department` to name, since it is the footer's\n * core content.\n */\n smallPrint?: boolean\n /** Rule along the top edge of the footer content. Defaults to `true`. */\n topBorder?: boolean\n /** Copyright year. See `FooterSmallPrint` for the SSR caveat. */\n year?: number\n /** Classes applied to the inner width-constraining wrapper. */\n containerClassName?: string\n /**\n * Extra content rendered above the acknowledgement — a logo, contact\n * details, or link columns.\n */\n children?: React.ReactNode\n ref?: React.Ref\n }\n\n/**\n * End-of-page `contentinfo` landmark: acknowledgement of Country, supporting\n * links, ownership and social channels. Render it once in a shared layout so\n * the same content appears on every page.\n *\n * - `color` themes the surface and names its light-mode tone; dark mode deepens\n * it onto the same family's dark steps. Every option meets WCAG 2.2 AA in\n * light mode and AAA in dark (see `footerColors`). All link, border and hover\n * colours derive from the surface's ink, so they follow automatically in both\n * themes.\n * - `container` selects the inner wrapper layout: `fluid` (full-bleed,\n * nswds-app parity) or `contained` (centred 1200px column, legacy\n * `nsw-container` parity). Fine-tune either with the `--footer-max-width`\n * and `--footer-padding-x` custom properties.\n * - Each section is independently omissible: `acknowledgement={false}`,\n * `smallPrint={false}`, or simply pass no `legalLinks` (an empty nav\n * landmark is never rendered). The rule between the acknowledgement and the\n * link row appears only when there is content on both sides of it.\n *\n * For a layout this does not cover, compose `FooterAcknowledgement`,\n * `FooterLegalLinks`, `FooterSmallPrint` and `FooterSocialLink` directly\n * inside your own `\n )\n}\n\nexport {\n Footer,\n FooterAcknowledgement,\n footerColors,\n footerContainerVariants,\n FooterLegalLinks,\n FooterNav,\n FooterNavColumn,\n FooterNavLink,\n FooterSmallPrint,\n FooterSocialLink,\n footerVariants,\n}\nexport type {\n FooterAcknowledgementProps,\n FooterColor,\n FooterLegalLinkItem,\n FooterLegalLinksProps,\n FooterLinkItem,\n FooterNavColumnProps,\n FooterNavLinkProps,\n FooterNavProps,\n FooterProps,\n FooterSmallPrintProps,\n FooterSocialLinkItem,\n FooterSocialLinkProps,\n}\n", "type": "registry:ui", "target": "components/footer.tsx" }, { "path": "src/lib/utils.ts", "content": "import { clsx, type ClassValue } from 'clsx'\nimport { twMerge } from 'tailwind-merge'\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs))\n}\n", "type": "registry:lib", "target": "lib/utils.ts" } ], "type": "registry:ui", "meta": { "nswdsVersion": "5.1.0" } }