Footer
A giant-wordmark SaaS footer with a vivid accent band of tinted dots and a brand wordmark that rises in letter-by-letter and clips at the seam.
A vivid accent band of tinted dots holds a centered tagline and an oversized brand wordmark that rises in letter-by-letter and is clipped where the band melts into the link block below. Built with Motion.
Installation
Complete the shared Setup first, then copy the component into
components/ui/footer.tsx.
bun add motion'use client';import * as React from 'react';import { motion, useReducedMotion, type Variants } from 'motion/react';import { cn } from '@/lib/cn';const EASE_OUT = [0.23, 1, 0.32, 1] as const;export type FooterLink = { label: string; href: string };export type FooterColumn = { title: string; links: FooterLink[] };export type FooterProps = { brand: string; tagline?: string; columns: FooterColumn[]; social?: FooterLink[]; copyright?: string; accent?: string; className?: string;};export function Footer({ brand, tagline, columns, social, copyright, accent = '#3b82f6', className,}: FooterProps) { const reduce = useReducedMotion(); const go = true; const rise: Variants = { hidden: { opacity: 0, y: reduce ? 0 : 14 }, show: (i: number) => ({ opacity: 1, y: 0, transition: { duration: 0.5, ease: EASE_OUT, delay: reduce ? 0 : i * 0.06 }, }), }; return ( <footer style={{ '--fa': accent } as React.CSSProperties} className={cn( 'relative isolate overflow-hidden', '[--band-base:#fafafa] [--surface:#f4f4f5] dark:[--band-base:#0a0a0b] dark:[--surface:#1c1c1c]', 'bg-[var(--surface)] text-neutral-900 dark:text-white', className, )} > <div className="relative overflow-hidden bg-[var(--band-base)]"> <BandDots /> {tagline ? ( <motion.p custom={0} variants={rise} initial="hidden" animate={go ? 'show' : 'hidden'} className="relative z-10 px-6 pt-14 text-center text-[18px] font-medium tracking-[-0.01em] sm:text-[22px]" > {tagline} </motion.p> ) : ( <div className="pt-14" /> )} <Wordmark brand={brand} go={go} reduce={!!reduce} /> </div> <div className="mx-auto w-full max-w-6xl px-6 pb-10 pt-12"> <div className="grid grid-cols-2 gap-x-6 gap-y-10 sm:grid-cols-3 lg:grid-cols-5"> {columns.map((col, i) => ( <motion.nav key={col.title} aria-label={col.title} custom={i + 1} variants={rise} initial="hidden" animate={go ? 'show' : 'hidden'} className="flex flex-col" > <h3 className="mb-3.5 text-[15px] font-semibold tracking-[-0.01em]"> {col.title} </h3> <ul className="flex flex-col gap-2.5"> {col.links.map((link) => ( <li key={link.label}> <FooterAnchor href={link.href}>{link.label}</FooterAnchor> </li> ))} </ul> </motion.nav> ))} {social && social.length > 0 ? ( <motion.nav aria-label="Socials" custom={columns.length + 1} variants={rise} initial="hidden" animate={go ? 'show' : 'hidden'} className="flex flex-col" > <h3 className="mb-3.5 text-[15px] font-semibold tracking-[-0.01em]"> Socials </h3> <ul className="flex flex-col gap-2.5"> {social.map((link) => ( <li key={link.label}> <FooterAnchor href={link.href} underline> {link.label} </FooterAnchor> </li> ))} </ul> </motion.nav> ) : null} </div> {copyright ? ( <p className="mt-16 text-[13px] text-neutral-500 dark:text-[#8b8b8b]"> {copyright} </p> ) : null} </div> </footer> );}const DOT_MASK: React.CSSProperties = { WebkitMaskImage: 'radial-gradient(circle at center, #000 1px, transparent 1.4px)', maskImage: 'radial-gradient(circle at center, #000 1px, transparent 1.4px)', WebkitMaskSize: '5px 5px', maskSize: '5px 5px',};function BandDots() { const COLS = 44; const mid = (COLS - 1) / 2; return ( <div aria-hidden className="pointer-events-none absolute inset-0 z-0"> <div className="absolute inset-0 bg-black/[0.06] dark:bg-white/[0.08]" style={DOT_MASK} /> <div className="footer-dot-hue absolute inset-0 flex" style={DOT_MASK}> {Array.from({ length: COLS }).map((_, i) => { const t = Math.abs(i - mid) / mid; const reach = 24 + t * t * 56; return ( <div key={i} className="h-full flex-1" style={{ background: `linear-gradient(180deg, var(--fa) 0%, color-mix(in srgb, var(--fa) 55%, transparent) ${(reach * 0.5).toFixed(1)}%, transparent ${reach.toFixed(1)}%)`, }} /> ); })} </div> </div> );}function Wordmark({ brand, go, reduce,}: { brand: string; go: boolean; reduce: boolean;}) { const letters = React.useMemo(() => Array.from(brand), [brand]); const wordStyle: React.CSSProperties = { fontSize: 'clamp(96px,23vw,380px)', lineHeight: 0.92, letterSpacing: '-0.045em', }; const container: Variants = { hidden: {}, show: { transition: { staggerChildren: reduce ? 0 : 0.05, delayChildren: 0.12 } }, }; const letter: Variants = { hidden: reduce ? { y: '0%', opacity: 1, scale: 1, filter: 'blur(0px)' } : { y: '110%', opacity: 0, scale: 0.9, filter: 'blur(16px)' }, show: { y: '0%', opacity: 1, scale: 1, filter: 'blur(0px)', transition: reduce ? { duration: 0 } : { y: { type: 'spring', duration: 0.9, bounce: 0.28 }, scale: { type: 'spring', duration: 0.9, bounce: 0.28 }, opacity: { duration: 0.5, ease: EASE_OUT }, filter: { duration: 0.6, ease: EASE_OUT }, }, }, }; return ( <div aria-hidden className="relative z-[1] mt-4 h-[clamp(78px,17.5vw,290px)] overflow-hidden" > <motion.span className="absolute left-1/2 top-0 flex -translate-x-1/2 whitespace-nowrap font-bold" style={wordStyle} variants={container} initial="hidden" animate={go ? 'show' : 'hidden'} aria-label={brand} > {letters.map((ch, i) => ( <span key={i} className="inline-block overflow-hidden"> <motion.span variants={letter} className="inline-block"> {ch === ' ' ? ' ' : ch} </motion.span> </span> ))} </motion.span> </div> );}const FOCUS = 'focus-visible:outline-none focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:[outline-color:var(--fa)]';function FooterAnchor({ href, underline, children,}: { href: string; underline?: boolean; children: React.ReactNode;}) { return ( <a href={href} className={cn( 'inline-block rounded-[4px] text-[14px] text-neutral-500 transition-colors duration-150 ease-out', 'hover:text-neutral-900 dark:text-[#929292] dark:hover:text-white', underline && 'underline decoration-from-font underline-offset-[3px]', FOCUS, )} > {children} </a> );}Add the hue drift to your global.css.
.footer-dot-hue {
animation: footer-dot-hue 12s ease-in-out infinite;
}
@keyframes footer-dot-hue {
0% {
filter: hue-rotate(-28deg);
}
50% {
filter: hue-rotate(34deg);
}
100% {
filter: hue-rotate(-28deg);
}
}
@media (prefers-reduced-motion: reduce) {
.footer-dot-hue {
animation: none;
filter: none;
}
}Usage
Everything is data: pass the brand, tagline, columns, and socials.
import { Footer } from '@/components/ui/footer';
export default function Example() {
return (
<Footer
brand="Liten"
tagline="Design-engineered components. Depth, not glow."
accent="#ff2e88"
columns={[
{ title: 'Product', links: [{ label: 'Pricing', href: '/pricing' }] },
{ title: 'Company', links: [{ label: 'About', href: '/about' }] },
]}
social={[
{ label: 'X', href: '#' },
{ label: 'Linkedin', href: '#' },
]}
copyright="© 2026 Liten. Crafted with depth."
/>
);
}Props
| Prop | Type | Default | Description |
|---|---|---|---|
brand | string | - | The oversized wordmark behind the band. |
tagline | string | - | Centered line inside the accent band. |
columns | FooterColumn[] | - | Link columns ({ title, links }). |
social | FooterLink[] | - | Socials column, rendered as underlined text links. |
copyright | string | - | Muted credit line at the very bottom. |
accent | string | "#ff2e88" | The vivid accent the band is built from. |
className | string | - | Forwarded to the <footer>. |