Back to Code Bytes
8 min read
Input Clear with Dissolve

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

VariableDefaultNotes
--clear-dur1000mssourced from --p13-clear-dur
--clear-out-dur400mssourced from --p13-text-out-dur
--clear-in-dur400mssourced from --p13-text-in-dur
--clear-out-fly12pxsourced from --p13-text-out-fly
--clear-in-fly12pxsourced from --p13-text-in-fly
--clear-out-easecubic-bezier(0.22, 1, 0.36, 1)sourced from --p13-text-out-ease
--clear-in-easecubic-bezier(0.22, 1, 0.36, 1)sourced from --p13-text-in-ease
--clear-blur2pxsourced from --p13-blur
--glow-delay50mssourced from --p13-glow-delay
--glow-peak-at0.15sourced from --p13-glow-peak-at
--glow-opacity0.42sourced from --p13-glow-opacity
--glow-spread1.5sourced from --p13-glow-spread

Credit

Adapted from Input Clear with Dissolve on transitions.dev by Jakub Antalik. Original source: github.com/Jakubantalik/transitions.dev.