1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コピペで使える React でアニメーション付きの通知メッセージ

Last updated at Posted at 2025-10-28

React + Tailwind CSS環境で、所謂フラッシュメッセージの実装をしてみました。
ざっと以下のようなイメージです。

                                                                                                             
Flash Message
                                                                                                             

こちら、GIF化するとわかりにくいですが、clickを連打してフラッシュメッセージを複数回呼び出しております。
呼び出しはキューで順次処理されています。

※ Tailwind CSSの3系での作成となります。4系でも動くとは思われますが、責任は負いません!

FlashMessage.tsx

まずはコンポーネント部分になります。
importで読み込んでいるflashMessage.types.tsは後続の項で記載していきます。
FLASH_MESSAGE_TYPEの指定で通知の背景色が、isShownの状態によってアニメーションが切り替わります。

src/components/FlashMessage.tsx
import { FLASH_MESSAGE_TYPE } from '@/types/flashMessage.types'
import { FlashMessageType } from '@/types/flashMessage.types'
import { ReactElement } from 'react'

type FlashMessageProps = {
  isShown: boolean
  title?: string
  message: string
  type: FlashMessageType
  onAnimationEnd?: (e: React.AnimationEvent<HTMLElement>) => void
}

export default function FlashMessage({
  isShown,
  title = 'システムメッセージ',
  message,
  type = FLASH_MESSAGE_TYPE.DEFAULT,
  onAnimationEnd = () => { },
}: FlashMessageProps): ReactElement | null {
  const getBackgroundColor = (type: FlashMessageType) => {
    switch (type) {
      case FLASH_MESSAGE_TYPE.SUCCESS:
        return 'bg-green-500/45'
      case FLASH_MESSAGE_TYPE.ERROR:
        return 'bg-red-500/45'
      case FLASH_MESSAGE_TYPE.WARNING:
        return 'bg-orange-500/45'
      case FLASH_MESSAGE_TYPE.DEFAULT:
        return 'bg-neutral-950/15'
      default:
        break;
    }
  }

  return message ? (
    <div className='fixed justify-center items-center top-2 z-[2147483647] flex w-full pointer-events-none'>
      <section
        className={`px-4 py-2 w-3/4 min-h-16 text-white rounded-3xl border border-white/10 backdrop-blur-md pointer-events-auto ${getBackgroundColor(type)} ${isShown ? 'animate-slidein-down' : 'animate-slideout-top'}`}
        onAnimationEnd={(e) => onAnimationEnd(e)}
      >
        <h1 className='font-extrabold'>{title}</h1>
        <pre className='whitespace-pre-wrap'>{message}</pre>
      </section>
    </div>
  ) : null
}

flashMessage.types.ts

先程importを行っていたFlashMessage.type.tsになります。
as constを使ったオブジェクトリテラルは、実質的な enum(列挙型)として機能します。

src/types/flashMessage.types.ts
export const FLASH_MESSAGE_TYPE = {
  SUCCESS: 'success',
  ERROR: 'error',
  WARNING: 'warning',
  DEFAULT: 'default',
} as const

export type FlashMessageType = (typeof FLASH_MESSAGE_TYPE)[keyof typeof FLASH_MESSAGE_TYPE]

export type FlashMessageData = {
  message: string
  type: FlashMessageType
}

FlashMessageProvider.tsx

こちらがFlashMessageの状態管理を担うProviderコンポーネントになります。
1メッセージ辺りの表示時間は3秒にしてあるので、適宜調整してください。

src/providers/FlashMessageProvider.tsx
'use client'

import FlashMessage from '@/components/FlashMessage'
import { createContext, useState, useContext, useRef, useEffect, useMemo, useCallback } from 'react'
import { FlashMessageData } from '@/types/flashMessage.types'

type FlashMessageProviderType = {
  setFlashMessage: (message: FlashMessageData) => void
}

const defaultProvider: FlashMessageProviderType = {
  setFlashMessage: () => { },
}

const FlashMessageContext = createContext(defaultProvider)
export function useFlashMessageContext() {
  return useContext(FlashMessageContext)
}

export default function FlashMessageProvider({ children }: { children: React.ReactNode }) {
  const [messages, setMessages] = useState<FlashMessageData[]>([])
  const [isShown, setIsShown] = useState(false)

  // 現在表示中のメッセージ
  const displayMessage = messages[0] || { message: '', type: 'default' }

  // タイマーのIDを管理
  const showTimerRef = useRef<NodeJS.Timeout | null>(null)
  const hideTimerRef = useRef<NodeJS.Timeout | null>(null)

  const setFlashMessage = useCallback((data: FlashMessageData) => {
    if (!data || !data.message) return
    setMessages((prev) => [...prev, data])
  }, [])

  const api = useMemo(
    () => ({
      setFlashMessage,
    }),
    [setFlashMessage]
  )

  // onAnimationEnd を useCallback でメモ化
  // isShown の状態によって処理を分岐させるため、依存配列に [isShown] を指定
  const onAnimationEnd = useCallback(() => {
    // 既存のタイマーをクリア
    if (showTimerRef.current) clearTimeout(showTimerRef.current)
    if (hideTimerRef.current) clearTimeout(hideTimerRef.current)

    if (isShown) {
      // (A) 表示アニメーション終了後、3秒待って非表示アニメーションを開始
      showTimerRef.current = setTimeout(() => {
        setIsShown(false)
      }, 3000)
    } else {
      // (B) 非表示アニメーション終了後、500ms待ってからキューの先頭を削除
      hideTimerRef.current = setTimeout(() => {
        setMessages((prev) => prev.slice(1))
        // (stateが更新されると、下のuseEffectがトリガーされる)
      }, 500)
    }
  }, [isShown])

  // メッセージキューを監視するuseEffect (メインループ)
  useEffect(() => {
    // (C) キューに表示すべきメッセージがあり、かつ現在非表示の場合のみ実行
    if (messages.length > 0 && !isShown) {
      // (B)のタイマーが実行中ならキャンセル (すぐに次を表示するため)
      if (hideTimerRef.current) {
        clearTimeout(hideTimerRef.current)
        hideTimerRef.current = null
      }
      // 表示アニメーションを開始
      setIsShown(true)
    }
  }, [messages])

  // アンマウント時のクリーンアップ
  useEffect(() => {
    return () => {
      if (showTimerRef.current) clearTimeout(showTimerRef.current)
      if (hideTimerRef.current) clearTimeout(hideTimerRef.current)
    }
  }, [])

  return (
    <FlashMessageContext.Provider value={api}>
      <FlashMessage
        isShown={isShown}
        message={displayMessage.message}
        type={displayMessage.type}
        onAnimationEnd={onAnimationEnd}
      />
      {children}
    </FlashMessageContext.Provider>
  )
}

