← alle entries

newsletter-footer

FC-0141Footersfooter-newsletterblockv1.0.0

Vier kolommen: inschrijfblok met grote kop, tekst en e-mailveld met een ronde verzendknop ín het veld (zachte gloed in de primaire kleur erachter), snelle links, contactgegevens en 'volg ons' met ronde sociale knoppen plus ruimte voor een extra bediening; onderaan copyright en juridische links. Componeert field; de inschrijving gaat via onSubscribe naar het project.

zelfde component — andere tokens

Vier kolommen: inschrijven (field met de verzendknop ín het veld; de knop wacht op onSubscribe en toont dan een vinkje), snelle links, contact en volgen.

blocks/footers/newsletter-footer.tsx
/**
 * NewsletterFooter — vier kolommen: een inschrijfblok met grote kop, tekst en
 * e-mailveld + knop (met een zachte gloed in de primaire kleur erachter),
 * snelle links, contactgegevens en 'volg ons' met ronde sociale knoppen en
 * ruimte voor een extra bediening (bv. een thema-schakelaar van het project);
 * onderaan copyright en de juridische links. Componeert field; de ronde
 * verzendknop zit ín het veld (trailing-slot), zoals in de bron.
 *
 * Geoogst uit 21st.dev Footer section (arihantcodes): shadcn Input → field
 * uit de registry, de Send-iconknop → eigen ronde submit in het veld, radix
 * Tooltip → aria-label + title, de
 * dark-mode-Switch met document.documentElement.classList → vervalt (thema is
 * van het project; `extra`-slot houdt de plek), vaste adresregels → `contact`,
 * bg-primary/10 blur-2xl-gloed → color-mix(--primary) + filter, het e-mail-
 * adres gaat via `onSubscribe` naar het project (async, status in de knop).
 */

"use client";

import { useState } from "react";
import type * as React from "react";
import { Field } from "../../primitives/ui/input";
import type { FooterLink, FooterSocial } from "./footer-types";

export interface NewsletterFooterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children" | "title"> {
  title: React.ReactNode;
  intro?: React.ReactNode;
  /** Het project verwerkt de inschrijving (mail, CRM); een throw houdt het formulier open. */
  onSubscribe?: (email: string) => void | Promise<void>;
  labels?: Partial<{ email: string; placeholder: string; submit: string; sent: string; links: string; contact: string; follow: string }>;
  links: readonly FooterLink[];
  /** Adresregels, telefoon, e-mail — vrije regels van het project. */
  contact?: readonly React.ReactNode[];
  socials?: readonly FooterSocial[];
  /** Onder de sociale knoppen (bv. een thema-schakelaar). */
  extra?: React.ReactNode;
  copyright?: React.ReactNode;
  legal?: readonly FooterLink[];
  linkComponent?: React.ElementType;
}

