【Next.js】ポートフォリオに載せるおしゃれなDESKアニメーションを作った話
概要
デスク上に置かれたカップから湯気が出ているアニメーションとラップトップ画面にラインを表示するアニメーションを繰り返すデザインのコンポーネント群を発見したため、それをベースにラップトップ画面にプログラムをタイピングするようなデザインを追加した「エンジニアの作業デスク風アニメーション」を作成しました。
ポイントはタイピングを表現するためのライブラリ群(Type.jsなど)やkeyframeなどのCSSを使用することなく、Next.jsのフロントエンドのみで完結して作成できるという点です。
用途
ポートフォリオを作成する際にHeroセクションに名前しか表示していないデザインだったのがあまり気に入らなかったため、「なんか作業している風のアニメーションか何かあればいいな...」と思い立った次第でした。いろいろ調べたところ、ラップトップとマグカップが置かれたデスクのCSSアニメーションを発見しました。(下記参照)
こちらを変更せずにデザインに組み込んでもよかったのですが、あまりに味気なかったためラップトップの画面に「Welcome to Portfolio」みたいなのを表示できたら面白いなと思いました。
というのが経緯のため、実装したものの用途としてはHeroセクションなどのトップページにおしゃれなアニメーションデザインを表示することを目的としていますが、これはどこでも応用の効くコンポーネントですので皆さんのお好きなように使用されればいいと思います。
作り方
ベースとしたCSSアニメーション
まずはベースとするCSSアニメーションを自分のNext.jsプロジェクトの方に作成します。
SVGを描画し、その中にデスク、ラップトップ、カップ、本などのオブジェクトを含む、DeskAnimationという名前のReact.FC(関数コンポーネント)としてフロントエンドとデザインを実装します。
import type React from "react"
import styles from "@/styles/DeskAnimation.module.css"
const DeskAnimation: React.FC = () => {
return (
<svg
width="256"
height="256"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={styles.deskSvg}
>
<g id="study">
<rect width="64" height="64" />
<g id="smoke">
<path
id="smoke-2"
d="M9 21L9.55279 19.8944C9.83431 19.3314 9.83431 18.6686 9.55279 18.1056L9 17L8.44721 15.8944C8.16569 15.3314 8.16569 14.6686 8.44721 14.1056L9 13"
stroke="#797270"
/>
<path
id="smoke-1"
d="M6.5 22L7.05279 20.8944C7.33431 20.3314 7.33431 19.6686 7.05279 19.1056L6.5 18L5.94721 16.8944C5.66569 16.3314 5.66569 15.6686 5.94721 15.1056L6.5 14"
stroke="#797270"
/>
</g>
<g id="laptop">
<rect id="laptop-base" x="17" y="28" width="20" height="3" fill="#F3F3F3" stroke="#453F3C" strokeWidth="2" />
<rect
id="laptop-screen"
x="18"
y="17"
width="18"
height="11"
fill="#5A524E"
stroke="#453F3C"
strokeWidth="2"
/>
<rect id="line-1" x="20" y="19" width="14" height="1" fill="#F78764" />
<rect id="line-2" x="20" y="21" width="14" height="1" fill="#F9AB82" />
<rect id="line-3" x="20" y="23" width="14" height="1" fill="#F78764" />
<rect id="line-4" x="20" y="25" width="14" height="1" fill="#F9AB82" />
</g>
<g id="cup">
<rect id="Rectangle 978" x="5" y="24" width="5" height="7" fill="#CCC4C4" stroke="#453F3C" strokeWidth="2" />
<path
id="Ellipse 416"
d="M11 28C12.1046 28 13 27.1046 13 26C13 24.8954 12.1046 24 11 24"
stroke="#453F3C"
strokeWidth="2"
/>
<rect id="Rectangle 996" x="6" y="25" width="3" height="1" fill="#D6D2D1" />
</g>
<g id="books">
<rect
id="Rectangle 984"
x="58"
y="27"
width="4"
height="14"
transform="rotate(90 58 27)"
fill="#B16B4F"
stroke="#453F3C"
strokeWidth="2"
/>
<rect
id="Rectangle 985"
x="56"
y="23"
width="4"
height="14"
transform="rotate(90 56 23)"
fill="#797270"
stroke="#453F3C"
strokeWidth="2"
/>
<rect
id="Rectangle 986"
x="60"
y="19"
width="4"
height="14"
transform="rotate(90 60 19)"
fill="#F78764"
stroke="#453F3C"
strokeWidth="2"
/>
<rect id="Rectangle 993" x="47" y="20" width="12" height="1" fill="#F9AB82" />
<rect id="Rectangle 994" x="43" y="24" width="12" height="1" fill="#54504E" />
<rect id="Rectangle 995" x="45" y="28" width="12" height="1" fill="#804D39" />
</g>
<g id="desk">
<rect id="Rectangle 973" x="4" y="31" width="56" height="5" fill="#797270" stroke="#453F3C" strokeWidth="2" />
<rect
id="Rectangle 987"
x="10"
y="36"
width="30"
height="6"
fill="#797270"
stroke="#453F3C"
strokeWidth="2"
/>
<rect id="Rectangle 975" x="6" y="36" width="4" height="24" fill="#797270" stroke="#453F3C" strokeWidth="2" />
<rect
id="Rectangle 974"
x="40"
y="36"
width="18"
height="24"
fill="#797270"
stroke="#453F3C"
strokeWidth="2"
/>
<line id="Line 129" x1="40" y1="48" x2="58" y2="48" stroke="#453F3C" strokeWidth="2" />
<line id="Line 130" x1="22" y1="39" x2="28" y2="39" stroke="#453F3C" strokeWidth="2" />
<line id="Line 142" x1="46" y1="42" x2="52" y2="42" stroke="#453F3C" strokeWidth="2" />
<line id="Line 131" x1="46" y1="54" x2="52" y2="54" stroke="#453F3C" strokeWidth="2" />
<rect id="Rectangle 988" x="11" y="37" width="28" height="1" fill="#54504E" />
<rect id="Rectangle 992" x="5" y="32" width="54" height="1" fill="#9E9492" />
<rect id="Rectangle 989" x="7" y="37" width="2" height="1" fill="#54504E" />
<rect id="Rectangle 990" x="41" y="37" width="16" height="1" fill="#54504E" />
<rect id="Rectangle 991" x="41" y="49" width="16" height="1" fill="#54504E" />
<line id="Line 143" y1="60" x2="64" y2="60" stroke="#453F3C" strokeWidth="2" />
</g>
</g>
</svg>
)
}
export default DeskAnimation
.deskSvg {
width: 100%;
max-width: 256px;
height: auto;
}
/* SMOKE */
.deskSvg #smoke-1 {
stroke-dasharray: 0, 10;
animation: smoke 6s ease infinite;
}
.deskSvg #smoke-2 {
stroke-dasharray: 0, 10;
animation: smoke 6s 0.5s ease infinite;
}
@keyframes smoke {
0% {
stroke-dasharray: 0, 10;
}
50% {
stroke-dasharray: 10, 0;
}
100% {
stroke-dasharray: 10, 0;
opacity: 0;
}
}
/* WRITING */
.deskSvg #line-1 {
opacity: 0;
animation: writing 0.5s linear forwards;
}
.deskSvg #line-2 {
opacity: 0;
animation: writing 0.5s 1s linear forwards;
}
.deskSvg #line-3 {
opacity: 0;
animation: writing 0.5s 1.5s linear forwards;
}
.deskSvg #line-4 {
opacity: 0;
animation: writing 0.5s 2s linear forwards;
}
@keyframes writing {
0% {
width: 0px;
opacity: 1;
}
100% {
width: 14px;
opacity: 1;
}
}
タイピングアニメーションの実装
はやいところお見せしてしまうと実装は以下のようになっています。
使用していたHooks関連については下記に記してありますので、そちらをご覧ください。
"use client"
import type React from "react"
import { useEffect, useRef } from "react"
import styles from "@/styles/DeskAnimation.module.css"
const DeskAnimation: React.FC = () => {
const codeRef = useRef<HTMLElement>(null)
const timeoutIdRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
const codeElement = codeRef.current
if (!codeElement) return
const text = `const greet = () => {\n console.log(\n \"Welcome to portfolio\" \n );\n}\nexport default greet`
let index = 0
function typeText() {
if (!codeElement) return
if (index === 0) codeElement.textContent = ""
if (index < text.length) {
codeElement.textContent += text.charAt(index)
index++
timeoutIdRef.current = setTimeout(typeText, 100)speed here
} else {
timeoutIdRef.current = setTimeout(() => {
index = 0
typeText()
}, 2000) // Pause before restarting
}
}
typeText()
return () => {
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current)
}
}, [])
return (
<svg
width="512"
height="512"
viewBox="0 0 64 64"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={styles.deskSvg}
>
<g id="study">
<g id="laptop">
<rect id="laptop-base" x="17" y="28" width="20" height="3" fill="#F3F3F3" stroke="#453F3C" strokeWidth="2" />
<rect
id="laptop-screen"
x="18"
y="17"
width="18"
height="11"
fill="#5A524E"
stroke="#453F3C"
strokeWidth="2"
/>
<foreignObject x="19" y="18" width="17" height="9">
<div className={styles.code}>
<pre>
<code ref={codeRef}></code>
</pre>
</div>
</foreignObject>
</g>
{/* ラップトップ以外の実装は全て同じ */}
</g>
</svg>
)
}
export default DeskAnimation
/* タイピングアニメーションに使用したCSSのみ */
.code {
font-family: monospace;
font-size: 1px;
color: #f78764;
white-space: pre;
overflow: hidden;
text-align: left;
}
.code pre {
margin: 0;
}
.code code {
display: inline-block;
}
以下は実際に作成したアニメーションのイメージになります。
↓下記のcodepenで使用されているコードは実際のコードではなくcodepen用のコードです↓
以下のHooksや組み込み関数を使用して実装しました。順に見ていきましょう。
| 使用技術 | 説明 |
|---|---|
useRef |
codeRef を保持し、DOM 操作を可能にする |
useEffect |
初回レンダリング時に typeText を実行、クリーンアップ処理 |
setTimeout |
100ms ごとに typeText を実行し、タイピングアニメーションを再現 |
charAt |
text の index 番目の文字を取得し、1文字ずつ追加 |
| 再帰処理 |
setTimeout を再帰的に呼び出し、アニメーションをループ |
clearTimeout |
アンマウント時に setTimeout を停止し、メモリリークを防ぐ |
1. useRef(要素の参照を保持)
const codeRef = useRef<HTMLElement>(null)
useRefは React で DOM 要素を直接操作するためのフック-
<code>要素を参照し、テキストを変更するために使用 -
useRefの値はレンダリング時に変更されても再描画を引き起こさないため、パフォーマンスが良い
どこで使われているか
const codeElement = codeRef.current
if (!codeElement) return
-
codeRef.currentをcodeElementに代入し、textContentを直接変更する
2. useEffect(コンポーネントのライフサイクル管理)
useEffect(() => {
// タイピングアニメーション開始
typeText()
return () => {
// クリーンアップ処理
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current)
}
}, [])
useEffectを利用して、コンポーネントがマウントされた時にtypeTextを実行- クリーンアップ関数で
clearTimeoutを実行し、メモリリークを防止 -
[]の依存配列が空なので、コンポーネントの初回マウント時にのみ実行
3. setTimeout(遅延処理を利用したアニメーション)
timeoutIdRef.current = setTimeout(typeText, 100)
-
setTimeoutを使用して、100ms(0.1秒)ごとにtypeTextを呼び出し、文字を1つずつ追加 -
timeoutIdRefをuseRefで保持することで、アニメーション中にclearTimeoutできるようにしている
4. 文字列操作(charAt を用いた文字列の1文字ずつ追加)
if (index < text.length) {
codeElement.textContent += text.charAt(index)
index++
timeoutIdRef.current = setTimeout(typeText, 100)
}
charAt(index)を使用してtextのindex番目の文字を取得-
textContent +=で1文字ずつ追加し、タイピングエフェクトを再現 -
index++で次の文字に進む
アニメーション対象のテキスト
const text = `const greet = () => {\n console.log(\n \"Welcome to portfolio\" \n );\n}\nexport default greet`
- JavaScript の関数
greetをタイプライター風に表示 -
\n(改行)も含まれる
5. 非同期処理(アニメーションの再帰呼び出し)
function typeText() {
if (!codeElement) return
if (index === 0) codeElement.textContent = ""
if (index < text.length) {
codeElement.textContent += text.charAt(index)
index++
timeoutIdRef.current = setTimeout(typeText, 100)
} else {
timeoutIdRef.current = setTimeout(() => {
index = 0
typeText()
}, 2000)
}
}
-
typeText内で 再帰的にsetTimeoutを使用して自身を呼び出す - 文字列の最後まで表示したら、2秒待機してリセット
index = 0に戻し、typeTextを再度実行することで、アニメーションをループ
6. クリーンアップ処理(clearTimeout によるメモリリーク防止)
return () => {
if (timeoutIdRef.current) clearTimeout(timeoutIdRef.current)
}
- コンポーネントがアンマウントされた際に
clearTimeoutを実行し、不要なsetTimeoutをキャンセル - これにより、コンポーネントが削除された後に
setTimeoutが実行されるのを防ぐ
全体の処理フロー
- コンポーネントがマウントされる
useEffectによりtypeText関数が実行されるtypeTextがsetTimeoutを使って 100ms ごとに 1 文字ずつ表示- 全ての文字を表示し終えたら、2秒間停止
- リセット(
index = 0)してアニメーションを再開 - コンポーネントがアンマウントされると
clearTimeoutによりsetTimeoutがクリアされる
まとめ
今回はNext.jsでおしゃれなDESKアニメーションを作る方法についてまとめました。
Next.jsではフロントエンドだけでアニメーションを作成することができることを学べてよかったなと思います。
これからもやってみたことをZennでまとめていきますので、よろしくお願いいたします。
Discussion