🚀

Astro+Nano Storesでprefers-reduced-motionを管理する

に公開

はじめに

こんにちは、もりみちです。
ウェブアクセシビリティの観点から、ユーザーが「モーションを減らす」設定を選択している場合、それに対応することが重要です。

特に視覚的な動きに敏感なユーザーや、健康上の理由でアニメーションを避けたい人々をサポートするために、ブラウザのprefers-reduced-motionメディアクエリを活用できます。
Astroでは、さまざまなUIフレームワーク(React、Vue、Svelte等)のコンポーネントを混在させることができます。

そのような環境でフレームワークに依存しない状態共有が必要になった場合、
Nano Storesを使用することによってが共通の状態を簡潔に管理することができます。

Nano Storesとは

  • 軽量:必要最低限のJS(1KB未満)を提供します。
  • フレームワークに依存しない:どのフレームワーク間でも状態共有がシームレスに行えます。

詳しく知りたい方は、Astro公式ドキュメントとNano Storesのgithubをご確認ください。
https://docs.astro.build/ja/recipes/sharing-state-islands/
https://github.com/nanostores/nanostores

CSSとJavaScriptでの対応の違い

CSSアニメーションではprefers-reduced-motionへの対応は比較的簡単です。
メディアクエリを使用するだけで、ユーザーの設定に基づいてアニメーションを無効化できます。
一方、JavaScriptでアニメーションを制御する場合は、
ブラウザの設定変更を検知する必要があります。

実装例

ここから実装例を解説します。
今回はコンポーネントはAstroとReactを使用しています。
またアニメーションはgsapを使用してます。
※styleの記述は省いてます。

状態管理ストアの作成
prefers-reduced-motion.ts
import { atom, onMount } from 'nanostores';

const prefersReducedMotion = atom<boolean | undefined>(undefined);

onMount(prefersReducedMotion, () => {
  if (typeof window === 'undefined') return;

  const reducedMotionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');

  const updateMotion = () => prefersReducedMotion.set(reducedMotionQuery.matches);
  updateMotion();

  reducedMotionQuery.addEventListener('change', updateMotion);
  return () => reducedMotionQuery.removeEventListener('change', updateMotion);
});

export const prefersReducedMotionStore = {
  isReduced: () => prefersReducedMotion.get() ?? false,
  subscribe: prefersReducedMotion.subscribe,
};

Astroコンポーネントでの使用
AstroButton.astro
<button class="btn">
  <span>ボタン</span>
</button>
<script>
  import { gsap } from 'gsap';
  import { prefersReducedMotionStore } from '@/stores/prefers-reduced-motion';

  class AstroButton {
    button = document.querySelector('.btn');
    isReduced: boolean | undefined = undefined;

    constructor() {
      this.init();
    }

    init = () => {
      this.button?.addEventListener('click', () => {
        this.isReduced = prefersReducedMotionStore.isReduced();

        gsap.to(this.button, {
          width: '50%',
          duration: this.isReduced ? 0 : 0.5,
          ease: 'power2.inOut',
        });
      });
    };
  }
  new AstroButton();
</script>

Reactコンポーネントでの使用
ReactButton.tsx
import { gsap } from "gsap";
import { useRef } from "react";
import { prefersReducedMotionStore } from "@/stores/prefers-reduced-motion";

export const ReactButton = () => {

  const btnRef = useRef(null);

  const handleClick = () => {
    const isReduced = prefersReducedMotionStore.isReduced();

    gsap.to(btnRef.current, {
      width: "50%",
      duration: isReduced ? 0 : 0.5,
      ease: "power2.inOut",
    });
  };

  return (
    <button ref={btnRef} onClick={handleClick}>
      ReactButton
    </button>
  );
};

デモ

※gifで失礼します。

gsap.matchMediaとの比較

GSAPにはmatchMedia()というユーティリティがあり、メディアクエリに基づいたアニメーションの出し分けが可能です。
例えば以下のように記述できます。

const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: reduce)", () => {
  // prefers-reduced-motionがreduceの場合の設定  
});

コードはシンプルになりますが、
現在のprefers-reduced-motionの値を外部コンポーネントで共有することが難しいのと、
フレームワーク間の状態共有ができないです。

まとめ

Astro環境でprefers-reduced-motionを管理するために、Nano Storesを活用する方法を紹介しました。
この方法の主なメリットは:

  1. フレームワークに依存しない状態共有: 異なるUIフレームワーク間でシームレスに状態を共有できる
  2. 軽量で効率的: バンドルサイズへの影響が最小限
  3. アクセシビリティ対応: ユーザーの設定に合わせた体験を提供できる
  4. リアルタイム更新: 設定変更に即座に反応できる

Nano Storesというストア管理の処理が挟む分、初期実装コストは若干増えますが、状態共有をさまざまなフレームワークに伝達できるのがとても便利です。

参考

https://developer.mozilla.org/ja/docs/Web/CSS/@media/prefers-reduced-motion
https://www.w3.org/WAI/WCAG21/Understanding/animation-from-interactions.html
https://gsap.com/docs/v3/GSAP/gsap.matchMedia()#accessible-animations-with-prefers-reduced-motion

Discussion