はじめに

マンガIP事業本部でWebフロントエンドの開発をしている岸です。
本記事では、当社が運営する縦読み漫画のコンテンツスタジオ「STUDIO ZOON(スタジオズーン)」の公式サイト(https://zoon.jp/studio )の開発にあたって取り組んだ、実装とインタラクション周りのお話をしたいと思います。

プロジェクト背景と技術選定

今回の開発はSTUDIO ZOONのオリジナル縦読み漫画作品の公開にあわせて、元々あった公式サイトのリニューアルという形でおこなったものです。
元々の公式サイトはノーコードツールで作成されたものだったので、リニューアルにあたっては一から開発をおこないました。

サイトの構成は、ほぼ全てのコンテンツを一つのページでスクロールすることで閲覧できるようになっており、規模としてはランディングページに近い小規模なものです。
そのため、フレームワークの導入についての検討はありましたが、

  • コンポーネント駆動な開発やビルドツール、ホットリロードなど基本的な開発体験が欲しい
  •  既存の開発資産と新たに開発する資産を再利用したい
  •  将来的にページの規模が大きくなる可能性が捨てきれないため拡張性が欲しかった

といった理由から、最終的にはAstroとmicroCMSをもちいたJamstack構成とし、
以下のようなアプリケーションの構成で開発を進めました。

スタック

Astro, microCMS, preact, CSS Modules・Clsx, biome, vitest
GitHub Actions, Cloud Storage

シーケンス

(クリックで拡大します)

ユーザーのアクションから始まり、サービス層のAPIを経てAstroフレームワークを介してレイアウトとページが定義され、最終的にコンポーネント層でUIが構築されるWebフロントエンドのシーケンス図

ディレクトリ構成

(クリックで拡大します)
WEBアプリケーションのディレクトリツリーで、components、hooks、UI modules、viewModel、layout、serviceの階層があり、componentsはUIの再利用可能な部品を含み、serviceはAPIとデータフェッチャーのロジックを管理している


CSSによるインタラクション演出の実装

インタラクションの実装にあたっては、Javascriptに極力頼らずCSSを活用するようにしてます。
Scroll-driven Animationsをはじめ、従来では複雑な実装が必要だったものがCSSで表現できるようになってきました。実装が簡易化することで、今後はWEBにおけるインタラクション表現がより一般的になるかもしれません。

CASE1: Scroll-Snap × Scroll-driven Animations

Scroll-driven Animationsのview-timelineプロパティを利用することで、スクロール量に基づいてアニメーションの再生を制御することができます。
※ChromeとEdge以外のブラウザでは未サポートなのでポリフィルが必要になります(本記事執筆時点)

これをカルーセル表現が可能なScroll-Snapプロパティと組みわせることで、よりリッチなインタラクション表現をおこなうことができます。
今回はスクロール量に応じてscaleを変えています。各アイテム切り替え時に奥行きを表現することで抑揚を出し、メリハリのある操作感を演出しました。

 

 

ホームページでは、全体にこの機能を適用することで、スクロールにリズムを生み出し、自然なタイミングでインタラクティブな演出を差し込めるよう意識しています。

 

サイトやその要素の趣旨にもよりますが、基本的にインタラクション演出は付加価値としてあるべきもので、ユーザーを待たせたり動作に対するフィードバックを裏切る形での実装は避けたいところです。

この「自然なタイミング」というのは閲覧体験を阻害しないうえで、とても大切にしたいポイントなので特に注意して実装しました。

.container {
 width: 100%;
 height: 100dvh;
 scroll-snap-type: y mandatory;
 overflow: hidden scroll;
 scrollbar-width: none;
}
.container::-webkit-scrollbar {
 display: none;
}

.section {
 scroll-snap-align: start;
 height: 100%;
}
.inner {
 height: 100%;
 width: 100%;
 overflow: hidden scroll;
 scrollbar-width: none;
}
.inner::-webkit-scrollbar {
 display: none;
}

@keyframes inOutAnime {
 from {
   scale: 0.8;
   pointer-events: none;
 }
 1% {
   scale: 0.8;
 }
 45% {
   scale: 1;
 }
 55% {
   scale: 1;
 }
 99% {
   scale: 0.8;
 }
 to {
   scale: 0.8;
 }
}

@supports (view-timeline-axis: block) {
 .inner {
   view-timeline-name: --in-out-anime;
   view-timeline-axis: block;
   animation: linear inOutAnime both;
   animation-timeline: --in-out-anime;
   animation-range: entry 10% cover 90%;
   pointer-events: all;
 }
}

Tips

CSSにより実装が簡易化されたといったものの、Scroll-Snap × Scroll-driven Animationsの組み合わせは使い方によってはクセのある挙動があり、対応に四苦八苦しました。
せっかくなので以下に列挙します。(※Safari限定の挙動はCSSではなくJS由来)

  1. transform、filterプロパティを組み合わせるとカクツク & ブレる

    • 拡大・縮小させたいなら、transformのscaleではなく、scaleを使うとアニメーションがブレない
    • scroll-snapを効かせたアイテムのhoverに大きさの変化を与えるとブレる(座標が狂う)
    • fillterは負荷が高いので利用を避ける

  2. scaleプロパティを変化させつつ、アイテム内にあるimg要素のLazyloadを有効にするとチラつく

    • 事前に全て読み込んでからカルーセルを表示するか、scaleの変更を諦める

  3. 同じページ内にあるVIdeoタグ要素のpreload属性が有効だとスクロールがブレる(Safari限定)

    • preload属性を無効にする。
    • パフォーマンスを意識するならHls形式などのストリーミング形式の読み込みに対応する。
    • 手軽にやるならyoutubeのembedを埋め込む。

  4. アイテム要素にスクロール要素があると、親要素であるScroll-Snapのスクロールが行えなくなる(iOS safari限定)

    • 子要素のスクロール位置が最上部最下部に達した際に、子要素のスクロール可否をJSで制御して親要素がスクロールするように制御した

 

CASE2: has()

:has()疑似クラスを利用することで、引数に渡したセレクタにマッチする要素が存在する場合にのみスタイルを適用することができます。
:has()疑似クラスは2023年12月Firefoxのリリースをもって、すべてのブラウザにサポートされてます。

ページネーションアイテムの:has()に、アクティブ要素の隣接セレクタを渡すことで、ページネーションの色が切り替わる前後の過程をアニメーションで視覚的に表現することができます。
これにより、動作による状態の変化をユーザーに直感的に把握してもらいやすくなることを狙いました。

.item {
 background-color: #999;
 width: 12px;
 height: 12px;
 border-radius: 20px;
 position: relative;
}


.item.active {
 background-color: #d9d9d9;
 animation: activeAnime 0.4s 1 forwards;
}


@keyframes activeAnime {
 0%, 99% {
   background-color: #999;
 }
 100% {
   background-color: #d9d9d9;
 }
}


.item:has(+ .prevActive)::after,
.item.prevActive:has(+ .active)::after {
 content: "";
 width: 12px;
 height: 12px;
 border-radius: 20px;
 position: absolute;
 z-index: 1;
 top: 0;
 left: 0;
 background-color: #d9d9d9;
}


.item.active:has(+ .prevActive)::after {
 transform: translateX(24px);
 animation: movePaginationUpAnime 0.4s ease 1 forwards;
}


.item.prevActive:has(+ .active)::after {
 transform: translateX(24px);
 animation: movePaginationDownAnime 0.4s ease 1 forwards;
}


@keyframes movePaginationUpAnime {
 from {
   transform: translateX(24px) scale(1.5, 0.75);
 }
 to {
   transform: translateX(0);
 }
}

 

CASE3 :SVG 効果を HTML要素に適用する

最後にSVGです。目新しい技術ではありませんが、個人的には良い発見でしたので挙げさせていただきます。
<feTurbulence> や <feGaussianBlur>などのSVGフィルターのプリミティブ要素をつかって作成したフィルターはid属性を用いることでHTML要素に適応することが可能です。

<style>
   .target {
       filter: url('#verticalBlurFilter');
   }
</style>
<div class="target">ターゲット</div>
<svg width='0' height='0' style={{ display: 'none' }} aria-hidden='true'>
   <defs>
       <filter id='verticalBlurFilter'>
           <feGaussianBlur stdDeviation='0 4' />
       </filter>
   </defs>
</svg>

こちらを活用することで、通常CSSのプロパティだけではできなかった細かなフィルター表現が可能になります。
例えばCSSのfilterプロパティにはblur()が提供されてますが、この関数では縦もしくは横のみblur効果の指定は行えません。

SVGフィルターのプリミティブ要素をつかって自前でフィルターを用意すれば、縦もしくは横のみblur効果の指定が可能になります。
このように作成したフィルターとアニメーションを組み合わせることで、より表現の幅を広げることができます。

縦方向のみのBlurを利用したアニメーション。躍動感がでる Blurを利用しないアニメーション。躍動感に欠ける

 

 

 

今回はfilterプロパティに限った話でしたが、他にもmask, clip-pathも同じくHTML要素に適応することが可能です。
また、blur効果に限らず、色々なフィルター効果を用いるとさらに色々出来そうですし、実際にやってる人もいらっしゃるので気になった人は調べてみると幸せになるかもしれません。

 

アニメーション演出の為に作成したカスタムフック

今回のプロダクトはUIライブラリにpreactを採用しています。
これは今回の開発で新たに実装した処理を別プロダクトでも再利用しやすくする為でした。

アニメーション関連で必要となった処理も例外なくhooks化をおこないました。
使っていて便利だったカスタムフックをいくつか紹介します。


CASE1: MutationObserverを用いた変更検知によるアニメーション制御

テキストや要素の変更を検知して、アニメーション演出のCSSクラスを適用するカスタムフックです。

useEffect内で設定されたMutationObserverが要素のクラス属性の変更を監視し、変更が検出された場合にcheckUpdateAddClass関数を呼び出して、アニメーションクラスの適用が必要かを判断します。

アニメーションが完了すると、クラスは自動的に削除されます。

import { useEffect, useRef } from 'preact/hooks';


interface Props {
 effectClassName: string;
 baseClassName?: string;
}


// 本hooksを除いて、CSSのクラス属性が追加されたか判定する関数
function checkUpdateAddClass(
 oldClassValue: string,
 currentClassValue: string,
 hooksAnimationClassList: string[],
) {
 // ~中略~
 return true;
}


/**
* useEffectiveText - テキストや要素の変更を検知して、アニメーション演出を適用するカスタムフック。
*
* このフックはMutationObserverを使用して対象要素の変更を監視し、変更が検知された際にアニメーションクラスを適用する。
* アニメーションが完了すると、クラスは自動的に削除される。
*
* @param effectClassName - アニメーション演出を適用するクラス名
* @param baseClassName - アニメーションクラスを追加する前に適用するクラス名。オプショナル
* @returns 参照(ref)オブジェクト。これをアニメーションを適用したい要素に割り当てる。
*/
export function useEffectiveText({
 effectClassName,
 baseClassName = '',
}: Props) {
 const targetRef = useRef(null);
 const hooksAnimationClassList = [effectClassName, baseClassName];


 useEffect(() => {
   const target = targetRef.current;
   if (!target) {
     return;
   }


   // 変更を監視するためのMutationObserverを設定
   const observer = new MutationObserver((mutations) => {
     mutations.forEach((mutation) => {
       if (
         mutation.type === 'attributes' &&
         mutation.attributeName === 'class'
       ) {
         const oldClassValue = mutation.oldValue || '';
         const currentClassValue = target.getAttribute('class') || '';


         const isUpdateClass = checkUpdateAddClass(
           oldClassValue,
           currentClassValue,
           hooksAnimationClassList,
         );
         if (!isUpdateClass) {
           return;
         }


         // アニメーションクラスを追加する処理 ~中略~
       }
     });
   });


   // 監視の設定: 子要素とテキストの変更を監視
   observer.observe(target, {
       attributes: true, // 要素の属性の変更を監視する
       attributeOldValue: true, // 属性が変更された場合、変更前の値も提供する
       childList: true, // 要素の子リスト(子ノード)の変更を監視する
       subtree: true, // 対象要素のすべての子孫ノードの変更も監視する
       characterData: true, // 要素のテキスト内容(文字データ)の変更を監視する
   });


   return () => {
     observer.disconnect();
   };
 }, [effectClassName, baseClassName]);


 return targetRef;
}

CASE2 : テキストを一文字ずつアニメーションさせるカスタムフック

テキストを構成する各文字を個別の<span>要素にラップし、それらの要素がビューポート内に入ると、任意のアニメーションクラスを付与して、演出が開始されるようにするカスタムフックです。

import { useEffect, useRef } from 'preact/hooks';


/**
* 文字列の各文字を``要素にラップし、アニメーション遅延を適用します。
*/
export function createSpanWithDelay(
 text: string,
 defaultClass: string,
 delay: number,
) {
 if (text === '\n') {
   return document.createElement('br');
 }
 const span = document.createElement('span');
 span.textContent = text;
 span.classList.add(defaultClass);
 span.style.animationDelay = `${delay}s`;
 span.style.display = 'inline-block';
 return span;
}


/**
* 指定した要素にIntersectionObserverを追加して、ビューポート内での表示状態に基づいてアニメーションを制御します。
*/
export function addAnimationObserver(
 target: HTMLElement,
 observerCallback: (spanElements: NodeListOf) => void,
 unObserverCallback: (spanElements: NodeListOf) => void,
) {
 const observer = new IntersectionObserver(
   // ~中略~
 );
 observer.observe(target);
 return observer;
}


/**
* テキストを一文字ずつアニメーションさせるカスタムフック。
* 各文字は``要素にラップされ、ビューポートに表示されるとアニメーションが開始されます。
*
* @param {string} defaultClass - アニメーション適用前のデフォルトクラス名。
* @param {string} animationClass - アニメーション適用時のクラス名。
* @returns {React.MutableRefObject} アニメーションを適用する要素への参照。
*/
export function useTextFadeInAnimation(
 defaultClass: string,
 animationClass: string,
) {
 const targetRef = useRef(null);


 useEffect(() => {
   if (!targetRef.current) {
     console.error('[useTextFadeInAnimation] Invalid arguments');
     return;
   }


   const target = targetRef.current;
   const text = target.textContent;
   const textArray = text.trim().split('');


   target.textContent = '';


   for (const [index, char] of textArray.entries()) {
     const spanWithDelay = createSpanWithDelay(
       char,
       defaultClass,
       index * 0.05,
     );
     target.appendChild(spanWithDelay);
   }


   const observer = addAnimationObserver(
     target,
     (spanElements) => {
       for (const span of spanElements) {
         span.classList.add(animationClass);
       }
     },
     (spanElements) => {
       for (const span of spanElements) {
         span.classList.remove(animationClass);
       }
     },
   );


   return () => {
     observer.disconnect();
     target.innerHTML = text;
   };
 }, [animationClass, defaultClass]);


 return targetRef;
}

 

CASE3 : apngの再生制御をおこなうカスタムフック

APNGは半透過色も扱えるアニメーション画像なので、任意の要素に被せる形で演出に利用できます。
しかし、画像なので、そのままインタラクション演出に利用すると画像が再読み込みされたり、キャッシュで先頭からアニメーションの再生が行えないなどの扱いづらさがあります。

そこで、APNGを解析してcanvasとして再生制御をおこなうことができる「apng-js」というライブラリを用いました。
このカスタムフックはAPNGの読み込みに加えて、再生、停止機能を提供し、APNGの準備状態を管理します。
これによりAPINGを柔軟に演出に用いることが可能になりました。

import parseAPNG from 'apng-js';
import type Player from 'apng-js/types/library/player';
import { useEffect, useRef, useState } from 'preact/hooks';


/**
* useApngAnime
* APNGアニメーションの再生を制御するためのカスタムフック
*
* @param src - APNGファイルのソースパス。
* @param className - APNGアニメーションを表示するためのcanvas要素に付与するクラス名。
* @returns Object
*       ref - APNGアニメーションを表示するためのcanvas要素のRef
*       play - APNGアニメーションを再生する関数
*       stop - APNGアニメーションを停止する関数
*       isLoaded - APNGアニメーションが準備できたかどうかの判定結果
*/
export const useApngAnime = (
   src: string,
   className?: string,
): {
   ref: preact.RefObject;
   play: () => void;
   stop: () => void;
   isLoaded: boolean;
} => {
   const ref = useRef(null);
   const [isLoaded, setIsLoaded] = useState(false);
   const [animationPlayer, setAnimationPlayer] = useState(null); // animationPlayerをステートとして管理


   useEffect(() => {
       const loadAndPlayApng = async () => {
           if (ref.current && src) {
               try {
                   const response = await fetch(src);
                   const buffer = await response.arrayBuffer();
                   const apng = await parseAPNG(buffer);
                   const canvas = document.createElement('canvas');
                   if (className) {
                       canvas.classList.add(className);
                   }
                   const context = canvas.getContext('2d', { willReadFrequently: true });
                   ref.current.appendChild(canvas);


                   if (!(apng instanceof Error) && apng.frames.length > 0 && context) {
                       canvas.width = apng.width;
                       canvas.height = apng.height;
                       const player = await apng.getPlayer(context);
                       setAnimationPlayer(player);
                       setIsLoaded(true);
                   }
               } catch (error) {
                   console.error('APNGのロード中にエラーが発生しました:', error);
               }
           }
       };


       loadAndPlayApng();


       return () => {
           if (animationPlayer) {
               animationPlayer.stop();
               setAnimationPlayer(null);
           }
       };
   }, [src]);


   const play = () => {
       if (!isLoaded || !animationPlayer) {
           console.warn('APNGアニメーションが準備できていません。');
           return;
       }
       animationPlayer.play();
   };


   const stop = () => {
       if (animationPlayer) {
           animationPlayer.stop();
       }
   };


   return { ref, play, stop, isLoaded };
};

 

おわりに

今回はインタラクション演出の実装を中心にお話させていただきました。
WEBサイトの開発において、インタラクション演出はユーザー体験を大きく左右する要素の一つです。
サイトの魅力を引き立てる一方で時には体験を悪化させる可能性もあります。

昔から時折、その必要性について議論されることがありますが、
昨今ではView-transitionやScroll-drivenといったWEB APIの進化により、インタラクションの実装が以前よりも手軽になってきてます。

さらに、Core Web Vitals(CWV)においては、2024年3月からインタラクションに関するパフォーマンス指標としてFIDの代わりにINPを採用することが決定されております。
INP を Core Web Vitals に導入  |  Google 検索セントラル ブログ

これらの進展を踏まえると、インタラクション演出を通じたサービスの差別化が今後さらに重要になってくると私は考えています。


最後にインタラクションを実装する心構えとして、とても参考になった記事を紹介させてください。

Webサイトにアニメーションは必要あるのか。ないのか。どっちなのか。|Takumi HASEGAWA (unshift Inc.)
https://note.com/unshift/n/ne474ac3e9092

この演出は「なんの役割がある」のか。「ない方が良い」のか。「なくても問題ない」のか。

自分自身も含め、ここまでの考慮を開発者が当たり前に実装できると、アニメーション演出も抵抗なく受け入れられるケースが増え、より市民権を得られていくのかもしれません。

以上です。
最後までお読みいただきありがとうございました。
この記事が誰かのお役に立てれば幸いです。