Astro(View transition) × Nano Stores × vanilla-extractでCSS変数を状態管理する方法
tl;dr
- 
Astro、Nano Stores、vanilla-extractを使って、ページ間でCSS変数を動的に保持する方法を紹介
- ReactやVueなどのフレームワークを使わずに、純粋なAstroによる実装
- ページリロード時に状態を初期化しつつ、ページ遷移では値を保持
- 動的なスタイル変更を @vanilla-extract/dynamicで実現
使用したバージョンについて
- "astro": 5.3.0
- "nanostores": 0.11.3
- "@vanilla-extract/css": 1.17.1
- "@vanilla-extract/dynamic": 2.1.2
作成したもの
今回想定しているものについては以下になります。
- 初期値は設定済み
- Aページにて、セレクトボックスにて項目の選択(API等の通信は一切なし)
- 項目によって、色の変更
- AページからBページに遷移した際に「2」の項目を保持
- ページリロード時に設定がリセットされ、初期値が適用
コード・挙動について
結果のみ見たい人をここのコードを見ていただければと思います。
htmlタグのあるAstroファイル
---
import { ClientRouter } from 'astro:transitions'
---
<html lang="ja">
  <head>
    <!-- ここに何かのHTMLタグは入ります。 -->
    <ClientRouter />
  </head>
  <body>
    <!-- ここに何かのHTMLタグは入ります。 -->
  </body>
</html>
<script>
  import { updateCSSVariables } from '~/store/color'
  const html = document.documentElement
  updateCSSVariables(html)
  document.addEventListener('astro:page-load',  function() {
    updateCSSVariables(html)
  })
</script>
~/store/color.ts
import { setElementVars } from '@vanilla-extract/dynamic'
import { atom } from 'nanostores'
import { colorList, selectedColor } from '~/style/variables/color.css'
// 初期値の設定を行います。
const initialData = 1 // 初期値として1番目の色を設定しています(#6B5C94)
export const data = atom<number>(initialData)
// セレクトUIにて切り替えを行った時に実行される処理です。
export function setData(value: number): void {
  data.set(value)
}
// 状態が変更された際に、CSSの変数を動的に更新します。
export function updateCSSVariables(elem: HTMLElement | null): void {
  if (elem === null) return
  data.subscribe((changedValue) => {
    setElementVars(elem, {
      [selectedColor]: String(colorList[changedValue].main),
    })
  })
}
~/style/variables/color.css.ts
import { createGlobalTheme, createVar } from '@vanilla-extract/css'
// 色の定義を一覧化した変数
export const colorList = createGlobalTheme(':root', {
  1: {
    main: '#6B5C94',
  },
  2: {
    main: '#E37768',
  },
})
// 実際に設定を行うcss変数
export const selectedColor = createVar()
切り替えを行うコンポーネントのAstro
---
import { data } from '~/store/color'
---
<div>
    <select id="valueSelect" transition:persist>
      {[1,2].map((value) => (
        <option value={value.toString()} selected={value === data.get()}>
          {value}
        </option>
      ))}
    </select>
</div>
<script>
  import { setData } from "~/store/color"
  window.addEventListener('DOMContentLoaded', function () {
    const select = document.getElementById('valueSelect')
    select.addEventListener('change', (event: Event) => {
      if(!event.target) return
      const value  = Number((event.target as HTMLSelectElement).value)
      setData(value)
    })
  })
</script>
色の切り替わりを行うコンポーネント
Astroファイルについて
---
import * as style from './index.css'
---
<div class={style.content}>
  コンテンツ
</div>
cssファイル(vanilla-extract)について
import { style } from '@vanilla-extract/css'
import { selectedColor } from '~/style/variables/color.css'
export const content = style({
  background: selectedColor,
})
実際の挙動が見たい場合、以下のURLの一番下にある「年度の色の設定を行う」の場所で実装を行なっています。
手順について
 0. (準備)Astroにvanilla-extract & ViewTransitionを入れる
以下のURLを見ていただければと思うので、ここでの説明は省略します。
変更ログについて
英語版Webサイト

日本語版Webサイト

1. 状態管理の方法を検討する
1-1. 使用するライブラリの検討
今回状態管理を行うにあたり、以下で検討をしました。
結論、nanostoresを利用し理由については以下になります。
- 
transition:persistディレクティブはそのコンポーネント単体の状態を保持しておくものであり、全体の状態管理を行うものではないので除外
- zustand, jotai, nanostoresで比較した時にどれも積極的に開発されているのと、それぞれのダウンロード数を比較すると一番少ないが公式サイトで紹介されているものの使用感を試してみたかった
執筆時点(25/02/21)でのそれぞれのダウンロード数