export function NewsletterFooter({ title, intro, onSubscribe, labels, links, contact = [], socials = [], extra, copyright, legal = [], linkComponent: Link = "a", className, ...rest }: NewsletterFooterProps) {
  const t = { email: "E-mailadres", placeholder: "Je e-mailadres", submit: "Inschrijven", sent: "Ingeschreven", links: "Snel naar", contact: "Contact", follow: "Volg ons", ...labels };
  const [mail, setMail] = useState("");
  const [status, setStatus] = useState<"idle" | "bezig" | "klaar">("idle");

  const verstuur = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!mail || status !== "idle") return;
    setStatus("bezig");
    try {
      await onSubscribe?.(mail);
      setStatus("klaar");
    } catch {
      setStatus("idle");
    }
  };

  return (
    <footer {...rest} className={["fp-nfoot", className].filter(Boolean).join(" ")}>
      <div className="fp-nfoot__binnen">
        <div className="fp-nfoot__raster">
          <div className="fp-nfoot__kolom">
            <span className="fp-nfoot__gloed" aria-hidden="true" />
            <h2 className="fp-nfoot__titel">{title}</h2>
            {intro && <p className="fp-nfoot__intro">{intro}</p>}
            <form className="fp-nfoot__form" onSubmit={verstuur}>
              <Field
                label={t.email}
                type="email"
                required
                placeholder={t.placeholder}
                value={mail}
                onChange={(e) => setMail(e.target.value)}
                disabled={status !== "idle"}
                autoComplete="email"
                description={status === "klaar" ? t.sent : undefined}
                trailing={
                  <button type="submit" className="fp-nfoot__verzend" disabled={status !== "idle"} aria-label={status === "klaar" ? t.sent : t.submit} aria-busy={status === "bezig" || undefined}>
                    {status === "klaar" ? <Icoon d="M5 12l5 5L19 7" /> : <Icoon d="M22 2L11 13M22 2l-7 20-4-9-9-4z" />}
                  </button>
                }
              />
            </form>
          </div>
          <div className="fp-nfoot__kolom">
            <h3 className="fp-nfoot__kop">{t.links}</h3>
            <nav className="fp-nfoot__nav" aria-label={t.links}>
              {links.map((l) => (
                <Link key={l.label} href={l.href} className="fp-ft-link">{l.icon}{l.label}</Link>
              ))}
            </nav>
          </div>
          {contact.length > 0 && (
            <div className="fp-nfoot__kolom">
              <h3 className="fp-nfoot__kop">{t.contact}</h3>
              <address className="fp-nfoot__adres">
                {contact.map((regel, i) => <p key={i}>{regel}</p>)}
              </address>
            </div>
          )}
          <div className="fp-nfoot__kolom">
            <h3 className="fp-nfoot__kop">{t.follow}</h3>
            {socials.length > 0 && (
              <ul className="fp-ft-social">
                {socials.map((s) => (
                  <li key={s.label}>
                    <a href={s.href} target="_blank" rel="noopener noreferrer" aria-label={s.label} title={s.label} className="fp-ft-social__knop">{s.icon}</a>
                  </li>
                ))}
              </ul>
            )}
            {extra && <div className="fp-nfoot__extra">{extra}</div>}
          </div>
        </div>
        <div className="fp-ft-onder fp-nfoot__onder">
          {copyright && <p>{copyright}</p>}
          {legal.length > 0 && (
            <ul className="fp-ft-legal">
              {legal.map((l) => (
                <li key={l.label}><Link href={l.href} className="fp-ft-link">{l.label}</Link></li>
              ))}
            </ul>
          )}
        </div>
      </div>
    </footer>
  );
}

function Icoon({ d }: { d: string }) {
  return (
    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d={d} /></svg>
  );
}
blocks/footers/footer-types.ts
/**
 * Gedeelde datavormen van de footer-entries (sticky, centered, compact,
 * columns, grid, newsletter). Eén vocabulaire zodat een project van de ene
 * footer naar de andere kan wisselen zonder zijn data te hertalen.
 */

import type * as React from "react";

export interface FooterLink {
  label: string;
  href: string;
  /** Optioneel icoon vóór het label (van het project). */
  icon?: React.ReactNode;
}

export interface FooterColumn {
  title: string;
  links: readonly FooterLink[];
}

export interface FooterSocial {
  label: string;
  href: string;
  /** Het icoon komt van het project (21st Icons, eigen SVG). */
  icon: React.ReactNode;
}
blocks/footers/footers.css
/**
 * Footers — importeren in de app-root (registry-site: app/globals.css).
 * Token-contract 2: maten uit --text-*, --space-*, --radius-*, --border-width.
 * Gedeeld: .fp-ft-* (link, kop, sociale knop, onderbalk); per entry een eigen blok.
 */

/* ── Gedeelde onderdelen ─────────────────────────────────────────────────── */
.fp-ft-link {
  display: inline-flex; align-items: center; gap: var(--space-2xs);
  font-size: var(--text-body-sm); color: var(--muted-foreground); text-decoration: none;
  transition: color var(--motion-instant-duration) var(--motion-instant);
}
.fp-ft-link:hover { color: var(--foreground); }
.fp-ft-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.fp-ft-kop { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-label); font-weight: 600; letter-spacing: 0.08em; text-transform: uppercase; color: var(--foreground); }
.fp-ft-lijst { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-xs); }
.fp-ft-social { list-style: none; margin: 0; padding: 0; display: flex; align-items: center; gap: var(--space-xs); }
.fp-ft-social__knop {
  display: inline-flex; align-items: center; justify-content: center;
  width: 2.25rem; height: 2.25rem; border-radius: 50%;
  border: var(--border-width) solid var(--border); background: var(--card); color: var(--foreground);
  transition: background var(--motion-instant-duration) var(--motion-instant), border-color var(--motion-instant-duration) var(--motion-instant);
}
.fp-ft-social__knop:hover { background: var(--muted); border-color: var(--muted-foreground); }
.fp-ft-social__knop:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-ft-social__knop > * { width: 1rem; height: 1rem; }
.fp-ft-onder {
  display: flex; flex-direction: column; gap: var(--space-xs);
  padding-top: var(--space-lg); border-top: var(--border-width) solid var(--border);
  font-size: var(--text-body-sm); color: var(--muted-foreground);
}
@media (min-width: 48rem) { .fp-ft-onder { flex-direction: row; align-items: center; justify-content: space-between; } }
.fp-ft-onder p { margin: 0; }
.fp-ft-legal { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--space-md); }
.fp-ft-merk { display: flex; align-items: center; gap: var(--space-xs); color: var(--foreground); text-decoration: none; font-size: var(--text-h3); font-weight: 700; letter-spacing: -0.01em; }
.fp-ft-merk > svg, .fp-ft-merk > img { width: 2rem; height: 2rem; }
.fp-ft-tekst { margin: 0; max-width: 32ch; font-size: var(--text-body-sm); line-height: 1.6; color: var(--muted-foreground); }

