Variable Type

Hovered character pushes a variable font's weight and width to peak, neighbors ripple outward with quadratic falloff

variable-fonthoverripplefont-variation-settingscss
Preview

Source Code

"use client";

import { useId } from "react";

interface VariableTypeProps {
  text?: string;
  maxWeight?: number;
  baseWeight?: number;
  maxWidth?: number;
  baseWidth?: number;
  duration?: number;
  radius?: number;
  className?: string;
}

export default function VariableType({
  text = "Hover me",
  maxWeight = 900,
  baseWeight = 400,
  maxWidth = 125,
  baseWidth = 100,
  duration = 400,
  radius = 3,
  className = "",
}: VariableTypeProps) {
  const rawId = useId();
  const cls = `vt-${rawId.replace(/[^a-zA-Z0-9]/g, "")}`;

  const stops = Array.from({ length: radius }, (_, i) => {
    const d = i + 1;
    const t = 1 - (d / (radius + 1)) ** 2;
    return {
      w: Math.round(baseWeight + (maxWeight - baseWeight) * t),
      wd: Math.round((baseWidth + (maxWidth - baseWidth) * t) * 100) / 100,
    };
  });

  const siblingRules = stops
    .map((s, idx) => {
      const d = idx + 1;
      const forward = `.${cls} .vtc:hover` + " + .vtc".repeat(d);
      const inner = "+ .vtc ".repeat(d - 1) + "+ .vtc:hover";
      const backward = `.${cls} .vtc:has(${inner})`;
      return `${forward}, ${backward} { font-variation-settings: "wght" ${s.w}, "wdth" ${s.wd}; }`;
    })
    .join("\n");

  const css = `
    .${cls} { display: inline-block; }
    .${cls} .vtc {
      display: inline-block;
      font-variation-settings: "wght" ${baseWeight}, "wdth" ${baseWidth};
      transition: font-variation-settings ${duration}ms cubic-bezier(0.34, 1.56, 0.64, 1);
      will-change: font-variation-settings;
    }
    .${cls} .vtc:hover { font-variation-settings: "wght" ${maxWeight}, "wdth" ${maxWidth}; }
    ${siblingRules}
    @media (prefers-reduced-motion: reduce) {
      .${cls} .vtc { transition: none; }
      .${cls} .vtc:hover,
      .${cls} .vtc:has(+ .vtc:hover),
      .${cls} .vtc:hover + .vtc { font-variation-settings: "wght" ${baseWeight}, "wdth" ${baseWidth}; }
    }
  `;

  const chars = Array.from(text);

  return (
    <span className={`${cls} ${className}`} aria-label={text}>
      <style>{css}</style>
      {chars.map((c, i) => (
        <span key={i} className="vtc" aria-hidden="true">
          {c === " " ? " " : c}
        </span>
      ))}
    </span>
  );
}