1-2. Astroに導入をする
公式の導入方法を参考に以下のように記載をすることで状態管理の導入を行うことはできます。
状態管理周りの設定ファイルについて
import { atom } from 'nanostores'
// 初期値の設定を行います。
const initialData = 1
export const data = atom<number>(initialData)
// セレクトUIにて切り替えを行った時に実行される処理です。
export function setData(value: number): void {
  data.set(value)
}
// nanostoresにて設定した「data」が切り替わったのを検知してcssの変数を切り替える処理です。
export function updateCSSVariables(): void {
  data.subscribe((changedValue) => {
    // ここに処理を記載します。
  })
}
共通HTMLでの設定について
---
import { ClientRouter } from 'astro:transitions'
---
<html lang="ja">
  <head>
    <!-- ここに何かのHTMLタグは入ります。 -->
    <ClientRouter />
  </head>
  <body>
    <!-- ここに何かのHTMLタグは入ります。 -->
  </body>
</html>
<script>
  // サーバー側ではなくクライアント側で処理を行いたいので、scriptタグの中でimportしています。
  import { updateCSSVariables } from '~/store/color'
  const html = document.documentElement
  updateCSSVariables(html)
</script>
切り替えを行うコンポーネントでの記載について
---
import { data } from '~/store/color'
---
<div>
    <select id="valueSelect">
      {[1,2].map((value) => (
        <option value={value.toString()} selected={value === data.get()}>
          {value}
        </option>
      ))}
    </select>
</div>
<script>
  import { setData } from "~/store/color"
  window.addEventListener('DOMContentLoaded', function () {
    const select = document.getElementById('valueSelect')
    select.addEventListener('change', (event: Event) => {
      if(!event.target) return
      const value  = Number((event.target as HTMLSelectElement).value)
      setData(value)
    })
  })
</script>
2. vanilla-extractにて設定した値をスタイルとして適応されるようにする
「1. 状態管理の方法を検討する」で設定したものを実際のスタイルに適応されるようにしたいため、設定を行なっていきます。
React.js + Emotionの場合以下のように設定できていたのですが、Astro, vanilla-extract共に以下の課題がありそのまま流用することが難しかったです。
React.js + Emotionでの設定例
コンポーネント側での設定
/** @jsxRuntime classic /
/** @jsx jsx */
import { jsx } from '@emotion/react'
import * as style from './style'
import { useStore } from '~/store/color'
export const H2Title = () => {
  const { data } = useStore()
  return (
    <div css={style.content(data)}>
      コンテンツ
    </div>
  )
}
スタイル側での設定
import { css } from '@emotion/react'
import type { SerializedStyles } from '@emotion/react'
export const content = (data: number): SerializedStyles => css`
  background: ${colorList(data)}
`
- Astroコンポーネントの場合、クライアント上でレンダリングされないためReactの感覚でuseStore()を使うことが難しい。
- vanilla-extractのstyleの場合、Emotionのようなpropsは対応していない。
なので、以下のように解決を行いました。
Astroコンポーネントの場合、クライアント上でレンダリングされないため
useStore()のようなことが難しい
状態管理の場所からデータを取得 → そのままAstroコンポーネントに使用することは難しいが、以下のようにすることで解決。
- htmlタグに状態管理で設定したものを記載
- クライアント側で処理を行いたいため、<script>タグの中に記載
 
- クライアント側で処理を行いたいため、
- htmlタグに記載されたcss変数を元に使用したい場所にスタイルを適応させるようにする
- 何かアクションを起こしたタイミングでcss変数を動的に変更することで「2」で取得したものが適宜変更される
vanilla-extractの
styleの場合、Emotionのようなpropsは対応していない。
動的にスタイルを変更することができる@vanilla-extract/dynamicを使用することで解決。
import { createGlobalTheme, createVar } from '@vanilla-extract/css'
// ここでcss変数の設定
export const selectedColor = createVar()
// 初期値の設定を行います。
const initialData = 1
export const data = atom<number>(initialData)
// セレクトUIにて切り替えを行った時に実行される処理です。
export function setData(value: number): void {
  data.set(value)
}
// nanostoresにて設定した「data」が切り替わったのを検知してcssの変数を切り替える処理です。
export function updateCSSVariables(): void {
  data.subscribe((changedValue) => {
-     // ここに処理を記載します。
+    // 要素の存在チェック
+    if (elem === null) return
+    data.subscribe((changedValue) => {
+      // ここでhtml要素(elem)に対して、css変数(selectedColor)の値を設定します。
+      setElementVars(elem, {
+        [selectedColor]: String(colorList[changedValue].main), 
+      })
+    })
  })
}
最終的に以下のような結果になり、コンポーネントの場所を問わず設定を行った色の設定を行うことができるようになりました。
<html lang="ja" style="--_foobarbazb: var(--_foobarbaz);">
  <!-- 色々な要素 -->
</html>
コンポーネント側の指定の仕方についても以下のようになり、先ほど共有した「React.js + Emotionでの設定例」よりもシンプルになっています。
・Astroファイルについて
---
import * as style from './index.css'
---
<div class={style.content}>
  コンテンツ
