Description
Input Clear with Dissolve replaces the abrupt jump of an emptied field with a short, layered dissolve. When the clear button is pressed, the typed value is mirrored over the real input and then pushed down a few pixels as it blurs and fades to nothing. At the same time the placeholder falls in from above, sharpening as it settles. Underneath the text a soft glow rises to a quick peak and falls away, painted as a stack of radial gradients with one streak centered on each word. Per-frame JavaScript drives the whole sequence because the glow’s rise and fall envelope, together with the per-word gradient stack, cannot be expressed as a static set of keyframes. The field uses an uncontrolled input so the routine can mutate its value directly, the same way the original vanilla version does, and a Refill button restores the sample text so the dissolve can be replayed.
Example
CSS
:root {
--clear-dur: 1000ms;
--clear-out-dur: 400ms;
--clear-in-dur: 400ms;
--clear-out-fly: 12px;
--clear-in-fly: 12px;
--clear-out-ease: cubic-bezier(0.22, 1, 0.36, 1);
--clear-in-ease: cubic-bezier(0.22, 1, 0.36, 1);
--clear-blur: 2px;
--glow-delay: 50ms;
--glow-peak-at: 0.15;
--glow-opacity: 0.42;
--glow-spread: 1.5;
}
/* The wrap clips the glow to its rounded box. The hairline
border is `inset` so it sits inside that clip — when the
glow's mix-blend-mode darkens its area, the border
underneath darkens with it. Bring your own width / height /
border-radius / surface color. */
.t-clear {
position: relative;
overflow: hidden;
}
.t-clear-mirror,
.t-clear-placeholder {
position: absolute;
inset: 0;
display: flex;
align-items: center;
pointer-events: none;
white-space: nowrap;
overflow: hidden;
z-index: 2;
}
.t-clear-mirror {
opacity: 0;
}
.t-clear.has-value .t-clear-mirror,
.t-clear.is-clearing .t-clear-mirror {
opacity: 1;
}
/* Hide the input's own glyphs while the mirror owns them so
the cleared text doesn't double-render with the fly-up. */
.t-clear.has-value > input,
.t-clear.is-clearing > input {
-webkit-text-fill-color: transparent;
}
.t-clear.has-value .t-clear-placeholder {
opacity: 0;
}
/* The streak overlay: empty by default; JS writes a stack of
`radial-gradient(...)` layers into background during a clear,
then animates opacity. mix-blend-mode: multiply darkens the
underlying input + hairline; flip to `screen` in dark mode
so the same alpha values lighten instead of vanish. */
.t-clear-glow {
position: absolute;
inset: 0;
pointer-events: none;
opacity: 0;
z-index: 3;
mix-blend-mode: multiply;
}
/* The transitions live in JS (per-frame transform/opacity/
filter writes), so this stylesheet only owns the resting
state + the variables that JS reads. Read them with
`parseFloat(getComputedStyle(root).getPropertyValue(...))`
so live tweaks apply on the next clear without a reload. */
@media (prefers-reduced-motion: reduce) {
.t-clear-glow {
opacity: 0 !important;
}
}
React
Per-frame JavaScript is required here: the glow’s rise and fall envelope and the per-word radial-gradient stack cannot be expressed as static @keyframes. The input is uncontrolled so the routine can mutate its value directly, and all the wiring lives in a single effect that scopes the ported bezier, buildGlow, and clearWithAnimation to one wrapper ref and cancels its requestAnimationFrame on cleanup.
import { useEffect, useRef } from "react";
import "./input-clear-dissolve.css"; // paste the CSS above
const SAMPLE = "quarterly report final";
export function InputClearDissolve() {
const wrapRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const wrap = wrapRef.current;
if (!wrap) return;
const input = wrap.querySelector("input") as HTMLInputElement | null;
const mirror = wrap.querySelector(".t-clear-mirror") as HTMLElement | null;
const phold = wrap.querySelector(
".t-clear-placeholder",
) as HTMLElement | null;
const glow = wrap.querySelector(".t-clear-glow") as HTMLElement | null;
const btn = wrap.querySelector(".t-clear-btn") as HTMLButtonElement | null;
if (!input || !mirror || !phold || !glow || !btn) return;
const root = document.documentElement;
const canvas = document.createElement("canvas").getContext("2d");
const num = (name: string, fb: number) => {
const v = parseFloat(getComputedStyle(root).getPropertyValue(name));
return Number.isFinite(v) ? v : fb;
};
// Minimal cubic-bezier(x1,y1,x2,y2) sampler so JS easing matches CSS.
function bezier(str: string) {
const m = String(str).match(
/cubic-bezier\(([-\d.]+),([-\d.]+),([-\d.]+),([-\d.]+)\)/,
);
if (!m) return (t: number) => t;
const [x1, y1, x2, y2] = m.slice(1).map(parseFloat);
const cx = 3 * x1,
bx = 3 * (x2 - x1) - cx,
ax = 1 - cx - bx;
const cy = 3 * y1,
by = 3 * (y2 - y1) - cy,
ay = 1 - cy - by;
return (t: number) => {
if (t <= 0) return 0;
if (t >= 1) return 1;
let s = t;
for (let i = 0; i < 8; i++) {
const dx = ((ax * s + bx) * s + cx) * s - t;
const d = (3 * ax * s + 2 * bx) * s + cx;
if (Math.abs(dx) < 1e-6 || d === 0) break;
s -= dx / d;
}
return ((ay * s + by) * s + cy) * s;
};
}
let clearing = false;
let rafId = 0;
const sync = () => {
const has = input.value.length > 0;
wrap.classList.toggle("has-value", has);
if (has) mirror.textContent = input.value.replace(/ /g, "Â ");
};
// This site toggles dark mode via a class on <html>, not data-theme.
const isDarkMode = () =>
document.documentElement.classList.contains("dark");
function buildGlow(text: string) {
if (!canvas) return "";
canvas.font = getComputedStyle(input!).font;
const isDark = isDarkMode();
const rgb = isDark ? "255,255,255" : "0,0,0";
const w = wrap!.clientWidth || 280;
const padLeft = parseFloat(getComputedStyle(input!).paddingLeft) || 12;
const spread = num("--glow-spread", 1.5);
const layers: string[] = [];
let x = 0;
text.split(/(\s+)/).forEach((seg) => {
const segW = canvas.measureText(seg).width;
if (seg.trim()) {
const cx = padLeft + x + segW / 2;
const hw = Math.max(segW * 0.45, 8) * spread;
(
[
[0, 0.8, 7, 0.22],
[hw * 0.45, 0.55, 8, 0.18],
[-hw * 0.4, 0.65, 6, 0.16],
[hw * 0.15, 0.9, 5, 0.14],
] as const
).forEach(([dx, rwm, rh, a]) => {
const lx = (((cx + dx) / w) * 100).toFixed(2);
layers.push(
`radial-gradient(ellipse ${Math.max(hw * rwm, 2).toFixed(1)}px ${rh}px at ${lx}% 100%, rgba(${rgb},${a}), transparent)`,
);
});
}
x += segW;
});
return layers.join(", ");
}
function clearWithAnimation() {
if (clearing || !input!.value) return;
clearing = true;
const keepFocus = document.activeElement === input;
mirror!.textContent = input!.value.replace(/ /g, "Â ");
const total = num("--clear-dur", 1000);
const outDur = num("--clear-out-dur", 400);
const inDur = num("--clear-in-dur", 400);
const outFly = num("--clear-out-fly", 12);
const inFly = num("--clear-in-fly", 12);
const blur = num("--clear-blur", 2);
const delay = num("--glow-delay", 50);
const peakAt = num("--glow-peak-at", 0.15);
// Multiply over a dark surface vanishes, so dark mode paints white
// gradients (in buildGlow) and uses a brighter peak opacity.
const gOp = isDarkMode()
? Math.max(num("--glow-opacity", 0.42), 0.85)
: num("--glow-opacity", 0.42);
const easeOut = bezier(
getComputedStyle(root).getPropertyValue("--clear-out-ease"),
);
const easeIn = bezier(
getComputedStyle(root).getPropertyValue("--clear-in-ease"),
);
input!.value = "";
wrap!.classList.remove("has-value");
wrap!.classList.add("is-clearing");
glow!.style.background = buildGlow(mirror!.textContent || "");
glow!.style.opacity = "0";
phold!.style.transform = `translateY(-${inFly}px)`;
phold!.style.opacity = "0.9";
phold!.style.filter = `blur(${blur}px)`;
const t0 = performance.now();
const tick = (now: number) => {
const el = now - t0;
const eo = easeOut(Math.min(1, el / outDur));
mirror!.style.transform = `translateY(${(eo * outFly).toFixed(1)}px)`;
mirror!.style.opacity = (1 - eo).toFixed(3);
mirror!.style.filter = `blur(${(eo * blur).toFixed(1)}px)`;
const ei = easeIn(Math.min(1, el / inDur));
phold!.style.transform = `translateY(${(-inFly + ei * inFly).toFixed(1)}px)`;
phold!.style.opacity = (0.9 + ei * 0.1).toFixed(3);
phold!.style.filter = `blur(${(blur - ei * blur).toFixed(1)}px)`;
let g = 0;
if (el > delay) {
const gp = Math.min(1, (el - delay) / Math.max(1, total - delay));
g = gp < peakAt ? gp / peakAt : 1 - (gp - peakAt) / (1 - peakAt);
}
glow!.style.opacity = (g * gOp).toFixed(3);
if (el < total) {
rafId = requestAnimationFrame(tick);
} else {
rafId = 0;
wrap!.classList.remove("is-clearing");
[mirror!, phold!].forEach((node) => (node.style.cssText = ""));
mirror!.textContent = "";
glow!.style.opacity = "0";
glow!.style.background = "";
clearing = false;
if (keepFocus)
requestAnimationFrame(() => input!.focus({ preventScroll: true }));
}
};
rafId = requestAnimationFrame(tick);
}
const keep = (e: Event) => {
if (document.activeElement === input) e.preventDefault();
};
btn.addEventListener("pointerdown", keep);
btn.addEventListener("mousedown", keep);
btn.addEventListener("click", clearWithAnimation);
input.addEventListener("input", sync);
sync();
return () => {
if (rafId) cancelAnimationFrame(rafId);
btn.removeEventListener("pointerdown", keep);
btn.removeEventListener("mousedown", keep);
btn.removeEventListener("click", clearWithAnimation);
input.removeEventListener("input", sync);
mirror.style.cssText = "";
phold.style.cssText = "";
glow.style.opacity = "";
glow.style.background = "";
};
}, []);
const refill = () => {
const input = wrapRef.current?.querySelector("input");
if (input) {
input.value = SAMPLE;
input.dispatchEvent(new Event("input", { bubbles: true }));
}
};
return (
<div>
<div className="t-clear has-value" ref={wrapRef}>
<input type="text" defaultValue={SAMPLE} aria-label="Search" />
<div className="t-clear-mirror" aria-hidden="true">
{SAMPLE}
</div>
<div className="t-clear-placeholder" aria-hidden="true">
Search
</div>
<div className="t-clear-glow" aria-hidden="true" />
<button type="button" className="t-clear-btn" aria-label="Clear input">
×
</button>
</div>
<button onClick={refill}>Refill</button>
</div>
);
}
Variables
| Variable | Default | Notes |
|---|---|---|
--clear-dur | 1000ms | sourced from --p13-clear-dur |
--clear-out-dur | 400ms | sourced from --p13-text-out-dur |
--clear-in-dur | 400ms | sourced from --p13-text-in-dur |
--clear-out-fly | 12px | sourced from --p13-text-out-fly |
--clear-in-fly | 12px | sourced from --p13-text-in-fly |
--clear-out-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p13-text-out-ease |
--clear-in-ease | cubic-bezier(0.22, 1, 0.36, 1) | sourced from --p13-text-in-ease |
--clear-blur | 2px | sourced from --p13-blur |
--glow-delay | 50ms | sourced from --p13-glow-delay |
--glow-peak-at | 0.15 | sourced from --p13-glow-peak-at |
--glow-opacity | 0.42 | sourced from --p13-glow-opacity |
--glow-spread | 1.5 | sourced from --p13-glow-spread |
Credit
Adapted from Input Clear with Dissolve on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.