Transitions.dev

Home / Transitions

Input clear with dissolve

Clear with per-word dissolve

Open the live demo Install with the Skill

CSS

Namespaced classes and motion tokens you can retune. The reduced-motion guard ships with the snippet.

/* Transitions.dev — Input clear with dissolve */

/*
  Usage:
    <!-- Drop .t-clear on a wrapper that you've sized like an
         input field (positioning, padding, border, radius are
         yours). Inside it stack a real <input>, a mirror that
         visualizes the value, a fake placeholder for the new
         empty state, and a glow layer that gets the per-word
         radial-gradient stack written into it from JS. -->
    <div class="t-clear has-value">
      <input type="text" value="…" />
      <div class="t-clear-mirror" aria-hidden="true">…</div>
      <div class="t-clear-placeholder" aria-hidden="true">Search</div>
      <div class="t-clear-glow" aria-hidden="true"></div>
      <button class="t-clear-btn" aria-label="Clear">…</button>
    </div>

  Pair with a small JS routine (mirrors the gallery code) that
  flips `.is-clearing`, animates the mirror's translateY/opacity
  per frame, mirrors the math on the placeholder, and writes a
  stack of `radial-gradient(...)` layers onto .t-clear-glow's
  background so each word gets its own streak. Per-frame JS is
  unavoidable: the streak's rise/peak/fall envelope cannot be
  expressed as a static @keyframe.
*/

: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

Self-contained component; the styles inject themselves on first import.

// Transitions.dev — Input clear with dissolve (React, self-contained)
// Drop into any React project — no extra CSS file needed.

import { useEffect, useRef } from "react";

// ── Styles ──────────────────────────────────────────────
// Auto-injected on first import. Idempotent (guarded by
// the element id) and SSR-safe (no-ops without document).
const __TRANSITION_STYLES = `
: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; }
}
`;
if (typeof document !== "undefined" && !document.getElementById("transitions-p13")) {
  const __style = document.createElement("style");
  __style.id = "transitions-p13";
  __style.textContent = __TRANSITION_STYLES;
  document.head.appendChild(__style);
}

// Pair with the CSS from the CSS tab.
// Per-frame JS is unavoidable here: the per-word streak's
// rise → peak → fall envelope can't be expressed as a static
// @keyframe, so we tween transform/opacity/filter ourselves.
//
// Use it as:
//   <ClearInput defaultValue="How do transitions work?"
//               placeholder="Search anything" />
//
// Bring your own search icon, container size, and theming.
export function ClearInput({ defaultValue = "", placeholder = "" }) {
  const wrapRef    = useRef(null);
  const inputRef   = useRef(null);
  const mirrorRef  = useRef(null);
  const fakePhRef  = useRef(null);
  const glowRef    = useRef(null);
  const isClearing = useRef(false);

  useEffect(() => {
    const wrap = wrapRef.current;
    const input = inputRef.current;
    if (!wrap || !input) return;
    const sync = () => {
      const has = input.value.length > 0;
      wrap.classList.toggle("has-value", has);
      if (has && mirrorRef.current) {
        mirrorRef.current.textContent = input.value.replace(/ /g, "\u00a0");
      }
    };
    input.addEventListener("input", sync);
    sync();
    return () => input.removeEventListener("input", sync);
  }, []);

  const onClear = () => {
    const wrap = wrapRef.current;
    const input = inputRef.current;
    const mirror = mirrorRef.current;
    const fakePh = fakePhRef.current;
    const glow = glowRef.current;
    if (!wrap || !input || !mirror || !fakePh || !glow) return;
    if (isClearing.current || !input.value) return;
    isClearing.current = true;
    const wasFocused = document.activeElement === input;
    mirror.textContent = input.value.replace(/ /g, "\u00a0");
    const bg = buildLayers(wrap, mirror.textContent);
    const peakAt   = readNum("--glow-peak-at", 0.15);
    const opacity  = readNum("--glow-opacity", 0.42);
    const total    = readNum("--clear-dur", 1000);
    const outDur   = readNum("--clear-out-dur", 400);
    const inDur    = readNum("--clear-in-dur", 400);
    const outFly   = readNum("--clear-out-fly", 12);
    const inFly    = readNum("--clear-in-fly", 12);
    const blurPx   = readNum("--clear-blur", 2);
    const glowDly  = readNum("--glow-delay", 50);
    const eOut = makeEase(readEase("--clear-out-ease", "cubic-bezier(0.22, 1, 0.36, 1)"));
    const eIn  = makeEase(readEase("--clear-in-ease",  "cubic-bezier(0.22, 1, 0.36, 1)"));

    input.value = "";
    wrap.classList.remove("has-value");
    wrap.classList.add("is-clearing");
    fakePh.style.transform = `translateY(-${inFly}px)`;
    fakePh.style.opacity = "0.9";
    fakePh.style.filter = `blur(${blurPx}px)`;
    glow.style.background = bg;
    glow.style.opacity = "0";

    const start = performance.now();
    const tick = (now) => {
      const elapsed = now - start;
      const p  = Math.min(1, elapsed / total);
      const e  = eOut(Math.min(1, elapsed / outDur));
      mirror.style.transform = `translateY(${(e * outFly).toFixed(1)}px)`;
      mirror.style.opacity = (1 - e).toFixed(3);
      mirror.style.filter = `blur(${(e * blurPx).toFixed(1)}px)`;
      const pe = eIn(Math.min(1, elapsed / inDur));
      fakePh.style.transform = `translateY(${(-inFly + pe * inFly).toFixed(1)}px)`;
      fakePh.style.opacity = (0.9 + pe * 0.1).toFixed(3);
      fakePh.style.filter = `blur(${(blurPx - pe * blurPx).toFixed(1)}px)`;
      let g = 0;
      if (elapsed > glowDly) {
        const remaining = Math.max(1, total - glowDly);
        const gp = Math.min(1, (elapsed - glowDly) / remaining);
        g = gp < peakAt ? gp / peakAt : 1 - (gp - peakAt) / (1 - peakAt);
      }
      glow.style.opacity = (g * opacity).toFixed(3);
      if (p < 1) requestAnimationFrame(tick);
      else {
        wrap.classList.remove("is-clearing");
        for (const el of [mirror, fakePh]) el.style.cssText = "";
        mirror.textContent = "";
        glow.style.opacity = "0";
        glow.style.background = "";
        isClearing.current = false;
        if (wasFocused) input.focus({ preventScroll: true });
      }
    };
    requestAnimationFrame(tick);
  };

  return (
    <div ref={wrapRef} className="t-clear">
      <input
        ref={inputRef}
        type="text"
        defaultValue={defaultValue}
        placeholder={placeholder}
      />
      <div ref={mirrorRef} className="t-clear-mirror" aria-hidden="true" />
      <div ref={fakePhRef} className="t-clear-placeholder" aria-hidden="true">
        {placeholder}
      </div>
      <div ref={glowRef} className="t-clear-glow" aria-hidden="true" />
      <button
        type="button"
        className="t-clear-btn"
        aria-label="Clear"
        // Trap focus on the input across pointer + mouse so the button
        // never steals it during the clear animation.
        onPointerDown={(e) => { if (document.activeElement === inputRef.current) e.preventDefault(); }}
        onMouseDown={(e) => { if (document.activeElement === inputRef.current) e.preventDefault(); }}
        onClick={onClear}
      >×</button>
    </div>
  );
}

