Swish UI
All componentsText Free

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.

Found on