12-Thumbnail_Image.png

FramerMotionで流れるループ画像を作る / How to Create Infinite Autoplay Carousel (Ticker Carousel)

SaaSのLPにあるような流れる企業ロゴを、FramerMotionとReactでお手軽に流れるループ画像を作ります。既存のCarouselのpackageを魔改造することなく実現できるので、見通しがよく、デザインの改築を簡単に行えます。
公開日2022.07.03
更新日2022.07.03

この記事について

94bce2375dfda82247000489dd875e1c.gif
サービスサイトのLPによくある流れる企業ロゴのループを、Reactで実装します。
FramerMotionと呼ばれるツールで作成します。

Motivation

流れる画像群のコンポーネントは「TickerCarousel」「Infinite Autoplay Carousel」と呼称されるようです。 React製の既存のライブラリを探しましたが、いい感じのものがありませんでした。
Carousel系のpackageを利用しようと試みましたが、実装が想像以上に複雑になってしまうのと、それらpackageが意図するスコープを超えたものになってしまったので、このアプローチは諦めました。
作成WebアプリのアニメーションをFramerMotionを使ってスタイリングしていたこともあり、FramerMotionを使った実装を行うことにしました。

FramerMotionとは

React用のアニメーション・モーションライブラリです。
12_000-02.png
座標・透明度・回転・パス操作のアニメーションのコントロールをはじめ ドラッグアンドドロップ・スクロール操作等のインタラクションのトリガー管理を シンプルな記法で表現することができます。 3Dカメラ操作などもあります。

注釈

また本サンプルコードではスタイリングにChakraUIを利用しています。
ChakraUIを使っていない方は各自脳内でCSSに置換して読解してください。 (ChakraUIは、CSSのパラメータをComponentのパラメータに書き直しているだけなので、脳内変換しやすいとおもいます)
特に影響のないプレーンな方法で記述したかったのですが面倒臭かったのでどうかご容赦ください。

本題

完成品

結論は下記コードです。ScrollImagesと呼ばれるコンポーネントとなっています。
import { Box, Flex } from '@chakra-ui/react'
import { AnimatePresence, motion } from 'framer-motion'
import { useEffect, useRef, useState } from 'react'

type Props = {
  images: React.ReactNode[]
}

const Component: React.FC<Props> = ({ images }) => {
  const ref = useRef<HTMLDivElement>(null)

  // 1つの画像の横の長さ
  const itemWidth = 240
  // 画像間の感覚
  const gap = 24
  // 画像の横幅と、画像間の合計値
  const itemWidthWithGap = itemWidth + gap
  // 画像の数
  const numberOfContents = images.length
  // 横に流れる画像のシーケンス合計
  const [imageBlocks, setImageBlocks] = useState(images)

  useEffect(() => {
    // 「横幅の穴埋め」
    // windowの長さよりコンテンツ数が少ない場合
    // 横幅 < 画像の合計の長さ となるように、画像群(シーケンス)をループさせて配列に加える
    if (
      ref.current?.offsetWidth &&
      imageBlocks.length * itemWidthWithGap < ref.current.offsetWidth
    ) {
      // 全体の長さから何個分 足りていないのか
      const fillableNumberOfContents: number = Math.floor(
        (ref.current.offsetWidth - imageBlocks.length * itemWidthWithGap) /
          numberOfContents
      )

      // シーケンスを追加するのは何個か
      const fillableNumberOfSequence: number = Math.ceil(
        fillableNumberOfContents / numberOfContents
      )

      // シーケンス分 コンテンツを追加
      const newimageBlocks = [...imageBlocks]
      const _ = [...Array(fillableNumberOfSequence)].map((_, index) => {
        newimageBlocks.push(...imageBlocks)
      })

      setImageBlocks(newimageBlocks)
    }
  }, [ref.current]) //DOMがレンダリングされ、横幅が確定した瞬間に実行される

  return (
    <>
      <Box
        alignItems="center"
        w="full"
        position="relative"
        mx={'auto'}
        overflow="hidden"
        ref={ref}
      >
        <AnimatePresence onExitComplete={() => console.log('aaa')}>
          <motion.div
            // アニメーションの変化終了時点の最終移動差分
            animate={{
              x: itemWidthWithGap,
            }}
            // 初期状態〜Animationまでをどう変化させるかを記述
            transition={{
              repeat: Infinity, //ループさせる
              duration: 5, // animationを終えるまでの時間(秒)
              ease: 'linear', // 変化方法。直線的に変化させている。
            }}
            onUpdate={(latest) => {
              if (latest.x >= itemWidthWithGap) {
                //1マス分動いたら発動する処理
                const newimageBlocks = [...imageBlocks]
                newimageBlocks.unshift(imageBlocks[imageBlocks.length - 1]) //冒頭に末尾の画像を追加
                newimageBlocks.pop() //末端の画像を消去する
                setImageBlocks(newimageBlocks) //変更した配列を適応
              }
            }}
          >
            <Flex
              gap={6}
              w={`${itemWidthWithGap * imageBlocks.length}px`}
              ml={`-${itemWidth}px`}
            >
              {imageBlocks.map((block, index) => {
                return (
                  <Box
                    key={index}
                    w={`${itemWidth}px`}
                    h={`${itemWidth}px`}
                    position="relative"
                  >
                    {block}
                  </Box>
                )
              })}
            </Flex>
          </motion.div>
        </AnimatePresence>
      </Box>
    </>
  )
}

export { Component as ScrollImages }

上記コンポーネントを下記のようにして利用します。任意のコンポーネントを入れて使います。
<ScrollImages images={[<img src='hoge01.png' />, <img src='hoge02.png' />,<img src='hoge03.png' />]} />

解説① 実装の考え方

