🖥️

【Next.js】ポートフォリオに載せるおしゃれなDESKアニメーションを作った話

2025/02/08に公開

概要

デスク上に置かれたカップから湯気が出ているアニメーションとラップトップ画面にラインを表示するアニメーションを繰り返すデザインのコンポーネント群を発見したため、それをベースにラップトップ画面にプログラムをタイピングするようなデザインを追加した「エンジニアの作業デスク風アニメーション」を作成しました。

ポイントはタイピングを表現するためのライブラリ群(Type.jsなど)やkeyframeなどのCSSを使用することなく、Next.jsのフロントエンドのみで完結して作成できるという点です。

用途

ポートフォリオを作成する際にHeroセクションに名前しか表示していないデザインだったのがあまり気に入らなかったため、「なんか作業している風のアニメーションか何かあればいいな...」と思い立った次第でした。いろいろ調べたところ、ラップトップとマグカップが置かれたデスクのCSSアニメーションを発見しました。(下記参照)

こちらを変更せずにデザインに組み込んでもよかったのですが、あまりに味気なかったためラップトップの画面に「Welcome to Portfolio」みたいなのを表示できたら面白いなと思いました。

というのが経緯のため、実装したものの用途としてはHeroセクションなどのトップページにおしゃれなアニメーションデザインを表示することを目的としていますが、これはどこでも応用の効くコンポーネントですので皆さんのお好きなように使用されればいいと思います。

作り方

ベースとしたCSSアニメーション

まずはベースとするCSSアニメーションを自分のNext.jsプロジェクトの方に作成します。

SVGを描画し、その中にデスク、ラップトップ、カップ、本などのオブジェクトを含む、DeskAnimationという名前のReact.FC(関数コンポーネント)としてフロントエンドとデザインを実装します。

app/components/DeskAnimation.tsx
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
styles/DeskAnimation.module.css
.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関連については下記に記してありますので、そちらをご覧ください。

app/components/DeskAnimation.tsx
"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

styles/DeskAnimation.module.css
/* タイピングアニメーションに使用した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 textindex 番目の文字を取得し、1文字ずつ追加
再帰処理 setTimeout を再帰的に呼び出し、アニメーションをループ
clearTimeout アンマウント時に setTimeout を停止し、メモリリークを防ぐ

1. useRef(要素の参照を保持)

const codeRef = useRef<HTMLElement>(null)
  • useRef は React で DOM 要素を直接操作するためのフック
  • <code> 要素を参照し、テキストを変更するために使用
  • useRef の値はレンダリング時に変更されても再描画を引き起こさないため、パフォーマンスが良い

どこで使われているか

const codeElement = codeRef.current
if (!codeElement) return
  • codeRef.currentcodeElement に代入し、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つずつ追加
  • timeoutIdRefuseRef で保持することで、アニメーション中に clearTimeout できるようにしている

4. 文字列操作(charAt を用いた文字列の1文字ずつ追加)

if (index < text.length) {
  codeElement.textContent += text.charAt(index)
  index++
  timeoutIdRef.current = setTimeout(typeText, 100)
}
  • charAt(index) を使用して textindex 番目の文字を取得
  • 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 が実行されるのを防ぐ

全体の処理フロー

  1. コンポーネントがマウントされる
  2. useEffect により typeText 関数が実行される
  3. typeTextsetTimeout を使って 100ms ごとに 1 文字ずつ表示
  4. 全ての文字を表示し終えたら、2秒間停止
  5. リセット(index = 0)してアニメーションを再開
  6. コンポーネントがアンマウントされると clearTimeout により setTimeout がクリアされる

まとめ

今回はNext.jsでおしゃれなDESKアニメーションを作る方法についてまとめました。
Next.jsではフロントエンドだけでアニメーションを作成することができることを学べてよかったなと思います。
これからもやってみたことをZennでまとめていきますので、よろしくお願いいたします。

Discussion