Heat
import { useEffect, useRef, useState } from 'react';
const css = `
@keyframes field-heat-cool {
from {
color: light-dark(#373a40, #f1f2f3);
text-shadow: 0 0 calc(var(--h) * 9px) color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--h) * 90%), transparent);
transform: translateY(calc(var(--h) * -2px));
}
to { color: light-dark(#42454c, #e5e6e9); text-shadow: none; transform: none; }
}
.field-heat { width: 250px; }
.field-heat__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-heat__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-heat__wpm { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-heat__box { position: relative; height: 42px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-heat__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-heat__input, .field-heat__mirror { padding: 0 12px; font-family: inherit; font-size: 15px; font-kerning: none; letter-spacing: 0; }
.field-heat__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-heat__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-heat__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
.field-heat__ch { display: inline-block; animation: field-heat-cool 2.4s cubic-bezier(0.2, 0.7, 0.4, 1) both; }
.field-heat__meter { height: 4px; margin-top: 8px; border-radius: 2px; background: light-dark(#ebedf0, #232529); overflow: hidden; }
.field-heat__meter span { display: block; height: 100%; border-radius: 2px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#373a40, #f1f2f3)); transform-origin: left; transition: transform 0.3s ease; }
@media (prefers-reduced-motion: reduce) { .field-heat__ch { animation: none; } }
`;
export function HeatInput() {
const [value, setValue] = useState('');
const heat = useRef<{ h: number; id: number }[]>([]);
const last = useRef(0);
const gaps = useRef<number[]>([]);
const seq = useRef(0);
const [wpm, setWpm] = useState(0);
// Speed decays when you stop typing.
useEffect(() => {
const id = window.setInterval(() => {
if (performance.now() - last.current > 1200) setWpm((w) => (w < 1 ? 0 : w * 0.7));
}, 200);
return () => clearInterval(id);
}, []);
const change = (next: string) => {
const now = performance.now();
const gap = last.current ? now - last.current : 1000;
last.current = now;
let p = 0;
while (p < value.length && p < next.length && value[p] === next[p]) p++;
let s = 0;
while (s < value.length - p && s < next.length - p && value[value.length - 1 - s] === next[next.length - 1 - s]) s++;
const added = next.length - p - s;
// Fast keystrokes come out hot; slow ones barely warm.
const h = Math.max(0, Math.min(1, (380 - gap) / 300));
heat.current = [...heat.current.slice(0, p), ...Array.from({ length: added }, () => ({ h, id: seq.current++ })), ...heat.current.slice(heat.current.length - s)];
if (added > 0 && gap < 1500) {
gaps.current = [...gaps.current, gap].slice(-8);
const avg = gaps.current.reduce((a, b) => a + b, 0) / gaps.current.length;
setWpm(60000 / (avg * 5));
}
setValue(next);
};
return (
<>
<style>
{css}
</style>
<div className="field-heat">
<div className="field-heat__head">
<label className="field-heat__label" htmlFor="field-heat-input">
Message
</label>
<span className="field-heat__wpm" aria-hidden="true">
{Math.round(wpm)} wpm
</span>
</div>
<div className="field-heat__box">
<span className="field-heat__mirror" aria-hidden="true">
{[...value].map((c, i) => (
<span key={heat.current[i]?.id ?? i} className="field-heat__ch" style={{ '--h': heat.current[i]?.h ?? 0 } as React.CSSProperties}>
{c}
</span>
))}
</span>
<input
id="field-heat-input"
className="field-heat__input"
value={value}
maxLength={30}
placeholder="Type fast to heat it up"
autoComplete="off"
spellCheck={false}
onChange={(e) => change(e.target.value)}
/>
</div>
<div className="field-heat__meter" aria-hidden="true">
<span style={{ transform: `scaleX(${Math.min(1, wpm / 110)})` }} />
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldHeat(el) on the mounted element instead.
<div class="field-heat">
<div class="field-heat__head">
<label class="field-heat__label" for="field-heat-input">Message</label>
<span class="field-heat__wpm" aria-hidden="true">0 wpm</span>
</div>
<div class="field-heat__box">
<span class="field-heat__mirror" aria-hidden="true"></span>
<input id="field-heat-input" class="field-heat__input" maxLength="30" placeholder="Type fast to heat it up" autoComplete="off" spellCheck="false" value=""/>
</div>
<div class="field-heat__meter" aria-hidden="true">
<span style="transform:scaleX(0)"></span>
</div>
</div>@keyframes field-heat-cool {
from {
color: light-dark(#373a40, #f1f2f3);
text-shadow: 0 0 calc(var(--h) * 9px) color-mix(in oklab, light-dark(#373a40, #f1f2f3) calc(var(--h) * 90%), transparent);
transform: translateY(calc(var(--h) * -2px));
}
to { color: light-dark(#42454c, #e5e6e9); text-shadow: none; transform: none; }
}
.field-heat { width: 250px; }
.field-heat__head { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
.field-heat__label { color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-heat__wpm { color: light-dark(#5f626a, #a7abb2); font-size: 11px; font-variant-numeric: tabular-nums; }
.field-heat__box { position: relative; height: 42px; border-radius: 10px; background: light-dark(#f3f4f7, #1a1c1f); box-shadow: inset 0 0 0 1px light-dark(#dbdde1, #2e3035); transition: box-shadow 0.2s; }
.field-heat__box:focus-within { box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-heat__input, .field-heat__mirror { padding: 0 12px; font-family: inherit; font-size: 15px; font-kerning: none; letter-spacing: 0; }
.field-heat__input { position: relative; z-index: 1; width: 100%; height: 100%; border: 0; outline: 0; background: none; color: transparent; caret-color: light-dark(#373a40, #f1f2f3); }
.field-heat__input::placeholder { color: light-dark(#5f626a, #a7abb2); }
.field-heat__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#42454c, #e5e6e9); white-space: pre; pointer-events: none; }
.field-heat__ch { display: inline-block; animation: field-heat-cool 2.4s cubic-bezier(0.2, 0.7, 0.4, 1) both; }
.field-heat__meter { height: 4px; margin-top: 8px; border-radius: 2px; background: light-dark(#ebedf0, #232529); overflow: hidden; }
.field-heat__meter span { display: block; height: 100%; border-radius: 2px; background: linear-gradient(90deg, light-dark(#6d7179, #7d8087), light-dark(#373a40, #f1f2f3)); transform-origin: left; transition: transform 0.3s ease; }
@media (prefers-reduced-motion: reduce) { .field-heat__ch { animation: none; } }function initFieldHeat(root) {
var input = root.querySelector('.field-heat__input');
var mirror = root.querySelector('.field-heat__mirror');
var wpmEl = root.querySelector('.field-heat__wpm');
var meter = root.querySelector('.field-heat__meter span');
if (!input || !mirror || !wpmEl || !meter) return;
var value = input.value;
var last = 0;
var gaps = [];
var wpm = 0;
function showWpm() {
wpmEl.textContent = Math.round(wpm) + ' wpm';
meter.style.transform = 'scaleX(' + Math.min(1, wpm / 110) + ')';
}
// Speed decays when you stop typing.
var timer = setInterval(function () {
if (performance.now() - last > 1200 && wpm > 0) {
wpm = wpm < 1 ? 0 : wpm * 0.7;
showWpm();
}
}, 200);
function change() {
var next = input.value;
var now = performance.now();
var gap = last ? now - last : 1000;
last = now;
var p = 0;
while (p < value.length && p < next.length && value[p] === next[p]) p++;
var s = 0;
while (s < value.length - p && s < next.length - p && value[value.length - 1 - s] === next[next.length - 1 - s]) s++;
// Fast keystrokes come out hot; slow ones barely warm. Untouched letters keep cooling as they were.
var h = Math.max(0, Math.min(1, (380 - gap) / 300));
var spans = Array.prototype.slice.call(mirror.children);
spans.slice(p, spans.length - s).forEach(function (el) { el.remove(); });
var after = spans[spans.length - s] || null;
next.slice(p, next.length - s).split('').forEach(function (c) {
var el = document.createElement('span');
el.className = 'field-heat__ch';
el.style.setProperty('--h', String(h));
el.textContent = c;
mirror.insertBefore(el, after);
});
var added = next.length - p - s;
if (added > 0 && gap < 1500) {
gaps = gaps.concat([gap]).slice(-8);
wpm = 60000 / ((gaps.reduce(function (a, b) { return a + b; }, 0) / gaps.length) * 5);
showWpm();
}
value = next;
}
input.addEventListener('input', change);
return function () {
clearInterval(timer);
input.removeEventListener('input', change);
};
}
document.querySelectorAll('.field-heat').forEach(initFieldHeat);Goal: Create a "Heat" input.
Visual behavior: A message field that heats up with your typing speed: letters typed fast come out glowing hot and lifted, then cool to the normal colour over a couple of seconds, and a meter shows your words per minute.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- Each new character gets a heat from the gap since the previous key: (380 - gap) / 300, clamped to 0 to 1. Its cooling keyframe starts from a colour, glow (color-mix with a calc() percentage) and lift scaled by that heat.
- Edits are diffed by common prefix and suffix, so only the characters actually typed are new; untouched characters keep their element and their animation state.
- Words per minute come from the average of the last eight gaps and decay when you stop; a gradient bar scales with them.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The meter is aria-hidden. Under prefers-reduced-motion characters do not glow.
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 "Heat" input.
Visual behavior: A message field that heats up with your typing speed: letters typed fast come out glowing hot and lifted, then cool to the normal colour over a couple of seconds, and a meter shows your words per minute.
Technique:
- The real input keeps transparent text and a visible caret; an aria-hidden mirror with the same font, size and padding (and font-kerning: none) draws the characters on top, so the caret always lines up.
- Each new character gets a heat from the gap since the previous key: (380 - gap) / 300, clamped to 0 to 1. Its cooling keyframe starts from a colour, glow (color-mix with a calc() percentage) and lift scaled by that heat.
- Edits are diffed by common prefix and suffix, so only the characters actually typed are new; untouched characters keep their element and their animation state.
- Words per minute come from the average of the last eight gaps and decay when you stop; a gradient bar scales with them.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. The meter is aria-hidden. Under prefers-reduced-motion characters do not glow.
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 message field that heats up with your typing speed: letters typed fast come out glowing hot and lifted, then cool to the normal colour over a couple of seconds, and a meter shows your words per minute.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, glow, state change
- Techniques
- keyframes, css variables, text animation
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. The meter is aria-hidden. Under prefers-reduced-motion characters do not glow.