Filter3.5 kHz · Q 0.35
Wave
React + Tailwind
import { useRef, useState } from 'react';
const css = `
@property --field-wave-amp { syntax: '<length>'; inherits: true; initial-value: 0px; }
@keyframes field-wave-ride { 0%, 100% { transform: translateY(calc(var(--field-wave-amp) * 0.6)); } 50% { transform: translateY(calc(var(--field-wave-amp) * -1)); } }
@keyframes field-wave-in { from { opacity: 0; } }
.field-wave { width: 250px; }
.field-wave__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-wave__box {
position: relative;
height: 46px;
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-wave-amp 0.6s ease;
}
/* Focused, the letters ride a travelling wave; on blur the wave calms to a flat line. */
.field-wave__box:focus-within { --field-wave-amp: 5px; box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-wave__input, .field-wave__mirror { padding: 0 12px; font-family: inherit; font-size: 16px; font-weight: 600; font-kerning: none; letter-spacing: 0.3px; }
.field-wave__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-wave__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-wave__ch { display: inline-block; animation: field-wave-ride 1.1s ease-in-out infinite, field-wave-in 0.3s ease-out; animation-delay: calc(var(--i) * -0.09s), 0s; }
@media (prefers-reduced-motion: reduce) { .field-wave__ch { animation: none; } }
`;
export function WaveInput() {
const [value, setValue] = useState('making waves');
const seq = useRef(0);
const ids = useRef<number[]>([...'making waves'].map(() => seq.current++));
const change = (next: string) => {
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++;
ids.current = [...ids.current.slice(0, p), ...Array.from({ length: next.length - p - s }, () => seq.current++), ...ids.current.slice(ids.current.length - s)];
setValue(next);
};
return (
<>
<style>
{css}
</style>
<div className="field-wave">
<label className="field-wave__label" htmlFor="field-wave-input">
Caption
</label>
<div className="field-wave__box">
<span className="field-wave__mirror" aria-hidden="true">
{[...value].map((c, i) => (
<span key={ids.current[i]} className="field-wave__ch" style={{ '--i': i } as React.CSSProperties}>
{c}
</span>
))}
</span>
<input
id="field-wave-input"
className="field-wave__input"
value={value}
maxLength={26}
autoComplete="off"
spellCheck={false}
onChange={(e) => change(e.target.value)}
/>
</div>
</div>
</>
);
}Markup where the component goes, CSS in your stylesheet, script before </body>. In Vue, Svelte or Angular, call initFieldWave(el) on the mounted element instead.
HTML
<div class="field-wave">
<label class="field-wave__label" for="field-wave-input">Caption</label>
<div class="field-wave__box">
<span class="field-wave__mirror" aria-hidden="true"><span class="field-wave__ch" style="--i:0">m</span><span class="field-wave__ch" style="--i:1">a</span><span class="field-wave__ch" style="--i:2">k</span><span class="field-wave__ch" style="--i:3">i</span><span class="field-wave__ch" style="--i:4">n</span><span class="field-wave__ch" style="--i:5">g</span><span class="field-wave__ch" style="--i:6"> </span><span class="field-wave__ch" style="--i:7">w</span><span class="field-wave__ch" style="--i:8">a</span><span class="field-wave__ch" style="--i:9">v</span><span class="field-wave__ch" style="--i:10">e</span><span class="field-wave__ch" style="--i:11">s</span></span>
<input id="field-wave-input" class="field-wave__input" maxLength="26" autoComplete="off" spellCheck="false" value="making waves"/>
</div>
</div>CSS
@property --field-wave-amp { syntax: '<length>'; inherits: true; initial-value: 0px; }
@keyframes field-wave-ride { 0%, 100% { transform: translateY(calc(var(--field-wave-amp) * 0.6)); } 50% { transform: translateY(calc(var(--field-wave-amp) * -1)); } }
@keyframes field-wave-in { from { opacity: 0; } }
.field-wave { width: 250px; }
.field-wave__label { display: block; margin-bottom: 6px; color: light-dark(#50535b, #c6c8cd); font-size: 12px; }
.field-wave__box {
position: relative;
height: 46px;
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-wave-amp 0.6s ease;
}
/* Focused, the letters ride a travelling wave; on blur the wave calms to a flat line. */
.field-wave__box:focus-within { --field-wave-amp: 5px; box-shadow: inset 0 0 0 1px light-dark(#5f626a, #a7abb2); }
.field-wave__input, .field-wave__mirror { padding: 0 12px; font-family: inherit; font-size: 16px; font-weight: 600; font-kerning: none; letter-spacing: 0.3px; }
.field-wave__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-wave__mirror { position: absolute; inset: 0; display: flex; align-items: center; overflow: hidden; color: light-dark(#373a40, #f1f2f3); white-space: pre; pointer-events: none; }
.field-wave__ch { display: inline-block; animation: field-wave-ride 1.1s ease-in-out infinite, field-wave-in 0.3s ease-out; animation-delay: calc(var(--i) * -0.09s), 0s; }
@media (prefers-reduced-motion: reduce) { .field-wave__ch { animation: none; } }JavaScript
function initFieldWave(root) {
var input = root.querySelector('.field-wave__input');
var mirror = root.querySelector('.field-wave__mirror');
if (!input || !mirror) return;
var value = input.value;
// Diff by common prefix and suffix: only the letters actually typed are new, so only they animate in.
function change() {
var next = input.value;
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++;
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-wave__ch';
el.textContent = c;
mirror.insertBefore(el, after);
});
// Each letter's place in the wave.
Array.prototype.forEach.call(mirror.children, function (el, i) { el.style.setProperty('--i', String(i)); });
value = next;
}
input.addEventListener('input', change);
return function () { input.removeEventListener('input', change); };
}
document.querySelectorAll('.field-wave').forEach(initFieldWave);Goal: Create a "Wave" input.
Visual behavior: A caption field whose letters ride a travelling wave while it is focused; on blur the wave calms smoothly to a flat line, and new letters fade in on the swell.
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 letter rides a translateY keyframe offset by -0.09s per index, which makes the wave travel.
- The amplitude is a registered @property length that the box transitions from 0 to 5px on :focus-within, so the wave grows and calms smoothly instead of snapping.
- 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.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the letters stay level.
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 "Wave" input.
Visual behavior: A caption field whose letters ride a travelling wave while it is focused; on blur the wave calms smoothly to a flat line, and new letters fade in on the swell.
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 letter rides a translateY keyframe offset by -0.09s per index, which makes the wave travel.
- The amplitude is a registered @property length that the box transitions from 0 to 5px on :focus-within, so the wave grows and calms smoothly instead of snapping.
- 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.
Accessibility: A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the letters stay level.
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 caption field whose letters ride a travelling wave while it is focused; on blur the wave calms smoothly to a flat line, and new letters fade in on the swell.
- Dependencies
- None
- Difficulty
- Intermediate
- Triggers
- focus, continuous, delay stagger
- Techniques
- css property, keyframes, css variables
- Reduced motion
- Respected
- Theme
- Light and dark (follows your page's color-scheme)
A real <input> with a visible <label>; decoration is aria-hidden. Under prefers-reduced-motion the letters stay level.