Footlight

No dependencies
Code format
React + Tailwind
import { useEffect, useRef, useState, type CSSProperties } from 'react';

const WORD = 'ENTER';
// The baseline of the word inside the button: every shadow hinges on it.
const BASE = 104;
// How far a shadow stretches per pixel the light moves away.
const SPAN = 58;
const REST = { x: 196, y: 28 };

const css = `
  .btn-footlight { flex: none; --lx: ${REST.x}px; --ly: ${REST.y}px; --pool: light-dark(transparent, color-mix(in oklab, light-dark(#50535b, #c6c8cd) 58%, transparent)); --cast: light-dark(color-mix(in oklab, light-dark(#373a40, #f1f2f3) 52%, transparent), light-dark(#fbfcfd, #121417)); position: relative; display: block; width: 264px; height: 196px; padding: 0; border: 0; border-radius: 14px; background: none; color: light-dark(#373a40, #f1f2f3); cursor: pointer; }
  .btn-footlight:focus-visible { outline: 2px solid light-dark(#50535b, #c6c8cd); outline-offset: 2px; }
  .btn-footlight__pool { position: absolute; inset: -120px; background: radial-gradient(232px 156px at calc(var(--lx) + 120px) calc(var(--ly) + 170px), var(--pool), transparent); pointer-events: none; }
  .btn-footlight__word { position: absolute; left: 0; right: 0; top: ${BASE - 52}px; display: flex; justify-content: center; gap: 1px; font: 900 56px/58px 'Arial Black', Impact, system-ui, sans-serif; letter-spacing: -0.02em; }
  .btn-footlight__letter { position: relative; display: block; }
  .btn-footlight__cast { position: absolute; left: 0; top: 0; color: var(--cast); transform: matrix(1, 0, 0.8, -1.3, 0, 0); transform-origin: 50% 89.6%; -webkit-mask-image: linear-gradient(to top, #000 34%, transparent 100%); mask-image: linear-gradient(to top, #000 34%, transparent 100%); }
  .btn-footlight__face { position: relative; display: block; }
  .btn-footlight__bulb { position: absolute; left: 0; top: 0; width: 8px; height: 8px; margin: -4px; border-radius: 50%; background: light-dark(#373a40, #f1f2f3); box-shadow: 0 0 0 3px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 20%, transparent), 0 0 22px 7px color-mix(in oklab, light-dark(#373a40, #f1f2f3) 36%, transparent); transform: translate(var(--lx), var(--ly)); pointer-events: none; }
  .btn-footlight[data-press='a'] .btn-footlight__face { animation: btn-footlight-hop-a 0.56s cubic-bezier(0.3, 0, 0.4, 1) calc(var(--i) * 45ms) backwards; }
  .btn-footlight[data-press='b'] .btn-footlight__face { animation: btn-footlight-hop-b 0.56s cubic-bezier(0.3, 0, 0.4, 1) calc(var(--i) * 45ms) backwards; }
  .btn-footlight[data-press='a'] .btn-footlight__pool { animation: btn-footlight-flash-a 0.6s ease-out; }
  .btn-footlight[data-press='b'] .btn-footlight__pool { animation: btn-footlight-flash-b 0.6s ease-out; }
  @keyframes btn-footlight-hop-a {
    0% { transform: none; }
    18% { transform: scale(1.06, 0.88); transform-origin: 50% 90%; }
    52% { transform: translateY(-22px) scale(0.96, 1.05); }
    84% { transform: scale(1.08, 0.86); transform-origin: 50% 90%; }
    100% { transform: none; }
  }
  @keyframes btn-footlight-hop-b {
    0% { transform: none; }
    18% { transform: scale(1.06, 0.88); transform-origin: 50% 90%; }
    52% { transform: translateY(-22px) scale(0.96, 1.05); }
    84% { transform: scale(1.08, 0.86); transform-origin: 50% 90%; }
    100% { transform: none; }
  }
  @keyframes btn-footlight-flash-a {
    30% { opacity: 1; transform: scale(1.5); }
  }
  @keyframes btn-footlight-flash-b {
    30% { opacity: 1; transform: scale(1.5); }
  }
  @media (prefers-reduced-motion: reduce) {
    .btn-footlight[data-press] .btn-footlight__face, .btn-footlight[data-press] .btn-footlight__pool { animation: none; }
  }
`;

