我愿称之为天才般的创意:纯文本实现水波纹效果
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>