/* ── sticky-footer: vast onder de pagina, de inhoud schuift eroverheen ─── */
.fp-sfoot { position: relative; width: 100%; height: var(--fp-sfoot-h, 40rem); clip-path: inset(0); color: var(--foreground); }
.fp-sfoot__vast { position: fixed; bottom: 0; left: 0; width: 100%; height: var(--fp-sfoot-h, 40rem); }
.fp-sfoot__vel {
  position: relative; display: flex; flex-direction: column; justify-content: space-between; gap: var(--space-lg);
  box-sizing: border-box; width: 100%; height: 100%; overflow: auto;
  padding: var(--space-xl) var(--space-md); background: var(--background);
  border-top: var(--border-width) solid var(--border);
}
@media (min-width: 48rem) { .fp-sfoot__vel { padding: var(--space-xl) var(--space-2xl); } }
/* Drie zachte lichtbanen in de tekstkleur, schuin over het vlak — de 'gloed' van de bron. */
.fp-sfoot__gloed { position: absolute; inset: 0; z-index: 0; overflow: hidden; pointer-events: none; }
.fp-sfoot__gloed i { position: absolute; top: 0; left: 0; display: block; border-radius: 50%; }
.fp-sfoot__gloed i:nth-child(1) { width: 35rem; height: 80rem; transform: translateY(-55%) rotate(-45deg); background: radial-gradient(68% 68% at 55% 31%, color-mix(in srgb, var(--foreground) 6%, transparent) 0, color-mix(in srgb, var(--foreground) 2%, transparent) 50%, transparent 80%); }
.fp-sfoot__gloed i:nth-child(2) { width: 15rem; height: 80rem; transform: translate(5%, -50%) rotate(-45deg); background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--foreground) 4%, transparent) 0, transparent 80%); }
.fp-sfoot__gloed i:nth-child(3) { width: 15rem; height: 80rem; transform: translateY(-55%) rotate(-45deg); background: radial-gradient(50% 50% at 50% 50%, color-mix(in srgb, var(--foreground) 4%, transparent) 0, transparent 80%); }
.fp-sfoot__rijen { position: relative; z-index: 1; display: flex; flex-direction: column; gap: var(--space-xl); margin-top: var(--space-lg); }
@media (min-width: 48rem) { .fp-sfoot__rijen { flex-direction: row; gap: var(--space-xl); } }
.fp-sfoot__merk { display: grid; gap: var(--space-md); width: 100%; max-width: 24rem; }
.fp-sfoot__kolom { width: 100%; }
.fp-sfoot__onder { position: relative; z-index: 1; }

/* ── centered-footer: logo, links op een rij, sociale iconen, arceerband ── */
.fp-cfoot { width: 100%; padding: var(--space-xl) 0 var(--space-lg); overflow: hidden; color: var(--foreground); }
.fp-cfoot__midden { display: flex; flex-direction: column; align-items: center; gap: var(--space-xl); margin-bottom: var(--space-xl); padding: 0 var(--space-md); }
.fp-cfoot__logo { display: flex; justify-content: center; }
.fp-cfoot__logo > * { height: 2.5rem; width: auto; }
.fp-cfoot__nav { position: relative; z-index: 1; display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-md) var(--space-lg); }
.fp-cfoot__nav-link { position: relative; display: inline-block; padding: var(--space-2xs) var(--space-xs); font-size: var(--text-body); font-weight: 500; color: var(--foreground); text-decoration: none; }
.fp-cfoot__nav-link:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; border-radius: var(--radius-sm); }
.fp-cfoot__nav-tekst { position: relative; z-index: 1; }
.fp-cfoot__nav-vlak { position: absolute; inset: 0; z-index: 0; border-radius: var(--radius-sm); background: var(--muted); transform-origin: center; }
/* De arceerband: dunne schuine lijnen in de tekstkleur, op lage dekking. */
.fp-cfoot__band {
  width: 100%; height: 3rem; opacity: 0.1;
  border-top: var(--border-width) solid var(--foreground); border-bottom: var(--border-width) solid var(--foreground);
  background-image: repeating-linear-gradient(315deg, currentColor 0, currentColor 1px, transparent 0, transparent 50%);
  background-size: 10px 10px;
}
.fp-cfoot__copy { margin: var(--space-lg) 0 0; padding: 0 var(--space-md); text-align: center; font-size: var(--text-body-sm); color: var(--muted-foreground); }