function buildLayers(wrap, text) {
  const inputW = wrap.clientWidth || 280;
  const padLeft = 32;
  const segments = text.split(/(\s+)/);
  const spread = readNum("--glow-spread", 1.5);
  const ctx = (buildLayers._ctx ||= (() => {
    const c = document.createElement("canvas").getContext("2d");
    c.font = "400 13px Inter, sans-serif";
    return c;
  })());
  // Light mode paints black gradients (multiply blend); dark mode
  // paints white (screen blend). Pair this with the matching
  // `mix-blend-mode` rule on .t-clear-glow in your theme CSS.
  const isDark = document.documentElement.getAttribute("data-theme") === "dark";
  const rgb = isDark ? "255,255,255" : "0,0,0";
  const layers = [];
  let x = 0;
  for (const seg of segments) {
    const w = ctx.measureText(seg).width;
    if (seg.trim()) {
      const cx = padLeft + x + w / 2;
      const hw = Math.max(w * 0.45, 8) * spread;
      const stops = [
        { dx: 0,         rw: hw * 0.8,  rh: 7, a: 0.22 },
        { dx: hw * 0.45, rw: hw * 0.55, rh: 8, a: 0.18 },
        { dx: -hw * 0.4, rw: hw * 0.65, rh: 6, a: 0.16 },
        { dx: hw * 0.15, rw: hw * 0.9,  rh: 5, a: 0.14 },
      ];
      for (const l of stops) {
        const lx = ((cx + l.dx) / inputW * 100).toFixed(2);
        layers.push(
          `radial-gradient(ellipse ${Math.max(l.rw, 2).toFixed(1)}px ${l.rh}px at ${lx}% 100%, rgba(${rgb},${l.a.toFixed(3)}), transparent)`
        );
      }
    }
    x += w;
  }
  return layers.join(", ");
}
function readNum(name, fb) {
  const v = parseFloat(getComputedStyle(document.documentElement).getPropertyValue(name));
  return Number.isFinite(v) ? v : fb;
}
function readEase(name, fb) {
  const v = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
  return v || fb;
}
function makeEase(ease) {
  const m = ease.match(/cubic-bezier\s*\(\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*,\s*([-\d.]+)\s*\)/i);
  if (!m) return (t) => t;
  const [x1, y1, x2, y2] = [m[1], m[2], m[3], m[4]].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;
  const sX = (s) => ((ax * s + bx) * s + cx) * s;
  const sY = (s) => ((ay * s + by) * s + cy) * s;
  const dX = (s) => (3 * ax * s + 2 * bx) * s + cx;
  return (t) => {
    if (t <= 0) return 0;
    if (t >= 1) return 1;
    let s = t;
    for (let i = 0; i < 8; i++) {
      const dx = sX(s) - t;
      if (Math.abs(dx) < 1e-6) break;
      const d = dX(s);
      if (d === 0) break;
      s -= dx / d;
    }
    return sY(s);
  };
}