Skip to content
Marketing block

Regulated Seller Footer

Legal entity, company number, registered address, VAT and the consumer links an EU or UK seller has to publish — with a region control that changes the terms, not just the currency.

287 lineslucide-reactAdded 27 Aug 2026Updated 2 Sept 2026
  • footer
  • legal
  • compliance
  • gdpr
  • imprint

What's included

  • components/footer-compliance.tsx
  • Needs lucide-react

Works with

  • React
  • Next.js
  • Tailwind CSS
  • TypeScript

npx hoverlab add footer-compliance

Or over MCP, from your editor's agent — no account needed.

License

Free to read, copy and install, for personal and non-commercial projects. Shipping it in client work or a paid product needs Pro ($79 once). The source lands in your repo and stops being ours — no attribution, nothing to upgrade.

Was this useful?

Start a page with this section — add more, order them, and leave with the page source.

Preview

Rendered live in your current theme — this is the same component whose source is below, not a screenshot of it.

Source

components/footer-compliance.tsx
'use client'

/**
 * <FooterCompliance> — the footer a lawyer asks for and a template never has.
 *
 * Footers already had the mega map, the minimal line, the newsletter
 * capture and the signed-in status bar. Every one of them treats the
 * footer as navigation. For anyone selling into the EU or the UK it is
 * also a legal surface, and the gap is normally found the week a payment
 * processor reviews the account or a regulator writes in.
 *
 * WHAT IS ACTUALLY REQUIRED, AND WHY IT IS HERE
 *
 * Trading name is not enough — the registered legal entity, its company
 * number and its registered address are the identification an EU or UK
 * seller has to publish (the Impressum requirement in Germany is the
 * strictest version of a rule that exists nearly everywhere). VAT number
 * where the seller is registered. A complaints route, and in the EU a link
 * to the ODR platform. An accessibility statement, which the European
 * Accessibility Act has made a live obligation for consumer services since
 * June 2025 — and which nobody can write for you, so it is a real page,
 * not a footer word.
 *
 * COOKIE SETTINGS ARE A LINK, NOT A ONE-TIME BANNER
 *
 * Consent has to be as easy to withdraw as it was to give. A banner that
 * appears once and is never reachable again fails that, and the footer is
 * the conventional place to put the way back. So this is a button that
 * reopens the preferences, sitting in the legal row rather than hidden in
 * a policy page.
 *
 * THE REGION CONTROL CHANGES THE TEXT, NOT JUST THE PRICES
 *
 * Statutory rights differ, so the entity, the tax line and the consumer
 * links differ with them. A footer that swaps a currency and keeps one
 * country's legal copy is worse than one that never localised at all: it
 * is now making a specific claim to the wrong audience.
 *
 * PAYMENT MARKS ARE INFORMATION, NOT DECORATION
 *
 * Drawn as text, so they render without an asset, survive dark mode and
 * are readable to a screen reader. Card-brand logos also carry usage rules
 * — being unable to ship the SVG is a common reason this row silently
 * never gets built.
 *
 * ACCESSIBILITY: `<address>` for the registered address, a labelled
 * `<nav>` for the legal links so it is distinguishable from the product
 * nav above it, and no link whose text is "here" or "learn more".
 */

import * as React from 'react'
import { Building2, Cookie, Scale } from 'lucide-react'

export interface ComplianceRegion {
  id: string
  label: string
  entity: string
  registration: string
  address: string[]
  taxLine: string
  /** Region-specific obligations, added to the shared legal links. */
  extraLinks: { label: string; href: string }[]
}

export interface FooterComplianceProps {
  brand?: string
  regions?: ComplianceRegion[]
  paymentMethods?: string[]
  className?: string
}

const DEFAULT_REGIONS: ComplianceRegion[] = [
  {
    id: 'uk',
    label: 'United Kingdom',
    entity: 'Northwind Trading Ltd',
    registration: 'Registered in England and Wales, company no. 09482013',
    address: ['4th Floor, 12 Ridgemount Street', 'London WC1E 7AE', 'United Kingdom'],
    taxLine: 'VAT registration GB 284 9271 05. Prices include VAT where applicable.',
    extraLinks: [
      { label: 'Cancellation and returns', href: '#returns' },
      { label: 'Modern slavery statement', href: '#modern-slavery' },
    ],
  },
  {
    id: 'eu',
    label: 'European Union',
    entity: 'Northwind Trading B.V.',
    registration: 'Registered with the Dutch KvK, no. 74920185',
    address: ['Keizersgracht 62', '1015 CS Amsterdam', 'Netherlands'],
    taxLine: 'VAT registration NL 8601 42 917 B01. Prices include VAT at your local rate.',
    extraLinks: [
      { label: 'Right of withdrawal', href: '#withdrawal' },
      { label: 'Online dispute resolution', href: '#odr' },
      { label: 'Imprint', href: '#imprint' },
    ],
  },
  {
    id: 'us',
    label: 'United States',
    entity: 'Northwind Trading Inc.',
    registration: 'Incorporated in Delaware, file no. 7194482',
    address: ['201 Mission Street, Suite 1200', 'San Francisco, CA 94105', 'United States'],
    taxLine: 'Sales tax is calculated at checkout where we are registered to collect it.',
    extraLinks: [
      { label: 'Do not sell or share my information', href: '#ccpa' },
      { label: 'State privacy rights', href: '#state-privacy' },
    ],
  },
]