tailwind.config.ts

themeの拡張設定(animation, keyframes)をtailwind.config.tsに取り込むことでアニメーションが可能になります。
使うときにはclassNameanimate-slidein-downといった感じで挿入するだけでOKです。
今回使用するのはanimate-slidein-downanimate-slideout-topだけではありますが、汎用的に使えるその他の設定も記載してあります。

tailwind.config.ts
import type { Config } from 'tailwindcss'

const config: Config = {
  ...
  theme: {
    extend: {
      animation: {
        fadein: 'fadein 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        fadeout: 'fadeout 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slidein-down': 'slidein-down 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slidein-right': 'slidein-right 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slidein-left': 'slidein-left 0.2s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slideout-top': 'slideout-top 0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slideout-right': 'slideout-right 0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
        'slideout-left': 'slideout-left 0.4s cubic-bezier(0.250, 0.460, 0.450, 0.940) both',
      },
      keyframes: {
        fadein: {
          '0%': {
            opacity: '0',
          },
          to: {
            opacity: '1',
          },
        },
        fadeout: {
          '0%': {
            opacity: '.4',
          },
          to: {
            opacity: '0',
          },
        },
        'slidein-down': {
          '0%': {
            transform: 'translateY(-80%)',
            opacity: '0',
          },
          to: {
            transform: 'translateY(0)',
            opacity: '1',
          },
        },
        'slidein-right': {
          '0%': {
            transform: 'translateX(-80%)',
            opacity: '0',
          },
          to: {
            transform: 'translateX(0)',
            opacity: '1',
          },
        },
        'slidein-left': {
          '0%': {
            transform: 'translateX(80%)',
            opacity: '0',
          },
          to: {
            transform: 'translateX(0)',
            opacity: '1',
          },
        },
        'slideout-top': {
          '0%': {
            transform: 'translateY(0)',
            opacity: '.4',
          },
          to: {
            transform: 'translateY(-80%)',
            opacity: '0',
          },
        },
        'slideout-right': {
          '0%': {
            transform: 'translateX(0)',
            opacity: '.4',
          },
          to: {
            transform: 'translateX(80%)',
            opacity: '0',
          },
        },
        'slideout-left': {
          '0%': {
            transform: 'translateX(0)',
            opacity: '.4',
          },
          to: {
            transform: 'translateX(-80%)',
            opacity: '0',
          },
        },
      },
    },
  },
  ...
}
export default config

使い方

まず、layout.tsxFlashMessageProviderを登録します。
ルートレイアウトに配置することで、どのページ・コンポーネントからでもsetFlashMessageを呼び出せるようになります。
layout.tsxはページ遷移(ルート変更)が起きてもアンマウント(再描画)されません。ここに <FlashMessage />本体(Providerの内部でレンダリングされる)を配置することで、ページを移動してもフラッシュメッセージが表示され続けるという動きになります。

src/app/layout.tsx
import type { Metadata } from 'next'
import { Inter } from 'next/font/google'
import './globals.css'
import FlashMessageProvider from '@/providers/FlashMessageProvider'

const inter = Inter({ subsets: ['latin'] })

export const metadata: Metadata = {
  title: 'flashメッセージのテスト',
  description: 'flashメッセージのテスト',
}

export default function RootLayout({
  children,
}: Readonly<{
  children: React.ReactNode;
}>) {
  return (
    <html lang='ja' className='bg-gray-100' suppressHydrationWarning>
      <body className={inter.className}>
        <FlashMessageProvider>
          <main className='my-0 mx-auto max-w-[1080px]'>
            {children}
          </main>
        </FlashMessageProvider>
      </body>
    </html>
  )
}

あとはフラッシュメッセージを使いたいページ・コンポーネントから、任意のタイミングでsetFlashMessageを呼び出すだけです。

src/app/page.tsx
'use client'

import Button from '@/components/elements/Button'
import { useFlashMessageContext } from '@/providers/FlashMessageProvider'
import { FLASH_MESSAGE_TYPE } from '@/types/flashMessage.types'

export default function Home() {
  const { setFlashMessage } = useFlashMessageContext()

  return (
    <>
      <h1>HOME</h1>
      {/* クリックされたときにフラッシュメッセージを呼び出す */}
      <Button onClick={() => setFlashMessage({ message: 'click', type: FLASH_MESSAGE_TYPE.SUCCESS })}>Flashメッセージ</Button>
    </>
  )
}

おわり

こういうのって意外と自前で作ると面倒なんですよね。
一回作れば使い回せて、他のコンポーネントを作るときの参考にもなります。
特にアニメーション付きのコンポーネントの状態管理はひと工夫が必要だったので、結構勉強になりました。

1
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?