前端網頁動效

-

Terminal 終端機文字特效

this.web

前陣子看到這個文字特效,在 hover 時文字會依序隨機變換,最後變回原本的字,有種產生亂碼的感覺,研究了一下,發現不會很複雜~

今天就讓我們看看這究竟是怎麼做的吧~

終端機文字特效 HTML

HTML 超簡單,就只是放一些文字而已:

<div>
  <p>Mount Everest</p>
  <p>Kangchenjunga</p>
  <p>Lhotse</p>
  <p>Chomo Lonzo</p>
  <p>Nanga Parbat</p>
  <p>Cho Oyu</p>
</div>

終端機文字特效 CSS

CSS 的部份也不難,選一個很適合的字體,我是用 google font 的 VT323

p {
  font-family: "VT323", monospace;
  letter-spacing: 1px;
  font-size: 36px;
  
  --color: var(--blue);
  color: var(--color);
  cursor: pointer;
}

接著調整顏色,這邊特別用 —color 變數是因為晚點也要用到 background 上,所以統一設置。

.char {
  transition: opacity 0.1s
}

然後設定每個 char 的 transition。

終端機文字特效 JS

接下來才是重頭戲,先簡單看一下整體的 JavaScript

整體很簡單,就是選取所有的 p 文字(這裡你可以設置 className,而不是選取 p 文字),接著調用 splitText 函數還分割所有的字串讓他變字符,最後設定 mouseenter 事件,代表只有在滑鼠進入元素時才觸發 hoverTextAnimation 函數。

const textEls = document.querySelectorAll("p");

textEls.forEach((textEl) => {
  splitText(textEl, { charClass: "char" });
  textEl.addEventListener("mouseenter", hoverTextAnimation);
});

接著讓我們分別看 splitTexthoverTextAnimation 函數的細節。

splitText 函數

splitText 函數不複雜,先選取 innerText 接著使用 map 函數遍歷這個字元數組,並將每個字元用 <span> 包起來,並給予類名。

最後使用 join 函數將 splitArray 中的所有字符串合併成一個單一的字符串。

function splitText(element, { charClass }) {
  const text = element.innerText;
  let splitArray;

  // 將每個字元用 <span> 包起來,並給予類名。
  splitArray = text
    .split("")
    .map((char) => `<span class="${charClass}">${char}</span>`);

  // 所有字符串合併成一個單一的字符串。
  element.innerHTML = splitArray.join("");
}

hoverTextAnimation 函數

hoverTextAnimation 函數就稍微複雜一點,我將步驟寫在下面:

function hoverTextAnimation(
  e,
  { charDelay = 50, charFreq = 200, randomCharRepeats = 2 }
) {
  const text = e.target.closest("p");

  // 利用 is-animated 類名,防止重複動畫。
  if (text.classList.contains("is-animated")) return;

  const chars = text.querySelectorAll(".char");
  text.classList.add("is-animated");
  
  // 遍歷 chars 字元元素,對每個字元調用 createCharAnimation 函數
  chars.forEach((char, index) => {
    createCharAnimation(char, index, {
      charDelay,
      charFreq,
      randomCharRepeats,
    });
  });

  // 計算動畫的總持續時間,並移除 is-animated 類名
  const totalDuration =
    charFreq * (randomCharRepeats + 1) + chars.length * charDelay;
  setTimeout(() => {
    text.classList.remove("is-animated");
  }, totalDuration);
}
  1. 首先傳入兩個參數
    • e:事件對象。
    • { charDelay = 50, charFreq = 200, randomCharRepeats = 2 }:物件的解構賦值,包含三個可選參數及其默認值:
      • charDelay:每個字元的延遲時間(默認為 50 毫秒)。
      • charFreq:動畫頻率或間隔時間(默認為 200 毫秒)。
      • randomCharRepeats:隨機字元重複次數(默認為 2 次)。
  2. 接著利用 is-animated 類名,防止重複動畫。
  3. 遍歷 chars 字元元素,對每個字元調用 createCharAnimation 函數。
  4. 計算動畫的總持續時間,並移除 is-animated 類名

整體就這樣,接著讓我們看 createCharAnimation 函數。

createCharAnimation 函數

這個函數是用來製作每個字元的動畫的。一樣把步驟放在下面:

function createCharAnimation(
  char, index, { charDelay, charFreq, randomCharRepeats }
) {
  // initText 用來保存元素的初始內容。
  // delay 是基於 index 的延遲時間,控制每個字元的動畫開始時間。
  const initText = char.innerText;
  const delay = charDelay * index;

  // 先將所有字串變透明
  char.style.setProperty("opacity", 0);
  
  // 第一個動畫。顯示字串並設定背景
  setTimeout(() => {
    char.style.setProperty("opacity", 1);
    char.style.setProperty("background", "var(--color)");
  }, charFreq - charDelay + delay);
  
  // 隨機字符串的動畫使用 for 來設置多次隨機字符替換的動畫
  for (let i = 1; i <= randomCharRepeats; i++) {
    setTimeout(() => {
      i == 1 && char.style.setProperty("background", "transparent");
      char.innerText = randomLetterAndSymbol();
    }, charFreq * i + delay);
  }
  
  // 最後一個動畫將字串變成原來的值
  const totalDuration = charFreq * (randomCharRepeats + 1) + delay;
  setTimeout(() => char.innerText = initText, totalDuration);
}
  1. 初始化分為三個部分
    1. initText 用來保存元素的初始內容。
    2. delay 是基於 index 的延遲時間,控制每個字元的動畫開始時間。
    3. 將元素的透明度設置為 0
  2. 第一個動畫
    1. 將元素的透明度設置為 1。
    2. 設置字元元素的背景色為 CSS 變量 --color
  3. 隨機字符串的動畫使用 for 來設置多次隨機字符替換的動畫:
    • 循環次數為 randomCharRepeats
    • 每次循環使用 setTimeout 設置延遲,在 charFreq * i + delay 毫秒後執行:
      • 如果是第一次替換(即 i == 1),將字元元素的背景設置為透明。
      • 將字元元素的文本內容設置為隨機字符,通過 randomLetterAndSymbol() 函數生成。
  4. 最後最後動畫(恢復原字符)

