前端嘛 Logo
前端嘛
我愿称之为天才般的创意:纯文本实现水波纹效果

我愿称之为天才般的创意:纯文本实现水波纹效果

2026-01-23
<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>ASCII Glitch Ripple Hover Effect</title>
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style>
    * {
      margin: 0;
      padding: 0;
      box-sizing: border-box;
      font-style: normal;
    }

    html {
      --color-bg: #121211;
      --color-text: #f9f9f7;
      --color-muted: #bdbdbd;
      --u-thickness: 1.1px;
      --u-spacing: 0.4rem;
      --u-offset: 0.2rem;
    }

    body {
      background: var(--color-bg);
      font: 14px/1.45 "Lucida Console", "Monaco", monospace;
      letter-spacing: 0.01em;
    }

    body,
    a {
      color: var(--color-text);
    }

    ::selection {
      background: var(--color-text);
      color: var(--color-bg);
    }

    main {
      min-height: 100vh;
      display: flex;
      flex-direction: column;
      justify-content: space-between;
    }

    .ct {
      margin: 0 auto;
      padding: 0.6em 1.6em;
      max-width: 52ch;
      position: relative;
      z-index: 1;
    }

    .ct>* {
      margin: 1rem 0 0;
    }

    ul {
      list-style: none;
      padding: 0;
      margin: 0 0 2em 0;
    }

    li {
      margin: 0.6rem 0;
      position: relative;
    }

    a {
      color: var(--color-text);
      text-decoration: none;
      position: relative;
      z-index: 1;
    }

    .h a::after {
      content: "";
      position: absolute;
      bottom: calc(-1.1 * var(--u-offset));
      left: 0;
      right: 0;
      height: var(--u-thickness);
      background: repeating-linear-gradient(to right, var(--color-muted) 0, var(--color-muted) 2px, transparent 2px, transparent var(--u-spacing));
      transition: background 0.3s ease-out;
      opacity: 0.75;
    }

    .h a:hover::after {
      background: var(--color-muted);
      height: calc(var(--u-thickness) * 0.5);
    }

    a:focus,
    a:hover {
      text-decoration: none;
    }

    header {
      margin-bottom: 1.5em;
    }

    footer {
      padding: 1em 0;
    }

    small {
      color: var(--color-muted);
      font-size: 0.8em;
      display: block;
      margin-top: 1.5em;
      text-align: center;
    }

    a.as {
      cursor: pointer;
      user-select: none;
    }

    a.as::selection {
      background: transparent;
    }

    a.as:hover {
      position: relative;
      cursor: pointer;
    }

    .pt li {
      padding: 0 0 0 0.6em;
    }

    .pt li::before {
      content: "";
      position: absolute;
      left: 0;
      top: 68%;
      width: 0.6em;
      height: 1px;
      background: var(--color-text);
      transform: scaleX(1);
      transform-origin: right;
      transition: transform 1s ease;
    }

    .pt li:hover::before {
      transform: scaleX(2);
      transition-duration: 0.3s;
    }

    .pt li a {
      display: inline-block;
      margin-left: 0.6em;
      white-space: nowrap;
    }

    .so {
      position: absolute;
      display: block;
      width: 1px;
      height: 1px;
      margin: -1px;
      border: 0;
      padding: 0;
      white-space: nowrap;
      overflow: hidden;
    }
  </style>
</head>