/* Shared obligations — the ones that do not change with the region. */
const CORE_LINKS = [
  { label: 'Terms of service', href: '#terms' },
  { label: 'Privacy policy', href: '#privacy' },
  { label: 'Accessibility statement', href: '#accessibility' },
  { label: 'Security', href: '#security' },
  { label: 'Complaints', href: '#complaints' },
]

const DEFAULT_PAYMENTS = ['Visa', 'Mastercard', 'Amex', 'PayPal', 'Apple Pay', 'SEPA']

export function FooterCompliance({
  brand = 'Northwind',
  regions = DEFAULT_REGIONS,
  paymentMethods = DEFAULT_PAYMENTS,
  className = '',
}: FooterComplianceProps) {
  const [regionId, setRegionId] = React.useState(regions[0].id)
  const region = regions.find((r) => r.id === regionId) ?? regions[0]
  const [cookiesReopened, setCookiesReopened] = React.useState(false)

  /**
   * Per-instance id for the region select.
   *
   * It was the literal `"compliance-region"`, which is correct until a
   * second <FooterCompliance> exists on the page — a catalog hub, or a
   * comparison of two regional sites. `<label htmlFor>` then binds to
   * whichever select comes first, so clicking the second one's label focuses
   * the first one's control and a screen reader announces the wrong region.
   */
  const selectId = `${React.useId()}-region`

  return (
    <footer className={`w-full border-t border-border bg-card ${className}`}>
      <div className="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6">
        <div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
          {/* ---- Who you are actually contracting with ---------------- */}
          <div className="lg:col-span-2">
            <div className="flex items-center gap-2">
              <span
                aria-hidden
                className="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground"
              >
                {brand.charAt(0)}
              </span>
              <span className="text-sm font-semibold text-foreground">{brand}</span>
            </div>

            <p className="mt-4 flex items-start gap-2 text-sm font-medium text-foreground">
              <Building2 aria-hidden className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
              {region.entity}
            </p>
            <p className="mt-1 ps-6 text-xs text-muted-foreground">{region.registration}</p>
            {/* A real <address>, not a stack of divs. */}
            <address className="mt-2 ps-6 text-xs not-italic leading-relaxed text-muted-foreground">
              {/* Keyed by position: an address is a fixed list of display
                  lines, and two of them can legitimately read the same (or be
                  blank). Keying by content drops the duplicate. */}
              {region.address.map((line, i) => (
                <span key={i} className="block">
                  {line}
                </span>
              ))}
            </address>
            <p className="mt-3 ps-6 text-xs text-muted-foreground">{region.taxLine}</p>
          </div>

          {/* ---- Legal links, kept apart from the product nav --------- */}
          <nav aria-label="Legal and policies">
            <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
              Legal
            </h2>
            <ul className="mt-3 space-y-2">
              {[...CORE_LINKS, ...region.extraLinks].map((link) => (
                <li key={link.label}>
                  <a
                    href={link.href}
                    className="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
                  >
                    {link.label}
                  </a>
                </li>
              ))}
            </ul>
          </nav>

          <div>
            <h2 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
              Region
            </h2>
            <label htmlFor={selectId} className="sr-only">
              Choose the region whose terms apply to you
            </label>
            <select
              id={selectId}
              value={regionId}
              onChange={(event) => setRegionId(event.target.value)}
              className="mt-3 h-9 w-full rounded-lg border border-field bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
            >
              {regions.map((r) => (
                <option key={r.id} value={r.id}>
                  {r.label}
                </option>
              ))}
            </select>
            {/*
              Says out loud that this changed the terms, not the currency.
            */}
            <p role="status" className="mt-2 text-xs text-muted-foreground">
              Showing the entity, tax registration and consumer rights that
              apply in {region.label}.
            </p>

            <h2 className="mt-6 text-xs font-semibold uppercase tracking-wide text-muted-foreground">
              We accept
            </h2>
            <ul className="mt-3 flex flex-wrap gap-1.5">
              {paymentMethods.map((method) => (
                <li
                  key={method}
                  className="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground"
                >
                  {method}
                </li>
              ))}
            </ul>
          </div>
        </div>

        <div className="mt-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-border pt-6">
          {/*
            `suppressHydrationWarning` because the year is read from a clock
            and this is a client component, so it is read twice — once in
            Node and once in the browser. For a few hours every New Year's
            Eve those two are in different years, and without this React
            treats the copyright line as a torn tree and rebuilds the
            footer. The server's year is the one kept, which for a
            copyright notice is the harmless side of the trade.
          */}
          <p className="text-xs text-muted-foreground" suppressHydrationWarning>
            © {new Date().getFullYear()} {region.entity}. All rights reserved.
          </p>

          {/*
            The way back into consent. Withdrawing has to be as easy as
            giving, and a banner that never returns does not clear that bar.
          */}
          <button
            type="button"
            onClick={() => setCookiesReopened(true)}
            className="inline-flex items-center gap-1.5 rounded text-xs text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <Cookie aria-hidden className="h-3.5 w-3.5" />
            Cookie settings
          </button>

          <a
            href="#dsa"
            className="inline-flex items-center gap-1.5 rounded text-xs text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"
          >
            <Scale aria-hidden className="h-3.5 w-3.5" />
            Report illegal content
          </a>

          {cookiesReopened ? (
            <p role="status" className="w-full text-xs text-muted-foreground">
              Cookie preferences reopened — this link is how consent gets
              withdrawn later, so it stays on every page.
            </p>
          ) : null}
        </div>
      </div>
    </footer>
  )
}

