Footlight
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>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnFootlight(el) on the mounted element instead.
<button type="button" class="btn-footlight" aria-label="Enter">
<span class="btn-footlight__pool" aria-hidden="true"></span>
<span class="btn-footlight__word" aria-hidden="true">
<span class="btn-footlight__letter" style="--i:0">
<span class="btn-footlight__cast">E</span>
<span class="btn-footlight__face">E</span>
</span>
<span class="btn-footlight__letter" style="--i:1">
<span class="btn-footlight__cast">N</span>
<span class="btn-footlight__face">N</span>
</span>
<span class="btn-footlight__letter" style="--i:2">
<span class="btn-footlight__cast">T</span>
<span class="btn-footlight__face">T</span>
</span>
<span class="btn-footlight__letter" style="--i:3">
<span class="btn-footlight__cast">E</span>
<span class="btn-footlight__face">E</span>
</span>
<span class="btn-footlight__letter" style="--i:4">
<span class="btn-footlight__cast">R</span>
<span class="btn-footlight__face">R</span>
</span>
</span>
<span class="btn-footlight__bulb" aria-hidden="true"></span>
</button>.btn-footlight { flex: none; --lx: 196px; --ly: 28px; --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: 52px; 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; }
}function initBtnFootlight(root) {
// The baseline of the word: every shadow hinges on it.
var BASE = 104;
var SPAN = 58;
var REST = { x: 196, y: 28 };
var calm = matchMedia('(prefers-reduced-motion: reduce)').matches;
var letters = [].map.call(root.querySelectorAll('.btn-footlight__letter'), function (node) {
return { x: node.offsetLeft + node.offsetWidth / 2, cast: node.firstElementChild };
});
var pointer = null;
var lx = REST.x;
var ly = REST.y;
var raf = 0;
var running = false;
var press = 'b';
function clamp(v, max) {
return Math.max(-max, Math.min(max, v));
}
function tick(t) {
var 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;
root.style.setProperty('--lx', lx.toFixed(1) + 'px');
root.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.
var stretch = clamp(-(BASE - ly) / SPAN, 1.8);
letters.forEach(function (l) {
l.cast.style.transform = 'matrix(1,0,' + clamp(-(l.x - lx) / SPAN, 2.8).toFixed(3) + ',' + stretch.toFixed(3) + ',0,0)';
});
raf = requestAnimationFrame(tick);
}
var io = new IntersectionObserver(function (entries) {
if (entries[0].isIntersecting && !running) {
running = true;
raf = requestAnimationFrame(tick);
} else if (!entries[0].isIntersecting && running) {
running = false;
cancelAnimationFrame(raf);
}
});
function onMove(e) {
var box = root.getBoundingClientRect();
pointer = { x: e.clientX - box.left, y: e.clientY - box.top };
}
function onLeave() {
pointer = null;
}
// Two identical keyframes, swapped, so the hop restarts on every press.
function onClick() {
press = press === 'a' ? 'b' : 'a';
root.setAttribute('data-press', press);
}
io.observe(root);
root.addEventListener('pointermove', onMove);
root.addEventListener('pointerleave', onLeave);
root.addEventListener('click', onClick);
return function () {
io.disconnect();
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', onMove);
root.removeEventListener('pointerleave', onLeave);
root.removeEventListener('click', onClick);
};
}
document.querySelectorAll('.btn-footlight').forEach(initBtnFootlight);Goal: Create a "Footlight" button.
Visual behavior: 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.
Technique:
- Each letter holds a copy of itself as its shadow, hinged on the baseline. Every frame the light eases towards the cursor, or drifts when there is none, and each shadow gets a matrix: its shear comes from how far the letter is from the light sideways and its stretch from how far back the light is, so the shadows fan out and pass through zero smoothly as the light crosses the word.
- A mask fades each shadow towards its tip, and a pool of light on the floor follows the bulb in dark mode, where a shadow needs something lit to fall on.
- Press swaps between two identical keyframes so the hop restarts every time, staggered letter by letter.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: neutral only. Black, white and grays, no hues. Use the color values above as written, or my project's closest neutral tokens.
Return a single self-contained component. No dependencies.Goal: Create a "Footlight" button.
Visual behavior: 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.
Technique:
- Each letter holds a copy of itself as its shadow, hinged on the baseline. Every frame the light eases towards the cursor, or drifts when there is none, and each shadow gets a matrix: its shear comes from how far the letter is from the light sideways and its stretch from how far back the light is, so the shadows fan out and pass through zero smoothly as the light crosses the word.
- A mask fades each shadow towards its tip, and a pool of light on the floor follows the bulb in dark mode, where a shadow needs something lit to fall on.
- Press swaps between two identical keyframes so the hop restarts every time, staggered letter by letter.
Accessibility: 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.
My stack: {{USER_STACK}}
My styling: {{USER_STYLING}}
My constraints: {{USER_CONSTRAINTS}}
My colors: use my project's existing color tokens. The color values above are grayscale placeholders. Map each one by its role (surface, text, border, accent, glow) to my theme, and keep their relative lightness so contrast and the effect still read.
Return a single self-contained component. No dependencies.Paste into Claude, ChatGPT or Cursor after filling in {{USER_STACK}}, {{USER_STYLING}} and {{USER_CONSTRAINTS}}. Neutral keeps the greys you see here; Match my project asks the model to map them onto your own colour tokens.
Free to use in any project, commercial included. MIT-0, no attribution required. Licence
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.