🐕

figma-clone作り中の棚卸し

に公開

本記事の内容

基本的には技術の棚卸しを目的としています。
現在、下記の動画を参考にfigma-cloneを作成し、勉強しています、
今回は、途中報告としています。
そのため、環境構築などは、下記動画を参考にしてください。英語ですが、指示に従えば特に問題はないです。
https://www.youtube.com/watch?v=oKIThIihv60

フォルダ構成のおさらい

本プロジェクト「figma-clone」のディレクトリ構成は、概ね以下の通りです。

figma-clone
 ├─ app
 │   ├─ layout.tsx
 │   ├─ page.tsx
 │   └─ Room.tsx
 └─ components
     ├─ Live.tsx
     ├─ cursor
     │   ├─ Cursor.tsx
     │   ├─ CursorChat.tsx
     │   └─ LiveCursors.tsx
     └─ reaction
         ├─ FlyingReaction.tsx
         ├─ index.module.css
         └─ ReactionButton.tsx
  • app/ ディレクトリ: Next.js(App Router) で使われる主要ファイル。
  • components/ ディレクトリ: Reactコンポーネントを機能ごとに配置している。
    • cursor/ … カーソル表示やチャット機能に関連
    • reaction/ … リアクション(絵文字)などに関連

この時点でのできること

このシステム(記事)で実現できることは、「複数ユーザーが同じ画面を見ながら、マウスカーソルやメッセージ、リアクション(絵文字)をリアルタイムに共有し合える」アプリの仕組みを学べることです。記事を読むことで、Next.js と Liveblocks を組み合わせたリアルタイムコラボアプリの具体的な実装方法を理解できます。


全体像

現状でできる機能は以下です。

  1. Next.js の App Router 構成

    • app/ ディレクトリ配下の layout.tsxpage.tsx でアプリ全体のレイアウトとページを管理し、Reactコンポーネントを自由に呼び出せる。
  2. Liveblocks を使ったリアルタイム通信

    • Room.tsxLiveblocksProviderRoomProvider を設定しており、全コンポーネントで「自分の状態」と「他人の状態」をリアルタイムに共有できる。
    • useMyPresence, useOthers, useBroadcastEvent, useEventListener などのフックを通じて、カーソル座標やメッセージ、イベント(リアクション)を送受信。
  3. カーソルとチャット機能

    • Cursor.tsx, CursorChat.tsx, LiveCursors.tsx で「カーソルの表示」「チャットモードの入力フォーム」「他ユーザーのカーソル描画」が実装され、ユーザー同士のやり取りを可視化できる。
  4. 絵文字リアクションとアニメーション

    • FlyingReaction.tsxindex.module.css を組み合わせ、絵文字が画面上をフワッと飛んで消えるアニメーションを実装。
    • ReactionButton.tsx からリアクションを選択し、選択された絵文字が全クライアントにブロードキャストされる。

各プログラム解説


1. app/ 配下

1.1 layout.tsx

import { Work_Sans } from "next/font/google";

import "./globals.css";

import { Room } from "./Room";

export const metadata = {
  title: "Figma Clone",
  description:
    "A minimalist Figma clone using fabric.js and Liveblocks for realtime collaboration",
};

const workSans = Work_Sans({
  subsets: ["latin"],
  variable: "--font-work-sans",
  weight: ["400", "600", "700"],
});

const RootLayout = ({ children }: { children: React.ReactNode }) => (
  <html lang='en'>
    <body className={`${workSans.className} bg-primary-grey-200`}>
      <Room>
        {children}
      </Room>
    </body>
  </html>
);

export default RootLayout;