Before you paste

  • Styling is Tailwind utility classes on semantic tokens (bg-card, text-muted-foreground) — it inherits your theme instead of overriding it.
  • Install: npm i lucide-react
  • Every prop has a default, so it renders standalone before you wire it up.

Where it goes

Drop it at components/footer-compliance.tsx and import it where you need the section:

import { FooterCompliance } from '@/components/footer-compliance'

Customize

1 of this block’s props are simple enough to drive from here. Change them and the block below re-renders — it is the same component whose source is above, not a mock of it. Everything else it accepts is in the table underneath.

Props

Read out of the component’s own type and signature, so this cannot drift from the source below. Every prop has a default — the component renders standalone before you pass it anything.

PropTypeDefault
brandstring'Northwind'
regionsComplianceRegion[]DEFAULT_REGIONS
paymentMethodsstring[]DEFAULT_PAYMENTS
classNamestring''

Not using React?

The same block rendered once to markup, wrapped as a file your framework compiles. Tailwind classes are framework-agnostic, so the design transfers intact — the behaviour does not.

This block is interactive. The markup below is its initial state with the event handlers stripped — you will need to re-wire the behaviour in your framework.

footer-compliance.html
<!--
  Regulated Seller Footer — markup from the Hoverlab catalog.

  This is the block rendered once to HTML and wrapped as a component
  file. It is not a port of the React source: the Tailwind classes carry
  the design, which is the part that took the work, and they are the same
  in every framework.

  This block is interactive in React and the handlers are NOT here.
  Buttons, toggles and menus render in their initial state and do
  nothing until you wire them up.
