見出し画像

IntersectionObserverAPIを使ってNuxt3でのスクロールアニメーションを攻略する

こちらの記事は、リバイバル記事としてVketマガジンに移行しました。

はじめに

VR法人HIKKYのフロントエンドエンジニアのツバクラです。
業務でスクロールアニメーションを実装する機会があり、Nuxtでの実装知見が貯まったため、今回筆を取る運びとなりました。

デモ

画像1

デモに出てくるキャラクターは筆者の創作で、HIKKYとは無関係です。

IntersectionObserverAPIとは

IntersectionObserverとは、JavaScriptで用意されている要素の交差判定APIです。
今までwindow.offsetを使って実装されていたような、スクロールと連動して実行するアニメーションを楽に実装できます。

今回は、3種類のアニメーション実行のパターン毎に、Nuxt3での実装例をご紹介します。

下準備

viewportに入ってきたことを監視するスクリプトを、useIntersectionObserver.tsとしてcomposablesにまとめます。

// composables/useIntersectionObserver.ts
import { Ref } from 'vue';

const doObserve = ((elements: Ref<HTMLElement | null>[]) => {
    const options = {
        root: null,
        rootMargin: '0px',
        threshold: 0.1,
    }
    // 各componentで配列にまとめてdoObserve()に渡したrefに対して、
    // forEachで回して一つ一つにIntersectionObserverの監視対象にする
    elements.forEach((element, index) => {
        const observer = new IntersectionObserver((items) => {
            // 一つのrefに対してclass付与処理をしていく
            items.forEach((item) => {
                if (item.isIntersecting && item.target.classList.contains('-delay')) {
                    const delay = 300 * index
                    setTimeout(() => {
                        item.target.classList.add('-intersecting')
                    }, delay)
                } else if (item.isIntersecting) {
                    item.target.classList.add('-intersecting')
                } else {
                    item.target.classList.remove('-intersecting')
                }

                if (item.isIntersecting && item.target.classList.contains('-once')) {
                    observer.unobserve(element.value)
                }
            })
        }, options)
        observer.observe(element.value!)
    })
})

export const useIntersectionObserver = () => {
    return {
        doObserve,
    }
}

componentのtemplate内で、アニメーションさせたい要素に固有の名前を持つrefを付与します。componentのscript内で同名のrefを定義すると、DOM要素の情報を取得できます。このrefを配列にまとめ、複数の要素をIntersectionObserverAPIの監視下に置くことが出来るようにします。そしてonMounted()のタイミングでIntersectionObserverによる監視を開始します。

<template>
<div class="o-about">
    <div class="about" ref="targetAbout">
        <h2 class="title">IntersectionObserverってなあに?</h2>
        <p class="description">
            IntersectionObserverとはJavaScriptで用意されている、DOM要素の交差判定APIです。<br />
            今までoffsetを使って実装されていたような、スクロールで実行するアニメーションが楽に実装できます。<br />
            この下から、使いこなしのサンプルを見ていきましょう。
        </p>
    </div>
    <div class="merits">
        <h3 class="title" ref="targetAboutListTitle">
            IntersectionObserverを使うといいことが!
        </h3>
        ...
    </div>
</div>
</template>
<script setup lang="ts">
const targetAbout = ref<HTMLElement | null>(null)
const targetAboutListTitle = ref<HTMLElement | null>(null)

const elements = [
    targetAbout,
    targetAboutListTitle,
]

onMounted(() => {
    useIntersectionObserver().doObserve(elements)
})
</script>

パターン1: 画面に要素が入ったらアニメーションさせたい

useIntersectionObserverの要素ごとのループ内で、element.target.isIntersectingというプロパティから、viewport内に要素があるかどうかの真偽値が取得できます。この値を使い、trueの時にアニメーション用のclassを付与します。(「下準備」useIntersectionObserver.tsの21 - 23行目)

if (item.isIntersecting) {
    item.target.classList.add('-intersecting')
}

アニメーションをCSSで記述します。次のCSS(SCSS)は下からふわっと浮かび上がるアニメーションです。

@keyframes fadeIn {
    from {
        opacity: 0;
        transform: translateY(20px);
    }
    to {
        opacity: 1;
        transform: translateY(0);
    }
}
.o-section {
    &.-intersecting {
        animation: fadeIn 0.5s ease-in-out both;
    }
}