解説

  1. import { Work_Sans } from "next/font/google";

    • Next.js 13 以降で推奨されるフォントの読み込み機能です。Google Fonts の「Work Sans」をプロジェクトに導入しています。
  2. import "./globals.css";

    • Tailwind等が定義されている globals.css をグローバルに適用。
  3. import { Room } from "./Room";

    • 後述の Room.tsx から Room コンポーネントをインポート。
    • レイアウト全体に Liveblocks のルーム(部屋)を適用し、アプリのどのページでもリアルタイム機能を使えるようにする狙いがあります。
  4. export const metadata = {...}

    • Next.js 13(App Router)の仕組みで、ページタイトルやディスクリプションなどを定義するためのオブジェクトです。
  5. const workSans = Work_Sans({ ... })

    • Work Sans を読み込み、ウェイトやサブセットを指定。--font-work-sans というカスタムプロパティにも適用しています。
  6. const RootLayout = ({ children }: { children: React.ReactNode }) => ( ... )

    • Reactコンポーネントとして書かれたレイアウト関数。children にはこのレイアウト配下のページやコンポーネントが入ります。
  7. <html lang='en'> ... </html>

    • HTML 全体のルートタグ。言語を英語に設定。
  8. <body className={${workSans.className} bg-primary-grey-200}>

    • 先ほど読み込んだフォントのクラス workSans.className と、Tailwind のクラス bg-primary-grey-200 を適用。背景色を薄いグレーに。
  9. <Room>{children}</Room>

    • ここで Room コンポーネントにラップすることで、すべての子要素が Liveblocks のルームに所属する形になります。

1.2 page.tsx

"use client";

import Live from "@/components/Live";

export default function Page() {
  return (
    <div className="h-[100vh] w-full flex justify-center items-center text-center">
      <Live />
    </div>
  );
}

解説

  1. "use client";

    • Next.js において、このコンポーネントがクライアント側でレンダリングされるという宣言。
  2. import Live from "@/components/Live";

    • components/Live.tsx から読み込んだ Live コンポーネントを使っています。
  3. export default function Page() { ... }

    • この Page コンポーネントが app/ ディレクトリ直下のルート ( / ) として機能します。
  4. <div className="h-[100vh] w-full flex justify-center items-center text-center">

    • 画面全体を高さ100%(100vh)にし、中央に要素を寄せるスタイルをTailwindで実装。
  5. <Live />

    • 後述する Live コンポーネントを単純に呼び出しているだけのシンプルな構造。
    • ここで描画される中身は、Liveblocks やカーソルの仕組みが詰まったコンポーネント側に委ねられています。

1.3 Room.tsx

"use client";

import { ReactNode } from "react";
import {
  LiveblocksProvider,
  RoomProvider,
  ClientSideSuspense,
} from "@liveblocks/react/suspense";

export function Room({ children }: { children: ReactNode }) {
  return (
    <LiveblocksProvider publicApiKey={process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!}>
      <RoomProvider id="my-room">
        <ClientSideSuspense fallback={<div>Loading…</div>}>
          {() => children}
        </ClientSideSuspense>
      </RoomProvider>
    </LiveblocksProvider>
  );
}

解説

  1. "use client";

    • こちらもクライアントコンポーネントとして動作。
  2. import { LiveblocksProvider, RoomProvider, ClientSideSuspense } from "@liveblocks/react/suspense";

    • Liveblocks の React 用ライブラリから機能をインポートしています。
      • LiveblocksProvider : 全体のリアルタイム機能用コンテキストを作るコンポーネント
      • RoomProvider : 特定のルーム(部屋)に参加するためのコンポーネント
      • ClientSideSuspense : クライアントサイドの Suspense 機能
  3. <LiveblocksProvider publicApiKey={process.env.NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY!}>

    • .env などに記載されている NEXT_PUBLIC_LIVEBLOCKS_PUBLIC_KEY を利用して認証。
    • ! は TypeScript の「非 null アサーションオペレータ」で、確実に存在する前提で利用。
  4. <RoomProvider id="my-room">

    • id="my-room" という名前の部屋に入る。
    • 同じルームIDを共有しているユーザー同士はリアルタイムで状態を同期できます。
  5. <ClientSideSuspense fallback={<div>Loading…</div>}>

    • クライアントサイドでローディングが必要な時に <div>Loading…</div> を表示。
    • {() => children} で子要素をレンダリング。

これにより、Room コンポーネント配下の要素(今回だと layout.tsxchildrenpage.tsx が含まれるすべて)で Liveblocks の各種フックや状態が利用できるようになります。


2. components/ 配下

2.1 Live.tsx

import { useBroadcastEvent, useEventListener, useMyPresence, useOthers } from "@/liveblocks.config";
import LiveCursors from "./cursor/LiveCursors";
import React, { useCallback, useEffect, useState } from "react";
import CursorChat from "./cursor/CursorChat";
import { CursorMode, CursorState, Reaction, ReactionEvent } from "@/types/type";
import ReactionSelector from "./reaction/ReactionButton";
import FlyingReaction from "./reaction/FlyingReaction";
import useInterval from "@/hooks/useInterval";