まずどういう考え方で実装しているかを説明します。実装内容は極めてシンプルです。

まず並べる

まずはループする画像を並べます。
12_000-03.png
右に動かしてみましょう。
12_000-04.png
これは想定していない挙動になります。横幅分が足りないので、スクロールしてもいびつな挙動になってしまいます。

「横幅の穴埋め」をします

なので、足りていない分を穴埋めします。横幅に達するまでシーケンスを繰り返します。
12_000-05.png

横移動させます。

この状態で右に動かすことを想像してみましょう。流れるように見えるはずです。
12_000-06.png
ですがこれを流し続けると..
12_000-07.png
左枠が足りなくなってしまい、空欄になります。 これは想定する挙動と異なります。

「移動をループ」するように作ります

そのため、1つめの画像が2つ目の画像の位置に移動し切ったところで もとの場所にもどします。
12_000-08.png
このタイミングで、配列の順番を入れ替えることで、移動して見えるように作ります。 これによりインフィニットなループを実現します。
12_000-09.png

まとめると...

つまり動きとしては下記のような流れを実装し、流れて「見える」ようにします。
12_000-10.png

解説② コード解説

「横幅の穴埋め」に対応したコード

useEffectの下記の箇所が当該コードです。 useRefで読み込んだDOMが確定したタイミングで、実行します。 横幅を計測し、どれくらい穴埋めが必要かを判定し、穴埋めします。
  useEffect(() => {
    // 「横幅の穴埋め」
    // windowの長さよりコンテンツ数が少ない場合
    // 横幅 < 画像の合計の長さ となるように、画像群(シーケンス)をループさせて配列に加える
    if (
      ref.current?.offsetWidth &&
      imageBlocks.length * itemWidthWithGap < ref.current.offsetWidth
    ) {
      // 全体の長さから何個分 足りていないのか
      const fillableNumberOfContents: number = Math.floor(
        (ref.current.offsetWidth - imageBlocks.length * itemWidthWithGap) /
          numberOfContents
      )

      // シーケンスを追加するのは何個か
      const fillableNumberOfSequence: number = Math.ceil(
        fillableNumberOfContents / numberOfContents
      )

      // シーケンス分 コンテンツを追加
      const newimageBlocks = [...imageBlocks]
      const _ = [...Array(fillableNumberOfSequence)].map((_, index) => {
        newimageBlocks.push(...imageBlocks)
      })

      setImageBlocks(newimageBlocks)
    }
  }, [ref.current]) //DOMがレンダリングされ、横幅が確定した瞬間に実行される

「横移動」に対応したコード

横移動は、FramerMotionを利用します。 ここでは「animate」と「transition」という二つのパラメータを利用します。
「animate」はDOMの変化終了時点の状態を記述します。 「transition」はDOMの初期状態(レンダリング時)からanimateの終了時点までどういう変換量で変化させるかを記述します。
移動は下記のように表します。
<motion.div
  // アニメーションの変化終了時点の最終移動差分
  animate={{
    x: itemWidthWithGap,
  }}
  // 初期状態〜Animationまでをどう変化させるかを記述
  transition={{
    repeat: Infinity, //ループさせる
    duration: 5, // animationを終えるまでの時間()
    ease: 'linear', // 変化方法。直線的に変化させている。
  }}
...
>

ここでポイントはtransitionのrepeat:Infinityです。この記述を行うとDOMの初期状態からanimateで記述した変化終了時点のアニメーションを無限ループさせることができます。
これにより、移動のループを実現します。 ただ移動がループするので、このままだと画像群が進んでは戻ってを繰り返しているようなイメージになってしまいます。
なので、移動が完了した時点で、配列を入れ替えて擬似的に動いていることを表現します。

「移動のループ」のコード

framerMotionにはonUpdateというコールバック関数があります。このコールバック関数の引数には、変化量の現時点の値が格納されています。framerMotionが処理を繰り返すたびに、実行されます。
このonUpdateを定期実行animateの変化終了と一致したタイミングを分岐させ、その時に配列を入れ替えるように書き換えます。それが下記です。
<motion.div
  ...
  onUpdate={(latest) => {
    if (latest.x >= itemWidthWithGap) {
      //1マス分動いたら発動する処理
      const newimageBlocks = [...imageBlocks]
      newimageBlocks.unshift(imageBlocks[imageBlocks.length - 1]) //冒頭に末尾の画像を追加
      newimageBlocks.pop() //末端の画像を消去する
      setImageBlocks(newimageBlocks) //変更した配列を適応
    }
  }}
>
これによりループし切ったら完了する、という動作を実現します。

まとめ

以上が実装方法の解説です。
FramerMotionを使うことで割とシンプルに流れるロゴを実装できます。 Carouselのpackageを魔改造することなく、実現できるので、見通しやデザインの改築もラクです。
他、細かいですが今回の解説では、細かなresponsiveに最適化された挙動には対応していません。(あくまで最適化の話なので、エッジケースな動作をしなければ、大きな問題にはならない)
作り込むとなると、配列数等を再計算させるといった挙動も実装が必要です。場合に応じて実装していきましょう。
picture
hanzochang
代表取締役 半澤勇大
慶應義塾大学卒業後、AOI Pro.にてWebプランナーとして勤務。ナショナルクライアントのキャンペーンサイトの企画・演出を担当。その後開発会社に創業メンバーとして参加。Fintech案件や大手企業のDXプロジェクトに関わり、その後個人事業主として独立。2023年にWeb3に特化した開発会社として法人化。

2017年ごろより匿名アカウントでCryptoの調査等を行い、ブロックチェーンメディアやSNSでビットコイン論文等の図解等を発信。
hanzochangはフリーランスのエンジニアです
スキルセットや特徴は下記よりご確認いただけます
fixed
© 2023 hanzochang