A two-word poster headline where each letter stretches vertically as the pointer passes, and its neighbours stretch a little less, so a bump travels along the word the way icons swell in a dock. The top word grows upwards from its baseline and the bottom word grows downwards from its cap line, which makes the pair open like a mouth. With no pointer on it a slow wave runs back and forth so the effect shows by itself. The demo is a festival poster with a date strip and a ticket button. On phones the type scales with the screen width and dragging a finger across a word drives the bump.
// Adapted from Eldora UI (https://github.com/karthikmudunuri/eldoraui/blob/main/apps/www/registry/eldoraui/dock-text.tsx)
// Copyright (c) Mudunuri Bhaskara Karthikeya Varma. MIT License: https://we.inc/components/licenses
import { useEffect, useRef, useState } from "react";
import type { PointerEvent } from "react";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowUpRight } from "lucide-react";
const lines = [
{ text: "LOUDER", down: false },
{ text: "TOGETHER", down: true },
];
const facts = [
{ label: "Dates", value: "14 to 16 August" },
{ label: "Where", value: "Old Tannery Yard, Brackwell" },
{ label: "Stages", value: "Four, one in a barn" },
];
const PEAK = 0.55; // extra height of the letter under the pointer (0.55 = 55% taller)
const FALLOFF = 0.14; // how much of that each neighbouring letter loses
/**
* A word whose letters grow taller near the pointer, like icons in a dock.
* `down` stretches the letters downwards from the top edge instead of upwards.
*/
function StretchWord({ text, down, idle, reduce }: { text: string; down: boolean; idle: number; reduce: boolean }) {
const ref = useRef<HTMLSpanElement>(null);
const [hovered, setHovered] = useState<number | null>(null);
const onMove = (e: PointerEvent<HTMLSpanElement>) => {
const el = ref.current;
if (!el) return;
let best = 0;
let bestDist = Infinity;
Array.from(el.children).forEach((child, i) => {
const r = child.getBoundingClientRect();
const d = Math.abs(e.clientX - (r.left + r.width / 2));
if (d < bestDist) {
bestDist = d;
best = i;
}
});
setHovered(best);
};
// With no pointer on the word, a slow wave runs through it so the effect is visible.
const focus = hovered ?? (reduce ? null : idle);
return (
<span
ref={ref}
aria-hidden="true"
onPointerMove={onMove}
onPointerDown={onMove}
onPointerLeave={() => setHovered(null)}
className="flex touch-pan-y select-none justify-center"
>
{text.split("").map((letter, i) => (
<motion.span
key={i}
className="inline-block"
style={{ transformOrigin: down ? "50% 0%" : "50% 100%" }}
animate={{ scaleY: focus === null ? 1 : Math.max(1, 1 + PEAK - Math.abs(i - focus) * FALLOFF) }}
transition={{ type: "spring", stiffness: 170, damping: 18, mass: 0.5 }}
>
{letter}
</motion.span>
))}
</span>
);
}
export default function HeadlineLettersThatStretchTallUnderThePointer() {
const reduce = !!useReducedMotion();
const [tick, setTick] = useState(0);
useEffect(() => {
if (reduce) return;
const id = setInterval(() => setTick((t) => t + 1), 150);
return () => clearInterval(id);
}, [reduce]);
// The idle wave bounces from one end of each word to the other.
const idleFor = (len: number, offset: number) => {
const span = len + 3;
const p = (tick + offset) % (span * 2);
return (p < span ? p : span * 2 - p) - 2;
};
return (
<section className="overflow-hidden bg-[#ff4f1f] px-4 py-14 text-black sm:px-6 sm:py-20">
<div className="mx-auto max-w-5xl">
<div className="flex items-center justify-between gap-4 border-b-2 border-black pb-4 text-xs font-bold uppercase tracking-widest sm:text-sm">
<span>Brackwell Summer Sessions</span>
<span>Year nine</span>
</div>
<h1 className="py-[9vw] text-[17vw] font-black leading-[0.86] tracking-tighter sm:py-20 sm:text-[8.5rem] lg:text-[11rem]">
<span className="sr-only">Louder together</span>
{lines.map((l, i) => (
<StretchWord key={l.text} text={l.text} down={l.down} idle={idleFor(l.text.length, i * 5)} reduce={reduce} />
))}
</h1>
<div className="grid gap-8 border-t-2 border-black pt-6 lg:grid-cols-[1fr_auto] lg:items-end">
<dl className="grid gap-5 sm:grid-cols-3">
{facts.map((f) => (
<div key={f.label}>
<dt className="text-xs font-bold uppercase tracking-widest">{f.label}</dt>
<dd className="mt-1 text-lg font-semibold leading-snug">{f.value}</dd>
</div>
))}
</dl>
<a
href="#"
className="inline-flex h-14 items-center justify-center gap-2 bg-black px-7 text-base font-bold uppercase tracking-wide text-[#ff4f1f] transition-colors hover:bg-white hover:text-black focus:outline-none focus-visible:ring-2 focus-visible:ring-black focus-visible:ring-offset-4 focus-visible:ring-offset-[#ff4f1f]"
>
Weekend passes <ArrowUpRight className="h-5 w-5" aria-hidden="true" />
</a>
</div>
</div>
</section>
);
}
Install with one command: npx shadcn@latest add https://we.inc/r/headline-letters-that-stretch-tall-under-the-pointer.json
Changing font size reflows the line, so every other letter would jump sideways. `scaleY` is a transform: the browser stretches the already drawn letter without touching layout, which keeps the word steady and the animation smooth.
On each pointer move it measures the horizontal centre of every letter and picks the nearest one. That index is stored in state, and each letter's height is worked out from its distance to that index.
It works with any text, but heavy uppercase letters look best because they share one height. Lowercase letters with ascenders and descenders stretch unevenly.
It is meant for short display words. On a wrapped sentence the stretched letters would collide with the lines above and below. Keep each word on its own line with tight leading.
Yes. It uses pointer events, so pressing or dragging a finger across a word moves the bump. Vertical scrolling still works because the words only claim horizontal drags.
Adapted from Dock Text in Eldora UI, used under its open-source licence. Licences and credits
Related: Headline Letters That React to Cursor Proximity, Poster Headline with 3D Flipping Letter Blocks, Headline with Moving Striped Line Shadow.
More text effects or all free UI components. Start for free to have a full site built around it.
We.Inc is an AI-powered website builder you can resell under your own brand. Launch a branded client dashboard, bill on Stripe Connect, and deliver AI-generated websites in minutes. White-label plans start at $99 a month for 25 client sites, with a 7-day free trial and no per-site fees.