-->
<footer class="w-full border-t border-border bg-card ">
  <div class="mx-auto w-full max-w-6xl px-4 py-10 sm:px-6">
    <div class="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
      <div class="lg:col-span-2">
        <div class="flex items-center gap-2">
          <span aria-hidden="true" class="flex h-7 w-7 items-center justify-center rounded-lg bg-primary text-sm font-bold text-primary-foreground">N</span>
          <span class="text-sm font-semibold text-foreground">Northwind</span>
        </div>
        <p class="mt-4 flex items-start gap-2 text-sm font-medium text-foreground"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-building2 lucide-building-2 mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" aria-hidden="true"><path d="M6 22V4a2 2 0 0 1 2-2h8a2 2 0 0 1 2 2v18Z"></path><path d="M6 12H4a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2h2"></path><path d="M18 9h2a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-2"></path><path d="M10 6h4"></path><path d="M10 10h4"></path><path d="M10 14h4"></path><path d="M10 18h4"></path></svg>Northwind Trading Ltd</p>
        <p class="mt-1 ps-6 text-xs text-muted-foreground">Registered in England and Wales, company no. 09482013</p>
        <address class="mt-2 ps-6 text-xs not-italic leading-relaxed text-muted-foreground">
          <span class="block">4th Floor, 12 Ridgemount Street</span>
          <span class="block">London WC1E 7AE</span>
          <span class="block">United Kingdom</span>
        </address>
        <p class="mt-3 ps-6 text-xs text-muted-foreground">VAT registration GB 284 9271 05. Prices include VAT where applicable.</p>
      </div>
      <nav aria-label="Legal and policies">
        <h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Legal</h2>
        <ul class="mt-3 space-y-2">
          <li>
            <a href="#terms" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Terms of service</a>
          </li>
          <li>
            <a href="#privacy" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Privacy policy</a>
          </li>
          <li>
            <a href="#accessibility" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Accessibility statement</a>
          </li>
          <li>
            <a href="#security" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Security</a>
          </li>
          <li>
            <a href="#complaints" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Complaints</a>
          </li>
          <li>
            <a href="#returns" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Cancellation and returns</a>
          </li>
          <li>
            <a href="#modern-slavery" class="rounded text-sm text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">Modern slavery statement</a>
          </li>
        </ul>
      </nav>
      <div>
        <h2 class="text-xs font-semibold uppercase tracking-wide text-muted-foreground">Region</h2>
        <label for="_R_0_-region" class="sr-only">Choose the region whose terms apply to you</label>
        <select id="_R_0_-region" class="mt-3 h-9 w-full rounded-lg border border-field bg-background px-2.5 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background">
          <option value="uk" selected="">United Kingdom</option>
          <option value="eu">European Union</option>
          <option value="us">United States</option>
        </select>
        <p role="status" class="mt-2 text-xs text-muted-foreground">Showing the entity, tax registration and consumer rights that apply in United Kingdom.</p>
        <h2 class="mt-6 text-xs font-semibold uppercase tracking-wide text-muted-foreground">We accept</h2>
        <ul class="mt-3 flex flex-wrap gap-1.5">
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">Visa</li>
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">Mastercard</li>
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">Amex</li>
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">PayPal</li>
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">Apple Pay</li>
          <li class="rounded-md border border-border bg-background px-2 py-1 text-[11px] font-medium text-muted-foreground">SEPA</li>
        </ul>
      </div>
    </div>
    <div class="mt-8 flex flex-wrap items-center gap-x-4 gap-y-2 border-t border-border pt-6">
      <p class="text-xs text-muted-foreground">© 2026 Northwind Trading Ltd. All rights reserved.</p>
      <button type="button" class="inline-flex items-center gap-1.5 rounded text-xs text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-cookie h-3.5 w-3.5" aria-hidden="true"><path d="M12 2a10 10 0 1 0 10 10 4 4 0 0 1-5-5 4 4 0 0 1-5-5"></path><path d="M8.5 8.5v.01"></path><path d="M16 15.5v.01"></path><path d="M12 12v.01"></path><path d="M11 17v.01"></path><path d="M7 14v.01"></path></svg>Cookie settings</button>
      <a href="#dsa" class="inline-flex items-center gap-1.5 rounded text-xs text-foreground underline-offset-4 transition hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background"><svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-scale h-3.5 w-3.5" aria-hidden="true"><path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"></path><path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z"></path><path d="M7 21h10"></path><path d="M12 3v18"></path><path d="M3 7h2c2 0 5-1 7-2 2 1 5 2 7 2h2"></path></svg>Report illegal content</a>
    </div>
  </div>
</footer>
  • This is rendered HTML, not a translation of the React source. The Tailwind classes carry the design and work in any framework.
  • It is one frame: the component in its initial state, with no props applied beyond the defaults.
  • This block is interactive in React — toggles, menus or form state. None of that survives here; the markup is the closed/default state and the handlers are gone. Re-wire them in your own framework.
  • Requires Tailwind, and the design tokens the classes reference (bg-card, text-muted-foreground, and so on). The template ZIPs ship a globals.css that defines them.

What each framework gets across the whole catalog — effects convert properly; this rung is markup.

For AI

The component, its props, the design tokens it expects and the command that installs it — as one prompt. Paste it into Claude, Cursor, v0 or ChatGPT and what they build around it will match the rest of the catalog instead of inventing its own system.

See the prompt

Used in these pages

Want the whole screen instead of this one section? Open a page and copy it entire.

4 more blocks in Footers

All of them free to read, copy and install — no account, no locked tiles, no watermarked preview. The whole catalog is open, and so are the API and the CLI.

Browse Footers

Shipping one commercially

Copying the code is free. Putting it in client work or a paid product is what Pro is for — the licence, not the access.

  • A commercial licence for everything in the catalog
  • Unlimited bundle exports, in Vue, Svelte and Tailwind
  • One payment — no subscription, nothing to renew
Pro — $79 once

More Footers blocks

View category
Open the full page for this block

Mega Footer with Link Columns

Brand column, four labelled nav columns, socials and a legal bar — the one place every page links to every section. Ships no client JavaScript.

Footers265 lines1 dep
Open the full page for this block

Footer with Newsletter Band

A raised email-capture band overlapping a three-column link footer, with the confirmation replacing the field in place rather than navigating away.

Footers260 lines1 dep