const Live = () => {
    const others = useOthers();
    const [{ cursor }, updateMyPresence ] = useMyPresence() as any;

    const [cursorState, setCursorState] = useState<CursorState>({
        mode: CursorMode.Hidden,
    });

    const [reactions, setReactions] = useState<Reaction[]>([]);

    const broadcast = useBroadcastEvent();

    useInterval(() => {
      setReactions((reactions) => reactions.filter((reaction) => reaction.timestamp > Date.now() - 4000));
    }, 1000);
    
    useInterval(() => {
      if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) {
        setReactions((reactions) =>
          reactions.concat([
            {
              point: { x: cursor.x, y: cursor.y },
              value: cursorState.reaction,
              timestamp: Date.now(),
            },
          ])
        );
        broadcast({
          x: cursor.x,
          y: cursor.y,
          value: cursorState.reaction,
        });
      }
    }, 100);

    useEventListener((eventData) => {
      const event = eventData.event as ReactionEvent;
      setReactions((reactions) =>
        reactions.concat([
          {
            point: { x: event.x, y: event.y },
            value: event.value,
            timestamp: Date.now(),
          },
        ])
      );
    });

    const handlePointerMove = useCallback((event: React.PointerEvent) => {
        event.preventDefault();
        if(cursor === null || cursorState.mode !== CursorMode.ReactionSelector ){
          const x = event.clientX - event.currentTarget.getBoundingClientRect().x;
          const y = event.clientY - event.currentTarget.getBoundingClientRect().y;
          updateMyPresence({ cursor: { x, y} });
        }
    }, [cursor, cursorState.mode, updateMyPresence]);

    const handlePointerLeave = useCallback(() => {
        setCursorState({ mode: CursorMode.Hidden });
        updateMyPresence({ cursor: { cursor: null, message: null } });
    }, [updateMyPresence]);

    const handlePointerDown = useCallback((event: React.PointerEvent) => {
        const x = event.clientX - event.currentTarget.getBoundingClientRect().x;
        const y = event.clientY - event.currentTarget.getBoundingClientRect().y;

        updateMyPresence({ cursor: { x, y} });
        setCursorState(( state: CursorState ) => 
          cursorState.mode === CursorMode.Reaction 
            ? {...state, isPressed: true}
            : state
        );
    }, [cursorState.mode, updateMyPresence]);

    const handlePointerUp = useCallback(() => {
      setCursorState(( state: CursorState ) => 
        cursorState.mode === CursorMode.Reaction
          ? {...state, isPressed: true}
          : state
      );
    }, [cursorState.mode]);

    useEffect(() => {
        const onKeyUp = (e: KeyboardEvent) => {
          if (e.key === "/") {
            setCursorState({
              mode: CursorMode.Chat,
              previousMessage: null,
              message: "",
            });
          } else if (e.key === "Escape") {
            updateMyPresence({ message: "" });
            setCursorState({ mode: CursorMode.Hidden });
          } else if (e.key === "e") {
            setCursorState({ mode: CursorMode.ReactionSelector });
          }
        };
    
        const onKeyDown = (e: KeyboardEvent) => {
          if (e.key === "/") {
            e.preventDefault();
          }
        };
        window.addEventListener("keyup", onKeyUp);
        window.addEventListener("keydown", onKeyDown);

        return () => {
            window.removeEventListener("keyup", onKeyUp);
            window.removeEventListener("keydown", onKeyDown);
        };
    }, [updateMyPresence]);

    const setReaction = useCallback((reaction: string) => {
      setCursorState({ mode: CursorMode.Reaction, reaction, isPressed: false });
    }, []);

    return (
        <div
            onPointerMove={handlePointerMove}
            onPointerLeave={handlePointerLeave}
            onPointerDown={handlePointerDown}
            onPointerUp={handlePointerUp}
            className="h-[100vh] w-full flex justify-center items-center text-center border-2 border-green-500"
        >
            <h1 className="text-2xl text-white">Liveblocks Figma cloen</h1>
            {reactions.map((r) => (
              <FlyingReaction
                key={r.timestamp.toString()}
                x={r.point.x}
                y={r.point.y}
                timestamp={r.timestamp}
                value={r.value}
              />
            ))}

            {cursor && (
                <CursorChat 
                    cursor={cursor}
                    cursorState={cursorState}
                    setCursorState={setCursorState}
                    updateMyPresence={updateMyPresence}
                />
            )}

            {cursorState.mode === CursorMode.ReactionSelector && (
              <ReactionSelector 
                setReaction={setReaction}
              />
            )}

            <LiveCursors others={others} />
        </div>
     );
}
 
