Voettekst die vast onder de pagina zit: de inhoud schuift eroverheen en onthult hem. Merk, tekst en sociale knoppen links, kolommen met links ernaast, onderaan copyright; drie zachte lichtbanen in de tekstkleur over het vlak. Elk deel komt scherp in beeld zodra het zichtbaar wordt.
Het vel zit vast onder de pagina; de inhoud schuift eroverheen en onthult hem. Kolommen komen scherp in beeld (blur + zakken).
blocks/footers/sticky-footer.tsx
/**
* StickyFooter — de voettekst zit vast onder de pagina en de inhoud schuift
* eroverheen: het blok reserveert zijn hoogte in de flow en knipt (clip-path)
* een vast gepositioneerd vel dat pas zichtbaar wordt zodra het blok in beeld
* scrolt. Merk + tekst + sociale knoppen links, kolommen met links ernaast,
* onderaan copyright en een tweede regel; elk deel komt scherp in beeld
* (blur + zakken) zodra het zichtbaar wordt (scroll: trigger).
*
* Geoogst uit 21st.dev Sticky Footer (efferd): motion/react → framer op de
* smooth-token, lucide-iconen → slots, shadcn Button → eigen sociale knop,
* Tailwind-gradients met --color-foreground → radial-gradients op
* color-mix(--foreground), vaste fintech-kolommen → `columns` van het project,
* 720px → `height` (CSS-lengte, default 40rem). Lenis uit de demo vervalt.
*
* Let op: `position: fixed` rekent op de viewport. In een scroll-paneel geef
* je een voorouder `transform: translateZ(0)` (de demo doet dat) — dan is dat
* paneel het referentiekader.
*/
"use client";
import type * as React from "react";
import { motion, useReducedMotion } from "framer-motion";
import { useSpringTransition } from "../../primitives/motion";
import type { FooterColumn, FooterSocial } from "./footer-types";
export interface StickyFooterProps extends Omit<React.HTMLAttributes<HTMLElement>, "children"> {
brand: React.ReactNode;
description?: React.ReactNode;
socials?: readonly FooterSocial[];
columns: readonly FooterColumn[];
copyright?: React.ReactNode;
/** Rechts in de onderbalk (bv. een tweede merknaam of taalkeuze). */
aside?: React.ReactNode;
/** Hoogte van het vel, CSS-lengte. Default 40rem. */
height?: string;
linkComponent?: React.ElementType;
}
function Onthul({ delay, className, children }: { delay: number; className?: string; children: React.ReactNode }) {
const reduced = useReducedMotion() ?? false;
const veer = useSpringTransition("smooth", { mass: "content", delay });
return (
<motion.div
className={className}
initial={reduced ? false : { filter: "blur(4px)", y: -8, opacity: 0 }}
whileInView={{ filter: "blur(0px)", y: 0, opacity: 1 }}
viewport={{ once: true, amount: 0.2 }}
transition={veer}
>
{children}
</motion.div>
);
}
export function StickyFooter({ brand, description, socials = [], columns, copyright, aside, height = "40rem", linkComponent: Link = "a", className, style, ...rest }: StickyFooterProps) {
return (
<footer {...rest} className={["fp-sfoot", className].filter(Boolean).join(" ")} style={{ ...style, ["--fp-sfoot-h" as string]: height } as React.CSSProperties}>
<div className="fp-sfoot__vast">
<div className="fp-sfoot__vel">
<div className="fp-sfoot__gloed" aria-hidden="true"><i /><i /><i /></div>
<div className="fp-sfoot__rijen">
<Onthul delay={0} className="fp-sfoot__merk">
<div className="fp-ft-merk">{brand}</div>
{description && <p className="fp-ft-tekst">{description}</p>}
{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} className="fp-ft-social__knop">{s.icon}</a>
</li>
))}
</ul>
)}
</Onthul>
{columns.map((k, i) => (
<Onthul key={k.title} delay={0.1 + i * 0.1} className="fp-sfoot__kolom">
<h3 className="fp-ft-kop">{k.title}</h3>
<ul className="fp-ft-lijst">
{k.links.map((l) => (
<li key={l.label}>
<Link href={l.href} className="fp-ft-link">{l.icon}{l.label}</Link>
</li>
))}
</ul>
</Onthul>
))}
</div>
<div className="fp-ft-onder fp-sfoot__onder">
{copyright && <p>{copyright}</p>}
{aside && <p>{aside}</p>}
</div>
</div>
</div>
</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); }