Skip to content
Motion
FreeIn reviewv1.0.0

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-wordmark

Controls

5
<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

  1. 1Add the VibeBox registry to components.json:
    {
      "registries": {
        "@vibebox": "https://motion.vibeboxph.com/r/{name}.json"
      }
    }
  2. 2Add the component with the shadcn CLI:
    npx shadcn@latest add @vibebox/footer-wordmark
  3. 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 files
components/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

PropTypeDefaultDescription
word *string—The word, usually the business name.
bouncenumberhalf the lettersHow many leading letters bounce with scroll speed.
sizestring"clamp(3.5rem, 24vw, 23rem)"Font size; a CSS length or clamp().
classNamestring—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 size or use the Equalizer Wordmark.
  • A wordmark in a header with letters at different heights

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

    Brand and proof · Motion

  • A small label saying View follows the pointer over project cards

    A cursor label that says what a click will do ("View", "Play", "Drag") and changes with the target. Fine pointers only.

    Cursor and touch · Motion

↑ ↓ to move↵ to openesc to close