これで要素が画面内に入った時に、アニメーションが実行されます。

パターン2: 画面に複数要素が入ったら、要素ごとに遅延させてアニメーションさせたい

遅延表示用のclassをtemplate内で付与しておき、refを付与した要素から遅延表示のclassが存在するかif文で判定しています。classが存在する場合は、ループしている要素のindexに待たせたい時間をミリ秒単位で掛け、setTimeout()の第二引数に指定します。(「下準備」useIntersectionObserver.tsの16 - 20行目)

<template>
<ul class="merit-list">
    <li class="item -delay" ref="targetAboutListItem1">
        <h4 class="heading">testtext!</h4>
        <p class="description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Animi magni aliquid, impedit consequuntur ratione necessitatibus consequatur quaerat repudiandae autem amet quas non accusamus numquam est aspernatur delectus deserunt iste facilis.</p>
    </li>
    <li class="item -delay" ref="targetAboutListItem2">
        <h4 class="heading">testtext!</h4>
        <p class="description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Animi magni aliquid, impedit consequuntur ratione necessitatibus consequatur quaerat repudiandae autem amet quas non accusamus numquam est aspernatur delectus deserunt iste facilis.</p>
    </li>
    <li class="item -delay" ref="targetAboutListItem3">
        <h4 class="heading">testtext!</h4>
        <p class="description">Lorem ipsum dolor sit amet consectetur adipisicing elit. Animi magni aliquid, impedit consequuntur ratione necessitatibus consequatur quaerat repudiandae autem amet quas non accusamus numquam est aspernatur delectus deserunt iste facilis.</p>
    </li>
</ul>
</template>
// この後scriptでrefの定義をまとめ、IntersectionObserveAPIの監視を開始する
if (item.isIntersecting && item.target.classList.contains('-delay')) {
    const delay = 300 * index
    setTimeout(() => {
        item.target.classList.add('-intersecting')
    }, delay)
}

複数要素が画面に入った時に、要素毎に遅延がかかった状態で.-intersectingのclassが付与され、アニメーションが実行されます。

パターン3: 一度アニメーションしたらアニメーションした後の状態を維持したい

一度だけアニメーションさせたい要素に、refと共に専用のclassを付与します。サンプルでは-onceとしています。

<template>
<div class="o-character1">
    <div class="character">
        <div class="image-container -delay -once" ref="targetCharacterImg1">
            <img src="@/assets/gal-annna.png" class="image" />
        </div>
        ...
    </div>
</div>
</template>
<script setup lang="ts">
import { useIntersectionObserver } from '@/composables/useIntersectionObserver'

const targetCharacterImg1= ref<HTMLElement | null>(null)

const elements = [
    targetCharacterImg1,
]

onMounted(() => {
    useIntersectionObserver().doObserve(elements)
})
</script>

-onceclassの存在をif文の条件で確認し、存在する場合はobserve.unobserve(element.value)でIntersectionObserverAPIによる監視を止めます。refで取得できる値は.valueに格納されています。(「下準備」useIntersectionObserver.tsの27 - 29行目)

if (item.isIntersecting && item.target.classList.contains('-once')) {
    observer.unobserve(element.value)
}

何度でも実行して良いアニメーションと、一度だけ実行したいアニメーションがページ内に混在する場合は、上記のif文だけを他パターンのif文から分けて判定した方が分かりやすいです。

まとめ

スクロールアニメーションで求められる基本的なものは概ね網羅出来ました。今回はNuxt3で実装しましたが、Nuxt2や他のフレームワークでもcomposablesとして切り出した部分と同様の考え方でスクロールアニメーションが使えそうです。

サンプルサイトのソースコードはこちらに置いています。

最後に

HIKKYはメタバースへの魅力的な入り口を作るべく、イベントや自社案件などで多彩なWebサイトを制作しています。華やかなアニメーションをパフォーマンス良く実装することもこれから挑戦していきたい分野としています。HIKKYのWebフロントエンドは、デザインの実装面でも高度なチャレンジが出来る環境です。

少しでも興味が御座いましたら、カジュアルな面談から可能ですので、下記ページよりぜひご応募ください!