</div>
・cssファイル(vanilla-extract)について
import { style } from '@vanilla-extract/css'
import { selectedColor } from '~/style/variables/color.css'
export const content = style({
  background: selectedColor,
})
3. ページ遷移すると色や選択したものが初期化されてしまうため、解消する
ここまででスタイルの適応まではできたのですが、ページ遷移の際にデータが消えてしまうため、対応していきます。
実際に消えてしまう場所について
- 
htmlタグにて設定したはずのcss変数
- セレクトボックスにて選択した内容が初期値になってしまう
3-1. 事象の解決方法についてとりあえずみてみる
事象について色々探していると以下があり、ここで原因でした。
グローバルな状態を設定しているコードがある場合は、そのスクリプトが複数回実行される可能性があることを考慮する必要があります。<script>タグでグローバルな状態をチェックし、可能な限り条件付きでコードを実行してください。
また、その下にあるastro:page-loadを試したところ解決しました!
astro:page-loadイベントはAstroでページが完全に読み込まれた後に発火されるイベントです。
コードの変更箇所については以下になります。
---
import { ClientRouter } from 'astro:transitions'
---
<html lang="ja">
  <head>
    <!-- ここに何かのHTMLタグは入ります。 -->
    <ClientRouter />
  </head>
  <body>
    <!-- ここに何かのHTMLタグは入ります。 -->
  </body>
</html>
<script>
  import { updateCSSVariables } from '~/store/color'
  const html = document.documentElement
  // ここは初回のロード用として記載をしています。
  updateCSSVariables(html)
+  document.addEventListener('astro:page-load',  function() {
+    // ここはページ遷移された時用として記載しています。
+    updateCSSVariables(html)
+  })
</script>
また、セレクトボックスの状態についてもAstroのViewTransitionページにあるtransition:persistを用いることでページ遷移しても状態が保持されるようになりました。
コードの変更箇所については以下になります。
---
import { data } from '~/store/color'
---
<div>
-   <select id="valueSelect">
+   <select id="valueSelect" transition:persist>
      {[1,2].map((value) => (
        <option value={value.toString()} selected={value === data.get()}>
          {value}
        </option>
      ))}
    </select>
</div>
<script>
  import { setData } from "~/store/color"
  window.addEventListener('DOMContentLoaded', function () {
    const select = document.getElementById('valueSelect')
    select.addEventListener('change', (event: Event) => {
      if(!event.target) return
      const value  = Number((event.target as HTMLSelectElement).value)
      setData(value)
    })
  })
</script>
最後に
こちらで作成したコード・挙動について改めてここでも貼り付けるので、何かの参考になれば幸いです!!
htmlタグのあるAstroファイル
---
import { ClientRouter } from 'astro:transitions'
---
<html lang="ja">
  <head>
    <!-- ここに何かのHTMLタグは入ります。 -->
    <ClientRouter />
  </head>
  <body>
    <!-- ここに何かのHTMLタグは入ります。 -->
  </body>
</html>
<script>
  import { updateCSSVariables } from '~/store/color'
  const html = document.documentElement
  updateCSSVariables(html)
  document.addEventListener('astro:page-load',  function() {
    updateCSSVariables(html)
  })
</script>
~/store/color.ts
import { setElementVars } from '@vanilla-extract/dynamic'
import { atom } from 'nanostores'
import { colorList, selectedColor } from '~/style/variables/color.css'
// 初期値の設定を行います。
const initialData = 1
export const data = atom<number>(initialData)
// セレクトUIにて切り替えを行った時に実行される処理です。
export function setData(value: number): void {
  data.set(value)
}
// 状態が変更された際に、CSSの変数を動的に更新します。
export function updateCSSVariables(elem: HTMLElement | null): void {
  if (elem === null) return
  data.subscribe((changedValue) => {
    setElementVars(elem, {
      [selectedColor]: String(colorList[changedValue].main),
    })
  })
}
~/style/variables/color.css.ts
import { createGlobalTheme, createVar } from '@vanilla-extract/css'
// 色の定義を一覧化した変数
export const colorList = createGlobalTheme(':root', {
  1: {
    main: '#6B5C94',
  },
  2: {
    main: '#E37768',
  },
})
// 実際に設定を行うcss変数
export const selectedColor = createVar()
切り替えを行うコンポーネントのAstro
---
import { data } from '~/store/color'
---
<div>
    <select id="valueSelect" transition:persist>
      {[1,2].map((value) => (
        <option value={value.toString()} selected={value === data.get()}>
          {value}
        </option>
      ))}
    </select>
</div>
<script>
  import { setData } from "~/store/color"
  window.addEventListener('DOMContentLoaded', function () {
    const select = document.getElementById('valueSelect')
    select.addEventListener('change', (event: Event) => {
      if(!event.target) return
      const value  = Number((event.target as HTMLSelectElement).value)
      setData(value)
    })
  })
</script>
色の切り替わりを行うコンポーネント
Astroファイルについて
---
import * as style from './index.css'
---
<div class={style.content}>
  コンテンツ
</div>
cssファイル(vanilla-extract)について
import { style } from '@vanilla-extract/css'
import { selectedColor } from '~/style/variables/color.css'
export const content = style({
  background: selectedColor,
})
実際の挙動が見たい場合、以下のURLの一番下にある「年度の色の設定を行う」の場所で実装を行なっています。
参考にした記事など



Discussion