Gecentreerde voettekst: logo, één rij links (een vlak groeit onder de link bij hover), sociale iconen, een band met schuine arcering over de volle breedte en daaronder de copyright. Logo, links en iconen verschijnen na elkaar zodra de footer in beeld komt.
Kom op een link: een vlak groeit eronder. Logo, links en iconen verschijnen na elkaar.
blocks/footers/centered-footer.tsx
/**
* CenteredFooter — alles gecentreerd: logo, één rij navigatielinks (een
* vlak in --muted groeit onder de link bij hover), de sociale iconen, dan een
* band met schuine arcering over de volle breedte en daaronder de copyright.
* Logo, links en iconen verschijnen na elkaar zodra de footer in beeld komt
* (scroll: trigger).
*
* Geoogst uit 21st.dev Animated Footer Section (solaceui): het SolaceUI-logo →
* `logo`-slot, SocialCloud → `socials` (iconen van het project), zes vaste
* links → `links`, spring stiffness/damping → de motion-tokens (pop voor de
* hover, smooth voor de onthulling), de 20 s backgroundPositionX-animatie van
* de band vervalt (verf-animatie zonder betekenis; de band is nu statisch),
* new Date().getFullYear() → `copyright`-slot.
*/
"use client";
import type * as React from "react";
import { useMemo } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import type { FooterLink, FooterSocial } from "./footer-types";
export interface CenteredFooterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
logo: React.ReactNode;
links: readonly FooterLink[];
socials?: readonly FooterSocial[];
copyright?: React.ReactNode;
linkComponent?: React.ElementType;
}
export function CenteredFooter({ logo, links, socials = [], copyright, linkComponent: Link = "a", className, ...rest }: CenteredFooterProps) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content" });
const tik = useSpringTransition("pop");
const item = {
hidden: reduced ? { opacity: 1, y: 0 } : { opacity: 0, y: 20 },
visible: { opacity: 1, y: 0, transition: veer },
};
// Eén keer per linkComponent maken — anders remount elke render de links.
const MotionLink = useMemo(() => motion.create(Link as React.ElementType), [Link]);
return (
<footer {...rest} className={["fp-cfoot", className].filter(Boolean).join(" ")}>
<motion.div
className="fp-cfoot__midden"
initial="hidden"
whileInView="visible"
viewport={{ once: true, amount: 0.3 }}
variants={{ hidden: { opacity: reduced ? 1 : 0 }, visible: { opacity: 1, transition: { staggerChildren: reduced ? 0 : 0.1, delayChildren: reduced ? 0 : 0.1 } } }}
>
<motion.div variants={item} className="fp-cfoot__logo">{logo}</motion.div>
<motion.nav variants={item} className="fp-cfoot__nav" aria-label="Voettekst">
{links.map((l) => (
<MotionLink key={l.label} href={l.href} className="fp-cfoot__nav-link" whileHover="hover" whileTap="tap" initial="rest" animate="rest">
<span className="fp-cfoot__nav-tekst">{l.label}</span>
<motion.span
className="fp-cfoot__nav-vlak"
aria-hidden="true"
variants={{ rest: { scale: 0, opacity: 0 }, hover: { scale: 1, opacity: 1 }, tap: { scale: 0.96, opacity: 1 } }}
transition={tik}
/>
</MotionLink>
))}
</motion.nav>
{socials.length > 0 && (
<motion.ul variants={item} className="fp-ft-social">
{socials.map((s) => (
<li key={s.label}>
<a href={s.href} target="_blank" rel="noopener noreferrer" aria-label={s.label} className="fp-ft-social__knop">{s.icon}</a>
</li>
))}
</motion.ul>
)}
</motion.div>
<div className="fp-cfoot__band" aria-hidden="true" />
{copyright && <p className="fp-cfoot__copy">{copyright}</p>}
</footer>
);
}
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); }