<body>
  <main id="main">
    <header class="h">

    </header>
    <article class="ct">
      <ul class="pt"><li><a href="#" aria-label="Roadside Picnic — Arkady & Boris Strugatsky">Roadside Picnic — Arkady & Boris Strugatsky </a></li><li><a href="#" aria-label="The City & the City — China Miéville">The City & the City — China Miéville </a></li><li><a href="#" aria-label="Parable of the Sower — Octavia E. Butler">Parable of the Sower — Octavia E. Butler </a></li><li><a href="#" aria-label="The Fifth Head of Cerberus — Gene Wolfe">The Fifth Head of Cerberus — Gene Wolfe </a></li><li><a href="#" aria-label="Riddley Walker — Russell Hoban">Riddley Walker — Russell Hoban </a></li><li><a href="#" aria-label="His Master's Voice — Stanisław Lem">His Master's Voice — Stanisław Lem </a></li><li><a href="#" aria-label="The Left Hand of Darkness — Ursula K. Le Guin">The Left Hand of Darkness — Ursula K. Le Guin </a></li><li><a href="#" aria-label="The Three Stigmata of Palmer Eldritch — Philip K. Dick">The Three Stigmata of Palmer Eldritch — Philip K. Dick </a></li><li><a href="#" aria-label="Stars in My Pocket Like Grains of Sand — Samuel R. Delany">Stars in My Pocket Like Grains of Sand — Samuel R. Delany </a></li></ul>
    </article>
    <footer>
      <small></small>
    </footer>
  </main>
  <script type="module">
    // Constants for wave animation behavior
    const WAVE_THRESH = 3;
    const CHAR_MULT = 3;
    const ANIM_STEP = 40;
    const WAVE_BUF = 5;

    /**
     * ASCII ripple animation instance for an element
     */
    export const createASCIIShift = (el, opts = {}) => {
      // State variables
      let origTxt = el.textContent;
      let origChars = origTxt.split("");
      let isAnim = false;
      let cursorPos = 0;
      let waves = [];
      let animId = null;
      let isHover = false;
      let origW = null;

      // options
      const cfg = {
        dur: 600,
        chars: '.,·-─~+:;=*π""┐┌┘┴┬╗╔╝╚╬╠╣╩╦║░▒▓█▄▀▌▐■!?&#$@0123456789*',
        preserveSpaces: true,
        spread: 0.3,
        ...opts
      };

      /**
       * Updates cursor position based on mouse move
       */
      const updateCursorPos = (e) => {
        const rect = el.getBoundingClientRect();
        const x = e.clientX - rect.left;
        const len = origTxt.length;
        const pos = Math.round((x / rect.width) * len);
        cursorPos = Math.max(0, Math.min(pos, len - 1));
      };

      /**
       * Starts a new wave animation from current cursor pos
       */
      const startWave = () => {
        waves.push({
          startPos: cursorPos,
          startTime: Date.now(),
          id: Math.random()
        });

        if (!isAnim) start();
      };

      /**
       * Clean up expired waves that have exceeded their duration
       */
      const cleanupWaves = (t) => {
        waves = waves.filter((w) => t - w.startTime < cfg.dur);
      };

      /**
       * Calculates wave fx for a character at given index
       * Returns whether to animate and which character to show
       */
      const calcWaveEffect = (charIdx, t) => {
        let shouldAnim = false;
        let resultChar = origChars[charIdx];

        for (const w of waves) {
          const age = t - w.startTime;
          const prog = Math.min(age / cfg.dur, 1);
          const dist = Math.abs(charIdx - w.startPos);
          const maxDist = Math.max(w.startPos, origChars.length - w.startPos - 1);
          const rad = (prog * (maxDist + WAVE_BUF)) / cfg.spread;

          if (dist <= rad) {
            shouldAnim = true;
            const intens = Math.max(0, rad - dist);

            // Chars in the wave zone shift through character sequence
            if (intens <= WAVE_THRESH && intens > 0) {
              const charIdx =
                (dist * CHAR_MULT + Math.floor(age / ANIM_STEP)) % cfg.chars.length;
              resultChar = cfg.chars[charIdx];
            }
          }
        }

        return { shouldAnim, char: resultChar };
      };

      /**
       * Generates scrambled text based on current waves
       */
      const genScrambledTxt = (t) =>
        origChars
          .map((char, i) => {
            if (cfg.preserveSpaces && char === " ") return " ";
            const res = calcWaveEffect(i, t);
            return res.shouldAnim ? res.char : char;
          })
          .join("");

      /**
       * Stops the animation and resets to original text
       */
      const stop = () => {
        el.textContent = origTxt;
        el.classList.remove("as");

        // Reset width to allow natural text flow
        if (origW !== null) {
          el.style.width = "";
          origW = null;
        }
        isAnim = false;
      };

      /**
       * Start the animation loop
       */
      const start = () => {
        if (isAnim) return;

        // Preserve original width to prevent layout shifts
        if (origW === null) {
          origW = el.getBoundingClientRect().width;
          el.style.width = `${origW}px`;
        }

        isAnim = true;
        el.classList.add("as");

        const animate = () => {
          const t = Date.now();

          // Clean up expired waves first
          cleanupWaves(t);

          if (waves.length === 0) {
            stop();
            return;
          }

          // Generate scrambled text
          el.textContent = genScrambledTxt(t);
          animId = requestAnimationFrame(animate);
        };

        animId = requestAnimationFrame(animate);
      };

      /**
       * Event handlers
       */
      const handleEnter = (e) => {
        isHover = true;
        updateCursorPos(e);
        startWave();
      };

      const handleMove = (e) => {
        if (!isHover) return;
        const old = cursorPos;
        updateCursorPos(e);
        if (cursorPos !== old) startWave();
      };

      const handleLeave = () => {
        isHover = false;
      };

      /**
       * Initializes event listeners
       */
      const init = () => {
        const events = [
          ["mouseenter", handleEnter],
          ["mousemove", handleMove],
          ["mouseleave", handleLeave]
        ];
        events.forEach(([evt, handler]) => el.addEventListener(evt, handler));
      };

      /**
       * Resets animation to original state
       */
      const resetToOrig = () => {
        waves = [];
        if (animId) {
          cancelAnimationFrame(animId);
          animId = null;
        }

        // Reset width preservation
        if (origW !== null) {
          el.style.width = "";
          origW = null;
        }
        stop();
      };

      /**
       * Updates the text content
       */
      const updateTxt = (newTxt) => {
        origTxt = newTxt;
        origChars = newTxt.split("");
        if (!isAnim) el.textContent = newTxt;
      };

      /**
       * Destroys the instance and cleans up event listeners
       */
      const destroy = () => {
        resetToOrig();
        ["mouseenter", "mousemove", "mouseleave"].forEach((evt, i) =>
          el.removeEventListener(evt, [handleEnter, handleMove, handleLeave][i])
        );
      };

      // Initialize the instance
      init();

      // public API
      return { updateTxt, resetToOrig, destroy };
    };

    /**
     * Initialize animation for all links on the page
     */
    const initASCIIShift = () => {
      const links = document.querySelectorAll("a");
      links.forEach((link) => {
        if (!link.textContent.trim()) return;
        createASCIIShift(link, { dur: 1000, spread: 1 });
      });
    };
    initASCIIShift();
  </script>
</body>

</html>