export default Live;

解説 (主要ポイント)

  1. フック

    • useMyPresence(): 自分の状態 (ここでは cursormessage) を管理・更新するための Liveblocks フック。
    • useOthers(): 他ユーザーの状態を配列として取得。
    • useBroadcastEvent(): 全ユーザーへイベントをブロードキャストする仕組み。
    • useEventListener(): 他ユーザーが送ってきたイベントを受け取る。
  2. カーソル状態 (cursorState)

    • mode によって現在のカーソル機能が「Hidden」「Chat」「Reaction」「ReactionSelector」などに分岐。
    • 例えば「/」キーを押すとチャットモードに切り替わり、エンターで送信できる。
    • e」キーで絵文字リアクション選択モードに入る。
  3. リアクション管理 (reactions)

    • リアクション(絵文字)を FlyingReaction コンポーネントに渡して画面に表示し、useInterval で定期的に古いものを消す。
    • if (cursorState.mode === CursorMode.Reaction && cursorState.isPressed && cursor) { ... } の部分で、マウスを押しっぱなしの間に連続して絵文字を放出する仕組み。
  4. ポインタイベントのハンドラ

    • handlePointerMove: マウスを動かすたびに座標を updateMyPresence で送信し、他ユーザーにも共有。
    • handlePointerDown, handlePointerUp: リアクションモード時にクリック中かどうか(isPressed)を管理。
    • handlePointerLeave: マウスが要素外に出たらカーソルを非表示(mode: Hidden)に変更。
  5. キーボードイベント

    • useEffect でグローバルにキーイベントを監視し、"/" キーでチャット開始、"Escape" でチャット取り消しなどを実装。
  6. レンダリング部分

    • <h1 className="text-2xl text-white">Liveblocks Figma cloen</h1> : タイトル(typo:「cloen」ですが、おそらく「clone」の誤字)。
    • {reactions.map((r) => <FlyingReaction ... />)} : 複数の絵文字が飛び交う処理。
    • {cursor && <CursorChat ... />} : 自分のカーソルにチャット入力ウィンドウを表示。
    • {cursorState.mode === CursorMode.ReactionSelector && <ReactionSelector ... />} : 絵文字の選択UI表示。
    • <LiveCursors others={others} /> : 他ユーザーのカーソルをまとめて描画。

2.2 cursor/Cursor.tsx

import CursorSVG from "@/public/assets/CursorSVG";

type Props = {
    color: string;
    x: number;
    y: number;
    message: string;
}

const Cursor = ({ color, x, y , message }:Props) => {
    return ( 
        <div className="pointer-events-none absolute top-0 left-0" style={{ transform: `translateX(${x}px) translateY(${y}px)`}}>
            <CursorSVG color={color} />

            {message && (
                <div
                    className='absolute left-2 top-5 rounded-3xl px-4 py-2'
                    style={{ backgroundColor: color, borderRadius: 20 }}
                >
                    <p className='whitespace-nowrap text-sm leading-relaxed text-white'>
                    {message}
                    </p>
                </div>
            )}
        </div>
     );
}
 
export default Cursor;

解説

  1. CursorSVG はマウスカーソルのアイコン画像を React コンポーネント化したもの (SVG)。
  2. style={{ transform: translateX({x}px) translateY({y}px)}} で、画面上にカーソルを配置。
  3. pointer-events-none を付与し、ユーザーがカーソル要素をクリックするのを無効化している。
  4. message がある場合のみ吹き出し要素を表示。吹き出し背景は style={{ backgroundColor: color }} で他ユーザーごとの色を変えられます。

2.3 cursor/CursorChat.tsx

