Thermal
import { useEffect, useRef } from 'react';
const css = `
.btn-thermal {
position: relative;
display: grid;
place-items: center;
width: 222px;
height: 60px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: crosshair;
overflow: hidden;
}
.btn-thermal:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-thermal canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-thermal__label {
position: relative;
padding: 3px 10px;
border-radius: 6px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 78%, transparent);
}
.btn-thermal__temp {
position: absolute;
right: 8px;
bottom: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
color: light-dark(#5f626a, #a7abb2);
font-variant-numeric: tabular-nums;
}
`;
const CELL = 6;
const LEVELS = 6; // thermal-camera banding
export function ThermalButton() {
const btn = useRef<HTMLButtonElement>(null);
const canvas = useRef<HTMLCanvasElement>(null);
const temp = useRef<HTMLSpanElement>(null);
const pointer = useRef<{ x: number; y: number } | null>(null);
useEffect(() => {
const el = canvas.current!;
const ctx = el.getContext('2d')!;
const w = el.clientWidth;
const h = el.clientHeight;
const dpr = Math.min(2, devicePixelRatio || 1);
el.width = w * dpr;
el.height = h * dpr;
ctx.scale(dpr, dpr);
const cols = Math.ceil(w / CELL);
const rows = Math.ceil(h / CELL);
let heat = new Float32Array(cols * rows);
let next = new Float32Array(cols * rows);
const still = matchMedia('(prefers-reduced-motion: reduce)').matches;
let raf = 0;
const frame = () => {
const p = pointer.current;
if (p) {
// Deposit heat in a soft disc around the cursor.
const cx = p.x / CELL;
const cy = p.y / CELL;
for (let y = Math.max(0, Math.floor(cy - 4)); y < Math.min(rows, cy + 4); y++)
for (let x = Math.max(0, Math.floor(cx - 4)); x < Math.min(cols, cx + 4); x++) {
const d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);
heat[y * cols + x] = Math.min(1, heat[y * cols + x] + Math.max(0, 1 - d / 3.6) * 0.22);
}
}
// Diffuse into neighbours and cool.
let peak = 0;
for (let y = 0; y < rows; y++)
for (let x = 0; x < cols; x++) {
const i = y * cols + x;
const n =
(x > 0 ? heat[i - 1] : 0) +
(x < cols - 1 ? heat[i + 1] : 0) +
(y > 0 ? heat[i - cols] : 0) +
(y < rows - 1 ? heat[i + cols] : 0);
next[i] = (heat[i] * 0.6 + n * 0.1) * 0.985;
if (next[i] > peak) peak = next[i];
}
[heat, next] = [next, heat];
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = getComputedStyle(btn.current!).color;
for (let i = 0; i < heat.length; i++) {
const q = Math.floor(heat[i] * LEVELS) / LEVELS;
if (q <= 0) continue;
ctx.globalAlpha = q * 0.85;
// Hotter cells fill more of their square, like a dithered camera readout.
const s = CELL * (0.45 + q * 0.55);
ctx.fillRect((i % cols) * CELL + (CELL - s) / 2, Math.floor(i / cols) * CELL + (CELL - s) / 2, s, s);
}
ctx.globalAlpha = 1;
if (temp.current) temp.current.textContent = `${(21 + peak * 46).toFixed(1)}°C`;
raf = requestAnimationFrame(frame);
};
if (!still) raf = requestAnimationFrame(frame);
return () => cancelAnimationFrame(raf);
}, []);
return (
<>
<style>
{css}
</style>
<button
ref={btn}
type="button"
className="btn-thermal"
onPointerMove={(e) => {
const r = btn.current!.getBoundingClientRect();
pointer.current = { x: e.clientX - r.left, y: e.clientY - r.top };
}}
onPointerLeave={() => (pointer.current = null)}
>
<canvas ref={canvas} aria-hidden="true" />
<span className="btn-thermal__label">Thermal</span>
<span ref={temp} className="btn-thermal__temp" aria-hidden="true">
21.0°C
</span>
</button>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initBtnThermal(el) on the mounted element instead.
<button type="button" class="btn-thermal">
<canvas aria-hidden="true"></canvas>
<span class="btn-thermal__label">Thermal</span>
<span class="btn-thermal__temp" aria-hidden="true">21.0°C</span>
</button>.btn-thermal {
position: relative;
display: grid;
place-items: center;
width: 222px;
height: 60px;
padding: 0;
border: 1px solid light-dark(#dbdde1, #2e3035);
border-radius: 12px;
background: light-dark(#fbfcfd, #121417);
color: light-dark(#373a40, #f1f2f3);
font-family: inherit;
font-size: 14px;
font-weight: 600;
letter-spacing: 0.05em;
cursor: crosshair;
overflow: hidden;
}
.btn-thermal:focus-visible { outline: 2px solid light-dark(#42454c, #e5e6e9); outline-offset: 3px; }
.btn-thermal canvas { position: absolute; inset: 0; width: 100%; height: 100%; }
.btn-thermal__label {
position: relative;
padding: 3px 10px;
border-radius: 6px;
background: color-mix(in oklab, light-dark(#fbfcfd, #121417) 78%, transparent);
}
.btn-thermal__temp {
position: absolute;
right: 8px;
bottom: 5px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
color: light-dark(#5f626a, #a7abb2);
font-variant-numeric: tabular-nums;
}function initBtnThermal(root) {
var canvas = root.querySelector('canvas');
var temp = root.querySelector('.btn-thermal__temp');
if (!canvas || !temp) return;
if (matchMedia('(prefers-reduced-motion: reduce)').matches) return;
var CELL = 6;
var LEVELS = 6; // thermal-camera banding
var ctx = canvas.getContext('2d');
var w = canvas.clientWidth;
var h = canvas.clientHeight;
var dpr = Math.min(2, devicePixelRatio || 1);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.scale(dpr, dpr);
var cols = Math.ceil(w / CELL);
var rows = Math.ceil(h / CELL);
var heat = new Float32Array(cols * rows);
var next = new Float32Array(cols * rows);
var pointer = null;
var raf = 0;
function frame() {
if (pointer) {
// Deposit heat in a soft disc around the cursor.
var cx = pointer.x / CELL;
var cy = pointer.y / CELL;
for (var y = Math.max(0, Math.floor(cy - 4)); y < Math.min(rows, cy + 4); y++) {
for (var x = Math.max(0, Math.floor(cx - 4)); x < Math.min(cols, cx + 4); x++) {
var d = Math.hypot(x + 0.5 - cx, y + 0.5 - cy);
heat[y * cols + x] = Math.min(1, heat[y * cols + x] + Math.max(0, 1 - d / 3.6) * 0.22);
}
}
}
// Diffuse into neighbours and cool.
var peak = 0;
for (var j = 0; j < rows; j++) {
for (var i = 0; i < cols; i++) {
var k = j * cols + i;
var n = (i > 0 ? heat[k - 1] : 0) + (i < cols - 1 ? heat[k + 1] : 0) + (j > 0 ? heat[k - cols] : 0) + (j < rows - 1 ? heat[k + cols] : 0);
next[k] = (heat[k] * 0.6 + n * 0.1) * 0.985;
if (next[k] > peak) peak = next[k];
}
}
var swap = heat;
heat = next;
next = swap;
ctx.clearRect(0, 0, w, h);
ctx.fillStyle = getComputedStyle(root).color;
for (var c = 0; c < heat.length; c++) {
var q = Math.floor(heat[c] * LEVELS) / LEVELS;
if (q <= 0) continue;
ctx.globalAlpha = q * 0.85;
// Hotter cells fill more of their square, like a dithered camera readout.
var s = CELL * (0.45 + q * 0.55);
ctx.fillRect((c % cols) * CELL + (CELL - s) / 2, Math.floor(c / cols) * CELL + (CELL - s) / 2, s, s);
}
ctx.globalAlpha = 1;
temp.textContent = (21 + peak * 46).toFixed(1) + '°C';
raf = requestAnimationFrame(frame);
}
function move(e) {
var r = root.getBoundingClientRect();
pointer = { x: e.clientX - r.left, y: e.clientY - r.top };
}
function leave() { pointer = null; }
root.addEventListener('pointermove', move);
root.addEventListener('pointerleave', leave);
raf = requestAnimationFrame(frame);
return function () {
cancelAnimationFrame(raf);
root.removeEventListener('pointermove', move);
root.removeEventListener('pointerleave', leave);
};
}
document.querySelectorAll('.btn-thermal').forEach(initBtnThermal);Goal: Create a "Thermal" button.
Visual behavior: A heat camera as a button. The cursor leaves a warm trail that blooms outward in blocky cells and cools slowly, banded like a thermal readout, with the peak temperature shown in the corner.
Technique:
- A canvas behind the label is a grid of 6px cells holding heat values in a Float32Array.
- Each frame the cursor deposits heat in a soft disc; then every cell becomes 0.6 of itself plus 0.1 of each neighbour, times 0.985, so heat spreads and cools.
- Values are quantised to six bands; hotter cells draw a larger, more opaque square, like a dithered camera readout.
- The ink comes from the button's computed colour, and a monospace readout shows 21 degrees plus the peak heat.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The canvas and temperature are aria-hidden. Under prefers-reduced-motion nothing is drawn.
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 "Thermal" button.
Visual behavior: A heat camera as a button. The cursor leaves a warm trail that blooms outward in blocky cells and cools slowly, banded like a thermal readout, with the peak temperature shown in the corner.
Technique:
- A canvas behind the label is a grid of 6px cells holding heat values in a Float32Array.
- Each frame the cursor deposits heat in a soft disc; then every cell becomes 0.6 of itself plus 0.1 of each neighbour, times 0.985, so heat spreads and cools.
- Values are quantised to six bands; hotter cells draw a larger, more opaque square, like a dithered camera readout.
- The ink comes from the button's computed colour, and a monospace readout shows 21 degrees plus the peak heat.
Accessibility: A real <button> with a visible label; decoration is aria-hidden. The canvas and temperature are aria-hidden. Under prefers-reduced-motion nothing is drawn.
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
A heat camera as a button. The cursor leaves a warm trail that blooms outward in blocky cells and cools slowly, banded like a thermal readout, with the peak temperature shown in the corner.
- Dependencies
- None
- Difficulty
- Advanced
- Triggers
- hover, mouse tracking, continuous
- Techniques
- raf, mouse tracking, event position
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <button> with a visible label; decoration is aria-hidden. The canvas and temperature are aria-hidden. Under prefers-reduced-motion nothing is drawn.