Footer Wordmark
A poster-sized word for the foot of a page whose letters rise toward the cursor like equalizer bars.
npx shadcn@latest add @vibebox/footer-wordmarkControls
<FooterWordmark />About
The sign-off at the bottom of a site: the business name at poster scale, painted in one continuous spectrum gradient, rising out of a soft fog as the footer scrolls in. Letters lift toward the cursor like equalizer bars, the leading letters bounce with scroll speed, and a tap (or the pointer arriving) sends a ripple from that letter. Each letter carries the whole word's gradient, offset to its own position, so the colour stays continuous while letters move on their own. Ported from the VibeBox footer; the word is now a prop.
Use it when
- A footer needs a confident brand moment.
- The name is short (up to about 10 letters reads at poster size).
Skip it when
- The name is long; it shrinks until it no longer reads as a poster.
- The page already ends with another large visual.
Install
- 1Add the VibeBox registry to
components.json:{ "registries": { "@vibebox": "https://motion.vibeboxph.com/r/{name}.json" } } - 2Add the component with the shadcn CLI:
npx shadcn@latest add @vibebox/footer-wordmark - 3Use it:
import { FooterWordmark } from "@/components/vibebox/footer-wordmark"; <footer> {/* links and the business name in text */} <FooterWordmark word="Northline" bounce={5} className="mt-10" /> </footer>
Code
2 filescomponents/vibebox/footer-wordmark/footer-wordmark.client.tsx
/**
* VibeBox Motion · Footer Wordmark
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
"use client";
import { LazyMotion, domAnimation, m, motionValue, useAnimationFrame, useScroll, useSpring, useTransform, useVelocity } from "motion/react";
import { useEffect, useMemo, useRef, type PointerEvent as ReactPointerEvent } from "react";
import { cn } from "@/lib/utils";
import { useReducedMotion } from "@/lib/vibebox/hooks";
/* A poster-sized word for the foot of a page. Every letter rises toward the cursor like an
equalizer bar, the leading letters bounce with scroll speed, a tap or a pointer arriving sends
a ripple from that letter, and the word climbs out of a fog at the bottom edge as the footer
scrolls in. Each letter carries the whole word's gradient, offset to its own position, so the
spectrum stays continuous while letters move independently. */
/** Lifts in em: cursor proximity, scroll bounce, and the cap that keeps letters inside the headroom. */
const LIFT = 0.12;
const HOP = 0.1;
const MAX_LIFT = 0.18;
/** Bounce heights, normalised, repeated across the bouncing letters. */
const BOUNCE = [0.67, 1, 0.8, 0.6];
type Ripple = { origin: number; start: number };
export type FooterWordmarkProps = {
/** The word, usually the business name. */
word: string;
/** How many leading letters bounce with scroll speed. Defaults to about half. */
bounce?: number;
/** Font size; a CSS length or clamp(). */
size?: string;
className?: string;
};
export function FooterWordmark({ word, bounce, size = "clamp(3.5rem, 24vw, 23rem)", className }: FooterWordmarkProps) {
const letters = useMemo(() => Array.from(word), [word]);
const bouncing = Math.min(letters.length, bounce ?? Math.ceil(letters.length / 2));
const rootRef = useRef<HTMLDivElement>(null);
const rowRef = useRef<HTMLDivElement>(null);
const letterRefs = useRef<Array<HTMLSpanElement | null>>([]);
const reduced = useReducedMotion();
const { scrollY, scrollYProgress } = useScroll({ target: rootRef, offset: ["start end", "end end"] });
const rise = useTransform(scrollYProgress, [0, 1], reduced ? ["0%", "0%"] : ["38%", "0%"]);
const energy = useSpring(
useTransform(useVelocity(scrollY), (v) => Math.min(1, Math.abs(v) / 2400)),
{ stiffness: 120, damping: 22, mass: 0.5 },
);
const offsets = useMemo(() => letters.map(() => motionValue(0)), [letters]);
const state = useRef({
font: 0,
centers: [] as number[],
pointerX: 0,
pointerActive: false,
ripple: null as Ripple | null,
lift: [] as number[],
resting: true,
});
// Measure letter positions so each letter's slice of the gradient lines up.
useEffect(() => {
const row = rowRef.current;
if (!row) return;
state.current.lift = letters.map(() => 0);
const measure = () => {
const elements = letterRefs.current.slice(0, letters.length).filter((el): el is HTMLSpanElement => Boolean(el));
const first = elements[0];
const last = elements[elements.length - 1];
if (!first || !last) return;
const start = first.offsetLeft;
const width = last.offsetLeft + last.offsetWidth - start;
state.current.font = parseFloat(getComputedStyle(row).fontSize) || 0;
state.current.centers = elements.map((el) => el.offsetLeft + el.offsetWidth / 2);
for (const el of elements) {
el.style.setProperty("--fw-word-w", `${width}px`);
el.style.setProperty("--fw-bg-x", `${start - el.offsetLeft}px`);
}
};
measure();
const observer = new ResizeObserver(measure);
observer.observe(row);
for (const el of letterRefs.current) if (el) observer.observe(el);
document.fonts?.ready.then(measure).catch(() => undefined);
return () => observer.disconnect();
}, [letters]);
useAnimationFrame((_, delta) => {
if (reduced) return;
const s = state.current;
const now = performance.now();
const hopEnergy = energy.get();
if (s.ripple && now - s.ripple.start > 1400) s.ripple = null;
if (!s.pointerActive && !s.ripple && hopEnergy < 0.005 && s.lift.every((value) => value < 0.002)) {
if (!s.resting) {
s.lift.fill(0);
offsets.forEach((offset) => offset.set(0));
s.resting = true;
}
return;
}
s.resting = false;
const sigma = s.font * 0.42 || 80;
const follow = 1 - Math.exp(-Math.min(delta, 64) / 110);
offsets.forEach((offset, index) => {
let target = 0;
if (s.pointerActive) {
const dx = s.pointerX - (s.centers[index] ?? 0);
target = Math.exp(-(dx * dx) / (2 * sigma * sigma));
}
if (s.ripple) {
const t = now - s.ripple.start - Math.abs(index - s.ripple.origin) * 70;
if (t > 0 && t < 560) target = Math.max(target, Math.sin((Math.PI * t) / 560) * 0.85);
}
const lift = (s.lift[index] ?? 0) + (target - (s.lift[index] ?? 0)) * follow;
s.lift[index] = lift;
const hopHeight = index < bouncing ? (BOUNCE[index % BOUNCE.length] ?? 0) : 0;
const hop = hopEnergy * hopHeight * Math.abs(Math.sin(now / 140 + index * 1.4));
offset.set(-s.font * Math.min(MAX_LIFT, lift * LIFT + hop * HOP));
});
});
const pointerX = (event: ReactPointerEvent) => {
const row = rowRef.current;
return row ? event.clientX - row.getBoundingClientRect().left : 0;
};
const startRipple = (x: number) => {
const { centers } = state.current;
let origin = 0;
centers.forEach((center, index) => {
if (Math.abs(center - x) < Math.abs((centers[origin] ?? 0) - x)) origin = index;
});
state.current.ripple = { origin, start: performance.now() };
};
const onPointerMove = (event: ReactPointerEvent) => {
if (reduced || event.pointerType === "touch") return;
state.current.pointerX = pointerX(event);
state.current.pointerActive = true;
};
return (
<LazyMotion features={domAnimation} strict>
<div
ref={rootRef}
data-vb="footer-wordmark"
aria-hidden="true"
className={cn(
"overflow-hidden select-none [-webkit-touch-callout:none]",
"[mask-image:linear-gradient(to_bottom,#000_39%,transparent_104%)] [-webkit-mask-image:linear-gradient(to_bottom,#000_39%,transparent_104%)]",
className,
)}
// Headroom above the letters so they can rise without being clipped.
style={{ height: `calc(${size} * 0.95)`, paddingTop: `calc(${size} * 0.2)` }}
onPointerEnter={(event) => {
if (reduced || event.pointerType === "touch") return;
onPointerMove(event);
startRipple(pointerX(event));
}}
onPointerMove={onPointerMove}
onPointerLeave={() => {
state.current.pointerActive = false;
}}
onPointerDown={(event) => {
if (reduced) return;
startRipple(pointerX(event));
}}
>
<m.div ref={rowRef} className="relative flex justify-center font-display leading-[0.86] font-bold" style={{ y: rise, fontSize: size }}>
{letters.map((letter, index) => (
<m.span
key={`${letter}-${index}`}
ref={(el: HTMLSpanElement | null) => {
letterRefs.current[index] = el;
}}
className="block bg-no-repeat px-[0.08em] py-[0.1em] text-transparent [-webkit-background-clip:text] [background-clip:text] will-change-transform"
style={{
y: offsets[index],
margin: "-0.1em -0.145em -0.1em -0.08em",
backgroundImage: "var(--vb-gradient-spectrum-text)",
backgroundSize: `var(--fw-word-w, ${letters.length * 100}%) 100%`,
backgroundPosition: `var(--fw-bg-x, ${letters.length > 1 ? (index * 100) / (letters.length - 1) : 0}%) 0`,
}}
>
{letter}
</m.span>
))}
</m.div>
</div>
</LazyMotion>
);
}components/vibebox/footer-wordmark/index.tsx
/**
* VibeBox Motion · Footer Wordmark
* Copyright (c) 2026 VibeBox. Licensed under the VibeBox Motion Free License:
* https://motion.vibeboxph.com/license
* Use it in unlimited personal, commercial and client projects.
* Do not sell or redistribute it as a component, kit, template or builder.
*/
export { FooterWordmark, type FooterWordmarkProps } from "./footer-wordmark.client";Props
| Prop | Type | Default | Description |
|---|---|---|---|
| word * | string | — | The word, usually the business name. |
| bounce | number | half the letters | How many leading letters bounce with scroll speed. |
| size | string | "clamp(3.5rem, 24vw, 23rem)" | Font size; a CSS length or clamp(). |
| className | string | — | Merged with cn(); add spacing with it. |
Accessibility and motion
- Keyboard
- Decorative and not focusable.
- Screen readers
- Hidden (aria-hidden); the footer's own text names the business.
- Touch
- A tap sends a ripple through the letters; the cursor lift is mouse and pen only.
- Reduced motion
- The word at rest, fully risen, in the spectrum gradient.
Edge cases and limits
- Before fonts load the gradient slices are estimated; they snap into place once measured.
- Very long words shrink; set a smaller
sizeor use the Equalizer Wordmark.
Related

A header wordmark whose leading letters bounce like equalizer bars with scroll speed, with a spectrum tint on hover.
Brand and proof · Motion

Context Cursor
FreeA cursor label that says what a click will do ("View", "Play", "Drag") and changes with the target. Fine pointers only.
Cursor and touch · Motion