/* ── compact-footer: merk + sociale knoppen, lijn, links rechts, copyright links ── */
.fp-kfoot { width: 100%; padding: var(--space-2xl) var(--space-md) var(--space-lg); color: var(--foreground); }
@media (min-width: 64rem) { .fp-kfoot { padding: var(--space-2xl) var(--space-xl) var(--space-lg); } }
.fp-kfoot__boven { display: flex; flex-direction: column; gap: var(--space-lg); }
@media (min-width: 48rem) { .fp-kfoot__boven { flex-direction: row; align-items: flex-start; justify-content: space-between; } }
.fp-kfoot__onder { display: grid; gap: var(--space-md); margin-top: var(--space-lg); padding-top: var(--space-lg); border-top: var(--border-width) solid var(--border); }
@media (min-width: 64rem) { .fp-kfoot__onder { grid-template-columns: 3fr 7fr; grid-template-rows: auto auto; padding-top: var(--space-xl); } }
.fp-kfoot__links { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: var(--space-xs) var(--space-md); }
@media (min-width: 64rem) { .fp-kfoot__links { grid-column: 2; justify-content: flex-end; } }
.fp-kfoot__hoofdlink { font-size: var(--text-body-sm); color: var(--foreground); text-decoration: none; text-underline-offset: 0.25em; }
.fp-kfoot__hoofdlink:hover { text-decoration: underline; }
.fp-kfoot__legallink { font-size: var(--text-body-sm); color: var(--muted-foreground); text-decoration: none; text-underline-offset: 0.25em; }
.fp-kfoot__legallink:hover { text-decoration: underline; }
.fp-kfoot__copy { font-size: var(--text-body-sm); line-height: 1.6; color: var(--muted-foreground); white-space: nowrap; }
@media (min-width: 64rem) { .fp-kfoot__copy { grid-column: 1; grid-row: 1 / 3; } }
.fp-kfoot__copy p { margin: 0; }

/* ── columns-footer: merk + tekst + socials links, drie kolommen rechts, onderbalk ── */
.fp-zfoot { width: 100%; max-width: 72rem; margin: 0 auto; padding: var(--space-2xl) var(--space-md); box-sizing: border-box; color: var(--foreground); }
.fp-zfoot__boven { display: flex; flex-direction: column; gap: var(--space-xl); }
@media (min-width: 64rem) { .fp-zfoot__boven { flex-direction: row; align-items: flex-start; } }
.fp-zfoot__merk { display: grid; gap: var(--space-lg); width: 100%; }
.fp-zfoot__kolommen { display: grid; gap: var(--space-lg); width: 100%; }
@media (min-width: 48rem) { .fp-zfoot__kolommen { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .fp-zfoot__kolommen { gap: var(--space-2xl); } }
.fp-zfoot__kolomkop { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-body); font-weight: 700; }
.fp-zfoot__onder { margin-top: var(--space-xl); }
.fp-zfoot__onder .fp-ft-onder { padding: var(--space-lg) 0; font-size: var(--text-label); font-weight: 500; }
.fp-zfoot__onder .fp-ft-link:hover { color: var(--primary); }
.fp-zfoot .fp-ft-social__knop { border: 0; background: transparent; width: auto; height: auto; }
.fp-zfoot .fp-ft-social__knop > * { width: 1.25rem; height: 1.25rem; }
.fp-zfoot .fp-ft-social__knop:hover { color: var(--primary); background: transparent; }

