Proximity font weight
Letters in a variable font get heavier and a little larger the closer the pointer comes to them.
It imports two small hooks from @/lib/motion and the colour tokens; the zip includes both.
Demo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import { useInView, usePrefersReducedMotion } from "@/lib/motion";
import styles from "./demo.module.css";
const WORD = "Comporta";
const LIGHT = 300;
const HEAVY = 900;
export default function Demo() {
const root = useRef<HTMLDivElement>(null);
const letters = useRef<(HTMLSpanElement | null)[]>([]);
const centres = useRef<{ x: number; y: number }[]>([]);
const inView = useInView(root);
const reduced = usePrefersReducedMotion();
const [noHover, setNoHover] = useState(false);
useEffect(() => setNoHover(matchMedia("(hover: none)").matches), []);
const apply = (amounts: number[]) =>
letters.current.forEach((el, i) => {
if (!el) return;
el.style.fontWeight = String(Math.round(LIGHT + (HEAVY - LIGHT) * amounts[i]));
el.style.scale = String(1 + 0.08 * amounts[i]);
});
const measure = () => {
const box = root.current!.getBoundingClientRect();
centres.current = letters.current.map((el) => {
const r = el!.getBoundingClientRect();
return { x: r.left + r.width / 2 - box.left, y: r.top + r.height / 2 - box.top };
});
};
const move = (event: React.PointerEvent) => {
if (reduced || noHover || event.pointerType === "touch") return;
if (!centres.current.length) measure();
const box = root.current!.getBoundingClientRect();
const x = event.clientX - box.left;
const y = event.clientY - box.top;
const radius = Math.max(120, box.width * 0.22);
apply(centres.current.map((c) => {
const k = Math.max(0, 1 - Math.hypot(x - c.x, (y - c.y) * 0.6) / radius);
return k * k * (3 - 2 * k);
}));
};
const leave = () => {
centres.current = [];
if (!reduced && !noHover) apply(letters.current.map(() => 0));
};
useEffect(() => {
if (!noHover || reduced || !inView) return;
let raf = 0;
const frame = (now: number) => {
const t = now / 1000;
apply(letters.current.map((_, i) => Math.pow((Math.sin(t * 2 - i * 0.6) + 1) / 2, 3)));
raf = requestAnimationFrame(frame);
};
raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, [noHover, reduced, inView]);
return (
<div
ref={root}
className={`${styles.root} ${reduced ? styles.still : ""} ${noHover ? styles.wave : ""}`}
onPointerEnter={() => { centres.current = []; }}
onPointerMove={move}
onPointerLeave={leave}
>
<p className={styles.eyebrow}>Region of the month</p>
<h2 className={styles.word} aria-label={WORD}>
{[...WORD].map((c, i) => (
<span key={i} aria-hidden="true" ref={(el) => { letters.current[i] = el; }} className={styles.letter}>
{c}
</span>
))}
</h2>
<p className={styles.sub}>31 villas, twelve minutes from the beach</p>
</div>
);
}
demo.module.css
.root { container-type: size; width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; padding: 16px; text-align: center; }
.eyebrow { font-size: 13px; font-weight: 600; color: var(--accent-text); }
.word { font-size: clamp(52px, min(15cqi, 26cqh), 150px); line-height: 1.05; letter-spacing: -0.03em; font-weight: 300; white-space: nowrap; }
.letter { display: inline-block; font-weight: 300; transform-origin: 50% 85%; transition: font-weight 0.3s var(--out), scale 0.3s var(--out); }
.wave .letter { transition: none; }
.still .letter { font-weight: 600; transition: none; }
.sub { font-size: 14px; color: var(--muted); }
@media (prefers-reduced-motion: reduce) { .letter { font-weight: 600; transition: none; } }
Use it for
- One display word on a brand or destination page
- Interactive type in a hero where the font is part of the identity
Avoid
- Body copy and buttons, the shifting weight hurts reading
- Fonts without a weight axis, the steps look broken
How it works
Each letter is an inline-block span. When the pointer enters, letter centres are measured relative to the demo root, then every pointermove computes the distance to each centre, maps it through a smoothstep falloff and writes font-weight from 300 to 900 plus a scale up to 1.08. Figtree is a variable font, so every weight in between renders and a short transition smooths the change. Devices without hover run a gentle sine wave across the letters in a requestAnimationFrame loop that only runs while the demo is in view.
Accessibility
The heading has an aria-label with the word and the letter spans are aria-hidden, so it is read as one word. Touch input never drives the effect, it gets the auto wave instead. Reduced motion shows the word at a steady 600 weight.