import { CursorChatProps, CursorMode } from "@/types/type";
import CursorSVG from "@/public/assets/CursorSVG";

const CursorChat = ({ cursor, cursorState, setCursorState, updateMyPresence }: CursorChatProps) => {
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    updateMyPresence({ message: e.target.value });
    setCursorState({
      mode: CursorMode.Chat,
      previousMessage: null,
      message: e.target.value,
    });
  };

  const handleKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
    if (e.key === "Enter") {
      setCursorState({
        mode: CursorMode.Chat,
        // @ts-ignore
        previousMessage: cursorState.message,
        message: "",
      });
    } else if (e.key === "Escape") {
      setCursorState({
        mode: CursorMode.Hidden,
      });
    }
  };

  return (
    <div
      className="absolute top-0 left-0"
      style={{
        transform: `translateX(${cursor.x}px) translateY(${cursor.y}px)`,
      }}
    >
      {cursorState.mode === CursorMode.Chat && (
        <>
          <CursorSVG color="#000" />

          <div
            className="absolute left-2 top-5 bg-blue-500 px-4 py-2 text-sm leading-relaxed text-white"
            onKeyUp={(e) => e.stopPropagation()}
            style={{
              borderRadius: 20,
            }}
          >
            {cursorState.previousMessage && <div>{cursorState.previousMessage}</div>}
            <input
              className="z-10 w-60 border-none bg-transparent text-white placeholder-blue-300 outline-none"
              autoFocus={true}
              onChange={handleChange}
              onKeyDown={handleKeyDown}
              placeholder={cursorState.previousMessage ? "" : "Say something…"}
              value={cursorState.message}
              maxLength={50}
            />
          </div>
        </>
      )}
    </div>
  );
};

export default CursorChat;

解説

  1. CursorChatProps, CursorMode は型定義が別ファイル( @/types/type など)にまとめられている。
  2. updateMyPresence({ message: e.target.value }) で入力値を更新し、他ユーザーにリアルタイムでチャット文字列を共有。
  3. handleKeyDown で Enter キー → メッセージを確定して消す、Escape キー → チャットキャンセル といった処理を実装。
  4. autoFocus={true} があるため、モードがチャットに切り替わった際、すぐに入力を開始できる。

2.4 cursor/LiveCursors.tsx

import Cursor from "./Cursor";
import { COLORS } from "@/constants";
import { LiveCursorProps } from "@/types/type";

const LiveCursors = ({ others }: LiveCursorProps) => {
  return others.map(({ connectionId, presence }) => {
    if (presence == null || !presence?.cursor) {
      return null;
    }

    return (
      <Cursor
        key={connectionId}
        color={COLORS[Number(connectionId) % COLORS.length]}
        x={presence.cursor.x}
        y={presence.cursor.y}
        message={presence.message}
      />
    );
  });
};

export default LiveCursors;

解説

  1. othersuseOthers() フックで取得したデータを想定し、ユーザーごとの connectionIdpresence を持つ。
  2. presence?.cursor が存在するユーザーのみをマップして、Cursor コンポーネントで描画。
  3. COLORS は別の定数定義ファイルにあり、複数の色が配列で管理されている想定。connectionId % COLORS.length でユーザーごとに色を決定。

2.5 reaction/ReactionButton.tsx

import React from "react";

type Props = {
  setReaction: (reaction: string) => void;
};

export default function ReactionSelector({ setReaction }: Props) {
  return (
    <div
      className="absolute bottom-20 left-0 right-0 mx-auto w-fit transform rounded-full bg-white px-2"
      onPointerMove={(e) => e.stopPropagation()}
    >
      <ReactionButton reaction="👍" onSelect={setReaction} />
      <ReactionButton reaction="🔥" onSelect={setReaction} />
      <ReactionButton reaction="😍" onSelect={setReaction} />
      <ReactionButton reaction="👀" onSelect={setReaction} />
      <ReactionButton reaction="😱" onSelect={setReaction} />
      <ReactionButton reaction="🙁" onSelect={setReaction} />
    </div>
  );
}

function ReactionButton({
  reaction,
  onSelect,
}: {
  reaction: string;
  onSelect: (reaction: string) => void;
}) {
  return (
    <button
      className="transform select-none p-2 text-xl transition-transform hover:scale-150 focus:scale-150 focus:outline-none"
      onPointerDown={() => onSelect(reaction)}
    >
      {reaction}
    </button>
  );
}