/* ── grid-footer: merk in de eerste kolom, vier kolommen in twee paren, centrale copyright ── */
.fp-gfoot {
  position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center;
  width: 100%; max-width: 72rem; margin: 0 auto; box-sizing: border-box;
  padding: var(--space-2xl) var(--space-md) var(--space-lg);
  border-top: var(--border-width) solid var(--border); color: var(--foreground);
}
@media (min-width: 64rem) { .fp-gfoot { padding: var(--space-2xl) var(--space-xl) var(--space-lg); } }
.fp-gfoot__raster { display: grid; gap: var(--space-xl); width: 100%; }
@media (min-width: 80rem) { .fp-gfoot__raster { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
.fp-gfoot__merk { display: grid; gap: var(--space-md); align-content: start; max-width: 14rem; }
.fp-gfoot__logo { display: inline-flex; width: 2.5rem; height: 2.5rem; border-radius: 50%; overflow: hidden; }
.fp-gfoot__logo > * { width: 100%; height: 100%; }
.fp-gfoot__paren { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: var(--space-xl); }
@media (min-width: 80rem) { .fp-gfoot__paren { grid-column: span 2; } }
.fp-gfoot__paar { display: grid; gap: var(--space-xl); }
@media (min-width: 48rem) { .fp-gfoot__paar { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
.fp-gfoot__kolomkop { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-body); font-weight: 400; color: var(--foreground); }
.fp-gfoot .fp-ft-lijst { gap: var(--space-md); }
.fp-gfoot__copy { width: 100%; margin: var(--space-2xl) 0 0; text-align: center; font-size: var(--text-body-sm); color: var(--muted-foreground); }

/* ── newsletter-footer: inschrijven, snelle links, contact, volgen; onderbalk ── */
.fp-nfoot { position: relative; width: 100%; border-top: var(--border-width) solid var(--border); background: var(--background); color: var(--foreground); }
.fp-nfoot__binnen { max-width: 72rem; margin: 0 auto; padding: var(--space-xl) var(--space-md); box-sizing: border-box; }
@media (min-width: 64rem) { .fp-nfoot__binnen { padding: var(--space-xl) var(--space-xl); } }
.fp-nfoot__raster { display: grid; gap: var(--space-xl); }
@media (min-width: 48rem) { .fp-nfoot__raster { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 64rem) { .fp-nfoot__raster { grid-template-columns: 1.4fr repeat(3, minmax(0, 1fr)); } }
.fp-nfoot__kolom { position: relative; }
.fp-nfoot__titel { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-h2); line-height: 1.1; font-weight: 700; letter-spacing: -0.02em; }
.fp-nfoot__intro { margin: 0 0 var(--space-lg); font-size: var(--text-body-sm); color: var(--muted-foreground); }
.fp-nfoot__form { position: relative; }
/* De verzendknop zit ín het veld (trailing-slot): rond, in de primaire kleur, met pijl; klaar = vinkje. */
.fp-nfoot__verzend {
  display: inline-flex; align-items: center; justify-content: center; width: 2rem; height: 2rem; padding: 0;
  border: 0; border-radius: 50%; background: var(--primary); color: var(--primary-foreground); cursor: pointer;
  transition: transform var(--motion-instant-duration) var(--motion-instant), opacity var(--motion-instant-duration) var(--motion-instant);
}
.fp-nfoot__verzend:hover { transform: scale(1.06); }
.fp-nfoot__verzend:active { transform: scale(0.96); }
.fp-nfoot__verzend:disabled { cursor: default; opacity: 0.7; transform: none; }
.fp-nfoot__verzend:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; }
.fp-nfoot__verzend > svg { width: 1rem; height: 1rem; }
.fp-nfoot__form .fp-input--met-trailing { padding-right: 3rem; }
/* De zachte gloed rechtsboven het inschrijfblok, in de primaire kleur. */
.fp-nfoot__gloed { position: absolute; top: -1rem; right: -1rem; width: 6rem; height: 6rem; border-radius: 50%; background: color-mix(in srgb, var(--primary) 10%, transparent); filter: blur(2rem); pointer-events: none; }
.fp-nfoot__kop { margin: 0 0 var(--space-md); font: inherit; font-size: var(--text-h3); font-weight: 600; }
.fp-nfoot__nav { display: grid; gap: var(--space-xs); }
.fp-nfoot__nav .fp-ft-link { color: var(--foreground); }
.fp-nfoot__nav .fp-ft-link:hover { color: var(--primary); }
.fp-nfoot__adres { margin: 0; display: grid; gap: var(--space-xs); font-style: normal; font-size: var(--text-body-sm); color: var(--foreground); }
.fp-nfoot__adres p { margin: 0; }
.fp-nfoot__extra { margin-top: var(--space-lg); }
.fp-nfoot__onder { margin-top: var(--space-xl); text-align: center; }
.fp-nfoot__onder .fp-ft-link { color: var(--foreground); }
.fp-nfoot__onder .fp-ft-link:hover { color: var(--primary); }