export function FootlightButton() {
  const root = useRef<HTMLButtonElement>(null);
  const [press, setPress] = useState('');

  useEffect(() => {
    const el = root.current;
    if (!el) return;
    const calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
    const letters = [...el.querySelectorAll<HTMLElement>('.btn-footlight__letter')].map((node) => ({
      x: node.offsetLeft + node.offsetWidth / 2,
      cast: node.firstElementChild as HTMLElement,
    }));
    let pointer: { x: number; y: number } | null = null;
    let lx = REST.x;
    let ly = REST.y;
    let raf = 0;
    let running = false;
    const tick = (t: number) => {
      const goal = pointer ?? (calm ? REST : { x: 132 + Math.sin(t / 1500) * 104, y: 30 + Math.sin(t / 830) * 12 });
      lx += (goal.x - lx) * 0.12;
      ly += (goal.y - ly) * 0.12;
      el.style.setProperty('--lx', `${lx.toFixed(1)}px`);
      el.style.setProperty('--ly', `${ly.toFixed(1)}px`);
      // A point light throws each letter's shadow straight away from itself, so
      // the slant depends on the letter and the length on how far back the light is.
      const stretch = Math.max(-1.8, Math.min(1.8, -(BASE - ly) / SPAN));
      for (const { x, cast } of letters) {
        const lean = Math.max(-2.8, Math.min(2.8, -(x - lx) / SPAN));
        cast.style.transform = `matrix(1,0,${lean.toFixed(3)},${stretch.toFixed(3)},0,0)`;
      }
      raf = requestAnimationFrame(tick);
    };
    const io = new IntersectionObserver(([entry]) => {
      if (entry.isIntersecting && !running) {
        running = true;
        raf = requestAnimationFrame(tick);
      } else if (!entry.isIntersecting && running) {
        running = false;
        cancelAnimationFrame(raf);
      }
    });
    io.observe(el);
    const move = (e: PointerEvent) => {
      const box = el.getBoundingClientRect();
      pointer = { x: e.clientX - box.left, y: e.clientY - box.top };
    };
    const leave = () => {
      pointer = null;
    };
    el.addEventListener('pointermove', move);
    el.addEventListener('pointerleave', leave);
    return () => {
      io.disconnect();
      cancelAnimationFrame(raf);
      el.removeEventListener('pointermove', move);
      el.removeEventListener('pointerleave', leave);
    };
  }, []);

  return (
    <>
      <style>
        {css}
      </style>
      <button ref={root} type="button" className="btn-footlight" aria-label="Enter" data-press={press || undefined} onClick={() => setPress((p) => (p === 'a' ? 'b' : 'a'))}>
        <span className="btn-footlight__pool" aria-hidden="true" />
        <span className="btn-footlight__word" aria-hidden="true">
          {[...WORD].map((ch, i) => (
            <span key={i} className="btn-footlight__letter" style={{ '--i': i } as CSSProperties}>
              <span className="btn-footlight__cast">{ch}</span>
              <span className="btn-footlight__face">{ch}</span>
            </span>
          ))}
        </span>
        <span className="btn-footlight__bulb" aria-hidden="true" />
      </button>
    </>
  );
}

Free to use in any project, commercial included. MIT-0, no attribution required. Licence

Install with shadcn

npx shadcn@latest add @movin/button-footlight
Source on GitHub

About this component

No box at all: a word stands on the floor and a small light follows your cursor, throwing long shadows that fan out and swing around every letter. Press and the letters hop.

Dependencies
None
Difficulty
Advanced
Triggers
mouse tracking, click, delay stagger
Techniques
raf, event position, css variables, keyframes
Reduced motion
Respected
Theme
Light and dark (follows your page's color-scheme)

A real button named by aria-label, with the letters and shadows aria-hidden. It works from the keyboard and shows a focus ring. Under prefers-reduced-motion the light rests until the cursor moves it and the letters do not hop.

More buttons

See all
Gumball
Honk
Ransom note
Scribble