前端網頁動效
-Terminal 終端機文字特效
前陣子看到這個文字特效,在 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);
});
接著讓我們分別看 splitText
和 hoverTextAnimation
函數的細節。
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);
}
- 首先傳入兩個參數
e
:事件對象。{ charDelay = 50, charFreq = 200, randomCharRepeats = 2 }
:物件的解構賦值,包含三個可選參數及其默認值:charDelay
:每個字元的延遲時間(默認為 50 毫秒)。charFreq
:動畫頻率或間隔時間(默認為 200 毫秒)。randomCharRepeats
:隨機字元重複次數(默認為 2 次)。
- 接著利用
is-animated
類名,防止重複動畫。 - 遍歷
chars
字元元素,對每個字元調用createCharAnimation
函數。 - 計算動畫的總持續時間,並移除
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);
}
- 初始化分為三個部分
- initText 用來保存元素的初始內容。
- delay 是基於 index 的延遲時間,控制每個字元的動畫開始時間。
- 將元素的透明度設置為 0
- 第一個動畫
- 將元素的透明度設置為 1。
- 設置字元元素的背景色為 CSS 變量
--color
。
- 隨機字符串的動畫使用
for
來設置多次隨機字符替換的動畫:- 循環次數為
randomCharRepeats
。 - 每次循環使用
setTimeout
設置延遲,在charFreq * i + delay
毫秒後執行:- 如果是第一次替換(即
i == 1
),將字元元素的背景設置為透明。 - 將字元元素的文本內容設置為隨機字符,通過
randomLetterAndSymbol()
函數生成。
- 如果是第一次替換(即
- 循環次數為
- 最後最後動畫(恢復原字符)
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));
}