解説

  1. ReactionSelector は絵文字ボタンをずらっと並べたUI。 setReaction が呼ばれると「何の絵文字を選択したか」が上位コンポーネント(Live.tsxなど)に伝わります。
  2. onPointerMove={(e) => e.stopPropagation()} は、ボタン上でのマウス移動が他要素に伝わらないようにするためのもの。
  3. ReactionButtononPointerDown イベントを使い、クリック時に絵文字を選択。
  4. ボタンに hover:scale-150 を付け、マウスホバー時に拡大するアニメーションをTailwindで実装。

2.6 reaction/FlyingReaction.tsx

import styles from "./index.module.css";

type Props = {
  x: number;
  y: number;
  timestamp: number;
  value: string;
};

export default function FlyingReaction({ x, y, timestamp, value }: Props) {
  return (
    <div
      className={`pointer-events-none absolute select-none ${
        styles.disappear
      } text-${(timestamp % 5) + 2}xl ${styles["goUp" + (timestamp % 3)]}`}
      style={{ left: x, top: y }}
    >
      <div className={styles["leftRight" + (timestamp % 3)]}>
        <div className="-translate-x-1/2 -translate-y-1/2 transform">
          {value}
        </div>
      </div>
    </div>
  );
}

解説

  1. FlyingReaction コンポーネントは、選択された絵文字を画面上でアニメーション表示するためのもの。
  2. timestamp % 5 を使って文字サイズ(text-2xl ~ text-6xl)を動的に変化させたり、timestamp % 3 で3パターンのアニメーション(goUp0, goUp1, goUp2 など)を切り替えて飽きのこない動きを演出。
  3. styles["leftRight" + (timestamp % 3)] といった形で、CSS モジュールのクラス名を組み立てています。
  4. pointer-events-none でこの要素がクリックなどの対象にならないように。

2.7 reaction/index.module.css

.goUp0 {
  opacity: 0;
  animation:
    goUpAnimation0 2s,
    fadeOut 2s;
}

@keyframes goUpAnimation0 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(0px, -400px);
  }
}

.goUp1 {
  opacity: 0;
  animation:
    goUpAnimation1 2s,
    fadeOut 2s;
}

@keyframes goUpAnimation1 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(0px, -300px);
  }
}

.goUp2 {
  opacity: 0;
  animation:
    goUpAnimation2 2s,
    fadeOut 2s;
}

@keyframes goUpAnimation2 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(0px, -200px);
  }
}

.leftRight0 {
  animation: leftRightAnimation0 0.3s alternate infinite ease-in-out;
}

@keyframes leftRightAnimation0 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(50px, 0px);
  }
}

.leftRight1 {
  animation: leftRightAnimation1 0.3s alternate infinite ease-in-out;
}

@keyframes leftRightAnimation1 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(100px, 0px);
  }
}

.leftRight2 {
  animation: leftRightAnimation2 0.3s alternate infinite ease-in-out;
}

@keyframes leftRightAnimation2 {
  from {
    transform: translate(0px, 0px);
  }
  to {
    transform: translate(-50px, 0px);
  }
}

@keyframes fadeOut {
  from {
    opacity: 1;
  }
  to {
    opacity: 0;
  }
}

解説

  1. .goUp0, .goUp1, .goUp2 で上方向への移動アニメーション量( -400px, -300px, -200px )をそれぞれ変えています。
  2. .leftRight0, .leftRight1, .leftRight2 は左右に揺れるアニメーションで、アニメーションの距離や方向を微妙に変えてバリエーションを出しています。
  3. .fadeOut は最終的に opacity: 0; になるアニメーションを 2 秒かけて実行し、絵文字が消えていく表現。
  4. FlyingReaction コンポーネントで timestamp % 3 を使ってクラスを付与しているため、ユーザーが多くの絵文字を連発してもランダム感を出せます。

4. 最後に

以上のように、本記事では「Next.js(App Router) と Liveblocks の組み合わせで、マウスカーソル共有やリアクタイムチャット・絵文字リアクションができるWebアプリ」を実装することができました。
より、深掘りしていきたいです。

Discussion