randomLetterAndSymbol 函數

這個函數很簡單,就是隨機返回一個字母

function randomLetterAndSymbol() {
  const chars =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()";
  return chars.charAt(Math.floor(Math.random() * chars.length));
}

到這邊就已經差不多完成了

但我們可以在用 css 做背景加強一下 ~

終端機文字特效 - CSS 背景

div {
  --blue-10: color-mix(in srgb, var(--blue), transparent 90%);
  --blue-50: color-mix(in srgb, var(--blue), transparent 50%);

  padding: 32px 64px;
  background: linear-gradient(0deg, var(--blue-50), transparent),
    linear-gradient(var(--blue-10) 2px, var(--black) 1px) center/100% 3px;
}

用 linear-gradient 來製作漸層和線條復古感後就大功告成了~!

Codepen 連結和全部的程式碼

最後附上 Codepen 和全部的程式碼~

<div class="container">
  <p>Mount Everest</p>
  <p>Kangchenjunga</p>
  <p>Lhotse</p>
  <p>Chomo Lonzo</p>
  <p>Nanga Parbat</p>
  <p>Cho Oyu</p>
</div>
@import url("https://fonts.googleapis.com/css2?family=VT323&display=swap");

  .container {
    --blue-10: color-mix(in srgb, var(--blue), transparent 90%);
    --blue-50: color-mix(in srgb, var(--blue), transparent 50%);

    padding: 32px 64px;
    display: flex;
    flex-direction: column;
    gap: 8px;
    background: linear-gradient(0deg, var(--blue-50), transparent),
      linear-gradient(var(--blue-10) 2px, var(--black) 1px) center/100% 3px;
  }

  p {
    font-family: "VT323", monospace;
    letter-spacing: 1px;
    font-size: 36px;
  }

  p {
    --color: var(--blue);
    color: var(--color);
    cursor: pointer;
  }
const textEls = document.querySelectorAll("p");

textEls.forEach((textEl) => {
  splitText(textEl, { charClass: "char" });
  textEl.addEventListener("mouseenter", hoverTextAnimation);
});

/**
 * @typedef {object} SplitTextOptions
 * @property {'words' | 'chars'} type
 * @property {string?} wordClass
 * @property {string?} charClass
 *
 * @param {HTMLElement} element
 * @param {SplitTextOptions} options
 */
function splitText(element, { type = "chars", wordClass, charClass } = {}) {
  const text = element.innerText;
  let splitArray;

  if (type === "words") {
    splitArray = text
      .split(" ")
      .map((word) => `<span class="${wordClass}">${word}</span>`);
  } else if (type === "chars") {
    splitArray = text
      .split("")
      .map((char) => `<span class="${charClass}">${char}</span>`);
  } else {
    throw new Error("Invalid split type. Use 'words' or 'chars'.");
  }

  element.innerHTML = splitArray.join(type === "words" ? " " : "");
}

/**
 *
 * @typedef {object} HoverTextAnimationOptions
 * @property {number?} charFreq
 * @property {number?} charDelay
 * @property {number?} randomCharRepeats
 *
 * @param {MouseEvent} e
 * @param {HoverTextAnimationOptions} options
 */
function hoverTextAnimation(
  e,
  { charDelay = 50, charFreq = 200, randomCharRepeats = 2 } = {}
) {
  const text = e.target?.closest("p");
  if (text.classList.contains("is-animated")) return;

  const chars = text.querySelectorAll(".char");
  text.classList.add("is-animated");

  chars.forEach((char, index) => {
    createCharAnimation(char, index, {
      charDelay,
      charFreq,
      randomCharRepeats,
    });
  });

  const totalDuration =
    charFreq * (randomCharRepeats + 1) + chars.length * charDelay;
  setTimeout(() => {
    text.classList.remove("is-animated");
  }, totalDuration);
}

/**
 * @param {HTMLElement} char
 * @param {number} index
 * @param {HoverTextAnimationOptions} options
 */
function createCharAnimation(
  char,
  index,
  { charDelay, charFreq, randomCharRepeats }
) {
  const initText = char.innerText;
  const delay = charDelay * index;

  // initial animation
  char.style.setProperty("opacity", 0);
  // first animation
  setTimeout(() => {
    char.style.setProperty("opacity", 1);
    char.style.setProperty("background", "var(--color)");
  }, charFreq - charDelay + delay);

  for (let i = 1; i <= randomCharRepeats; i++) {
    setTimeout(() => {
      i == 1 && char.style.setProperty("background", "transparent");
      char.innerText = randomLetterAndSymbol();
    }, charFreq * i + delay);
  }

  // last animation
  const totalDuration = charFreq * (randomCharRepeats + 1) + delay;
  setTimeout(() => {
    char.innerText = initText;
  }, totalDuration);
}

function randomLetterAndSymbol() {
  const chars =
    "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()";
  return chars.charAt(Math.floor(Math.random() * chars.length));
}


相關系列文章