カチカチするたびランダムに回転する僕の作り方 with Astro

8.5K Views

June 30, 23

スライド概要

2023年06月30日(金)に Cybozu Frontend Day という社内イベントで発表した資料です。

アイコンが動くバージョン: https://rotate-my-icon.vercel.app
english version: https://www.docswell.com/s/korosuke613/58GMW2-2023-06-30-rotate-my-icon-en

# 内容

https://korosuke613.dev/ のトップページの僕の画像をクリックすると、ランダム(X軸、Y軸、Z軸、X軸 & Y軸、X軸 & Z軸、Y軸 & Z軸)に回転します。
それを React を使った Astro (MPA) で行う方法を伝授します。

僕はフロントエンドよわよわエンジニアなので、発表時は用語等が間違ってる恐れがあります。ご了承ください。

[keywords]
CSS, JavaScript, React, Astro, Browser

profile-image

サイボウズ株式会社 開発本部 生産性向上チームで働いています。

シェア

またはPlayer版

埋め込む »CMSなどでJSが使えない場合

関連スライド

各ページのテキスト
1.

カチカチするたびランダムに 回転する僕の作り方 with Astro 金 2023/06/30 ( ) Cybozu Frontend Day 平木場 風太 <Futa Hirakoba>

2.

平木場 風太 - Futa Hirakoba 🌋 出身 - 鹿児島 🏢 勤め先 - サイボウズ株式会社 / 開発本部 / 生産性向上チーム 🧑‍💻 役割 - Engineering Productivity 🍣 好きな食べ物 - チキン南蛮、辛麺 💪 AWS や CI/CD ツール、Terraform などを触ることが多い 💀 フロントエンドはなんにもわからない [1] @korosuke613 @shitimi_613 1. 写真は桝元の辛麺(トマト 5 辛中華麺チーズトッピング)。毎日食べたい。 [ 2 / 46 ]

3.

https://korosuke613.dev [ 3 / 46 ]

5.

https://korosuke613.dev [ 5 / 46 ]

6.

Click! https://korosuke613.dev [ 5 / 46 ]

7.

Rotation! https://korosuke613.dev [ 6 / 46 ]

8.

Rotation! https://korosuke613.dev [ 7 / 46 ]

9.

僕が回転します 🙃 [ 8 / 46 ]

10.

さらに...? [ 9 / 46 ]

12.

回転する方向はランダムです 😵‍💫 [ 11 / 46 ]

13.

クリックするたびに変わります [ 12 / 46 ]

14.

https://www.geogebra.org/3d/czwmzrfr [ 13 / 46 ]

15.

X X 軸 軸&Y軸 Y X 軸 軸&Z軸 Z Y 軸 軸&Z軸 [ 14 / 46 ]

16.

korosuke613.dev 平木場のホームページ 自分のアウトプットや SNS へのリンクなどの情報をまとめる場所として作った Astro + React で構成 (二代目) (余談) 初代は Nuxt + Vue で構成していた (余談) Nuxt3 移行がつらすぎて新しめのフレームワーク + 業務でも使っている React で作り直すことに Astro とは は、コンテンツにフォーカスした高速なWebサイトを構築するためのオールインワンWebフレームワークです。 https://docs.astro.build/ja/getting-started/ より Astro を採用 極力クライアントで JS の実行を減らすように作られているため高速 UI フレームワークとして React を利用できる MPA (Multi Page Application) [ 15 / 46 ]

17.

ホームページできたけどつまらんな 😑 [ 16 / 46 ]

18.

そうだ。自分を回転させよう 🤩 [ 17 / 46 ]

19.

どうやって回転させるか でがんばる ついでに拡大縮小もさせる(`scale`) 無限に回転してもうざったいので、1 回のみ回転させる 下記の CSS クラスをアイコンの要素に付与する CSS 例:Z 軸を中心に回転するアニメーションの CSS .rotate-animation-z { animation: rotate-anime-z 1.5s linear 1 /* } アニメーション名 時間 曲線 繰り返し回数 */ 軸を中心に回転するアニメーションの定義 から までの間をそれぞれ指定 は回転角度、 は拡大縮小 /* Z */ /* 0% 100% */ /* rotate scale */ @keyframes rotate-anime-z { 0% {transform: rotate(0deg) scale(1);} 25% {transform: rotate(90deg) scale(1.5);} 50% {transform: rotate(180deg) scale(1);} 75% {transform: rotate(270deg) scale(1.5);} 100% {transform: rotate(360deg) scale(1);} } [ 18 / 46 ]

20.

クリックしたときに回転させたい このままだとページがロードされた時に 1 回転して終わる いきなり回られる(しかも 1 回転して止まる)と見てる方が意味わからない クリックした時に回転させる クリック時(`click`)に CSS クラスを付与、アニメーション終了時(`animationend`、`animationcancel`) に CSS クラスを削除する CSS クラスを削除しないと、最初のクリックでしか回転してくれない React の `useEffect` を使って、イベントリスナーをロード時に登録する [ 19 / 46 ]

21.
[beta]
クリックしたときに回転させたい
ブラウザ

ユーザ

僕のアイコン

ページのロード
イベントハンドラの登録

loop

クリック時にアニメーションのクラスを付与する

//
thisImg.addEventListener('click', () => {
thisImg.classList.add(cssClassName);
});

僕のアイコンをクリック
(click event)

アニメーションのクラスを付与
(handle click event)

回転
アニメーションの終了
(animationend event)

アニメーションのクラスを削除
(handle animationend event)

ユーザ

const cssClassName = 'rotate-animation-z';
export const MyIcon = (props: IMyIconProps) => {
useEffect(() => {
const thisImg = document.getElementById(props.iconId);
if (thisImg === null) return;

ブラウザ

僕のアイコン

アニメーションが終わったらアニメーションのクラスを削除する

//
const removeClass = () => {
thisImg.classList.remove(cssClassName);
};
thisImg.addEventListener('animationend', removeClass);
thisImg.addEventListener('animationcancel', removeClass);
});
return (<img /*
*/ />);
};

省略

[ 20 / 46 ]

22.

回転した! [ 21 / 46 ]

23.

なんか足りないよな〜 😕 [ 22 / 46 ]

24.

回転方向を毎回ランダムにしよう 🤣 [ 23 / 46 ]

25.
[beta]
クリックするたびに回転する方向を変えたい
今のところ Z 軸に 1 回転する動き
しかない
X 軸、Y 軸、Z 軸、XZ 軸、XY 軸、
YZ 軸をランダムに回転させる
アニメーション削除時はどのアニ
メーションクラスが付与されている
かわからないので、全部削除する

const cssClassNames = [
'rotate-animation-x',
'rotate-animation-y',
'rotate-animation-z',
'rotate-animation-xy',
'rotate-animation-xz',
'rotate-animation-yz',
];

export const MyIcon = (props: IMyIconProps) => {
useEffect(() => {
const thisImg = document.getElementById(props.iconId);
if (thisImg === null) return;

クリック時にランダムにアニメーションのクラスを付与する

//
thisImg.addEventListener('click', () => {
const randNum = Math.floor(Math.random() * cssClassNames.length);
const cssClassName = cssClassNames[randNum] || 'rotate-animation-z';
thisImg.classList.add(cssClassName);
});

アニメーションが終わったらアニメーションのクラスを削除する

//
const removeClass = () => {
cssClassNames.forEach((cssClassName) => {
//
thisImg.classList.remove(cssClassName);
});
};
thisImg.addEventListener('animationend', removeClass);
thisImg.addEventListener('animationcancel', removeClass);
});
return (<img /*
*/ />);
};

どのクラスが付与されているかわからないので全部削除する

省略

[ 24 / 46 ]

26.

X 軸 Y 軸 Z 軸 回転した! X 軸&Y軸 X 軸&Z軸 Y 軸&Z軸 [ 25 / 46 ]

27.

デスクトップ スマートフォン 実はこのホームページ、レスポンシブ対応している [ 26 / 46 ]

28.
[beta]
2

つのアイコンの表示を画面幅で切り替えている

デスクトップとスマホでアイコンの位置が違うため

<Section>
<div className="flex flex-col items-center md:flex-row md:justify-between md:gap-x-24">
<div>
<h1 className="hidden text-3xl font-bold md:block"> {/*
*/} </h1>
<div className="flex flex-row justify-between md:hidden md:gap-x-24">
{/*
*/}
<h1 className="text-3xl font-bold"> {/*
*/} </h1>
<div className="h-20 w-20" id="my-icon-small">
<MyIcon /> {/*
*/}
</div>
</div>
<p className="mt-6 text-xl leading-9"> {/*
*/} </p>
<div className="mt-3 flex flex-wrap gap-1"> {/* SNS
*/} </div>
</div>
<div className="hidden shrink-0 md:block">
<div className="h-72 w-72" id="my-icon-large">
<MyIcon /> {/*
*/}
</div>
</div>
</div>
</Section>

あいさつ

スマホ表示用

あいさつ

小さいアイコン

自己紹介

リンク

大きいアイコン

[ 27 / 46 ]

29.
[beta]
2

つのアイコンの表示を画面幅で切り替えている

デスクトップとスマホでアイコンの位置が違うため

<Section>
<div className="flex flex-col items-center md:flex-row md:justify-between md:gap-x-24">
<div>
<h1 className="hidden text-3xl font-bold md:block"> {/*
*/} </h1>
<div className="flex flex-row justify-between md:hidden md:gap-x-24">
{/*
*/}
<h1 className="text-3xl font-bold"> {/*
*/} </h1>
<div className="h-20 w-20" id="my-icon-small">
<MyIcon /> {/*
*/}
</div>
</div>
<p className="mt-6 text-xl leading-9"> {/*
*/} </p>
<div className="mt-3 flex flex-wrap gap-1"> {/* SNS
*/} </div>
</div>
<div className="hidden shrink-0 md:block">
<div className="h-72 w-72" id="my-icon-large">
<MyIcon /> {/*
*/}
</div>
</div>
</div>
</Section>

あいさつ

スマホ表示用

あいさつ

小さいアイコン

自己紹介

リンク

大きいアイコン

[ 27 / 46 ]

30.
[beta]
それぞれのアイコンにアニメーションをつける
ではなくクラス名でアイコンの要
素を取得するようにする
ハンドラーの付与をそれぞれの要素
に行う
Id

<div className="h-20 w-20 my-icon">
<MyIcon /> {/*
*/}
</div>

小さいアイコン

<div className="h-72 w-72 my-icon">
<MyIcon /> {/*
*/}
</div>

大きいアイコン

export const MyIcon = (props: IMyIconProps) => {
useEffect(() => {
const icons = document.getElementsByClassName(props.iconClass);
Array.from(icons).forEach((icon) => {
//
icon.addEventListener('click', () => {
const randNum = Math.floor(Math.random() * cssClassNames.length);
const pickedCssClassName =
cssClassNames[randNum] || 'rotate-animation-z';
icon.classList.add(pickedCssClassName);
});

クリック時にランダムにアニメーションのクラスを付与する

アニメーションが終わったらアニメーションのクラスを削除する

//
const removeClass = () => {
cssClassNames.forEach((cssClassName) => {
//
icon.classList.remove(cssClassName);
});
};
icon.addEventListener('animationend', removeClass);
icon.addEventListener('animationcancel', removeClass);
});
});

どのクラスが付与されているかわからないので全部削除する

[ 28 / 46 ]

31.

ひとつの要素に複数のアニメーションクラスが付与されてしまう。 そのため、出てくるアニメーションの確率が均一にならない。 [ 29 / 46 ]

32.

ページのロード 大きいアイコンの`useEffect` ランダムな アニメーションクラスの付与 小さいアイコンの`useEffect` ランダムな ランダムな アニメーションクラスの付与 アニメーションクラスの付与 ランダムな アニメーションクラスの付与 大きいアイコンの要素 小さいアイコンの要素 例: 'rotate-animation-z', 'rotate-animation-xy' 例: 'rotate-animation-x', 'rotate-animation-y' コンポーネントそれぞれが、 を持つ要素に対して、 アニメーションクラスをランダムに付与してしまうため。 `MyIcon` `.my-icon` [ 30 / 46 ]

33.

コンポーネントで 1 要素だけ扱えば良いのでは 🧐 → 簡単にはいかない理由がある... 1 [ 31 / 46 ]

34.
[beta]
現在のコンポーネントの呼び出し方
次のような構造で `MyIcon` を呼び出している
Astro (`index.astro`)
├── TSX (`SelfIntroduction.tsx`)
└── TSX (`MyIcon.tsx`)

は Astro から呼び出し、`SelfIntroduction` コンポーネントへ小要素として渡している
`SelfIntroduction.tsx` から `MyIcon.tsx` を呼び出していない理由は Astro の特性に関係する
`MyIcon`

<SelfIntroduction>
<MyIcon
client:idle
iconPath={/*
iconClass="my-icon"
/>
</SelfIntroduction>

アイコン画像への PATH */}

[ 32 / 46 ]

35.
[beta]
はクライアント上での を極力減らす思想

Astro
JS
Astro はサーバサイドでのレンダリングを可能な限り行うフレームワークであるため、クライアント上での
JS の実行を極力減らすように作られている
したがって、クライアント上で JS を実行するようにするには、`client:*` ディレクティブを宣言しないと
いけない
`client:idle`:ページの初期ロードが完了し、`requestIdleCallback` イベントが発生したら、コンポ
ーネントの JavaScript をロードしてハイドレートする
[1]

[2]

例(`index.astro`)
<SelfIntroduction>
<MyIcon
client:idle {/*
iconPath={/*
iconClass="my-icon"
/>
</SelfIntroduction>

これがないと動かない */}
アイコン画像への PATH */}

1. https://docs.astro.build/en/concepts/why-astro/#server-first
2. https://docs.astro.build/en/reference/directives-reference/#clientidle

[ 33 / 46 ]

36.

MyIcon に client:* を適用しないといけない はゴリゴリ JS を使っているため、`client:*` の設定が必要 `client:*` は `*.astro` のみから呼び出せる `client:*` の効果は付与したコンポーネントの子コンポーネントに及ぶ `MyIcon` 方法 1. `SelfIntroduction` ごと `client:idle` 付与 Astro (`index.astro`) └── TSX (`SelfIntroduction.tsx`) `client:idle` └── 2. `MyIcon` TSX (`MyIcon.tsx`) 付与 のみに `client:idle` を付与(現在の方法) Astro (`index.astro`) ├── TSX (`SelfIntroduction.tsx`) └── TSX (`MyIcon.tsx`) `client:idle` 付与。`SelfIntroduction` に渡す [ 34 / 46 ]

37.

自己紹介セクションが一瞬出てくるがすぐ消えてしまう... 🫨 「1. `SelfIntroduction` ごと `client:idle` 付与」 の方法はなんかうまくいかなかった。 (原因を特定するに至らず...) [ 35 / 46 ]

38.

ここまでの整理 現在、`MyIcon` を Astro から呼び出し、`SelfIntroduction` に渡している この方法ではアニメーションの確率が均一にならないので 1 コンポーネント 1 要素にしたい `SelfIntroduction` から `MyIcon` を呼び出すことで 1 コンポーネント 1 要素に簡単にできる が、この方法だと描画がおかしくなる どうするか [ 36 / 46 ]

39.

ここまでの整理 現在、`MyIcon` を Astro から呼び出し、`SelfIntroduction` に渡している この方法ではアニメーションの確率が均一にならないので 1 コンポーネント 1 要素にしたい `SelfIntroduction` から `MyIcon` を呼び出すことで 1 コンポーネント 1 要素に簡単にできる が、この方法だと描画がおかしくなる どうするか の `useState` を使って複数の `MyIcon` が複数の要素を扱ってもなんとかなるようにする `MyIcon` 呼び出し含む `SelfIntroduction.tsx` を `.astro` ファイル化 React [ 36 / 46 ]

40.

の useState を使って複数の MyIcon が複 数の要素を扱ってもなんとかなるようにする React 以前はこの方法を使っていた。資料を作ってるうちにやめた `useState` と `useRef` で大きいアイコンと小さいアイコンそれぞれの現在のアニメーションを状態とし て管理 大小アイコンそれぞれにアニメーションクラスが 1 つのみ追加されるようになった 結構複雑 (当時はなんか色々試してる内にできてしまったが、どうして `useRef` が必要だったかが思い出せない) [ 37 / 46 ]

41.
[beta]
`MyIcon.tsx`
export const MyIcon = (props: IMyIconProps) => {
const [largeMode, setLargeMode] = useState('rotate-animation-z');
const largeModeRef = useRef<string>(null!);
largeModeRef.current = largeMode;
const [smallMode, setSmallMode] = useState('rotate-animation-z');
const smallModeRef = useRef<string>(null!);
smallModeRef.current = smallMode;
useEffect(makeEffect('my-icon-large', setLargeMode, largeModeRef));
useEffect(makeEffect('my-icon-small', setSmallMode, smallModeRef));
return (
<img
src={props.iconPath}
style={{ width: '100%' }}
alt="Avatar image"
loading="lazy"
/>
);
};

供養 🪦

[ 38 / 46 ]

42.
[beta]
`MyIcon.tsx`
const makeEffect = (
id: string,
setMode: React.Dispatch<React.SetStateAction<string>>,
modeRef: React.MutableRefObject<string>
) => {
return () => {
const thisImg = document.getElementById(id);
if (thisImg === null) return () => {};
const start = () => {
cssNames.forEach((c) => {
thisImg.classList.remove(c);
});
const randNum = Math.floor(Math.random() * cssNames.
const cssName = cssNames[randNum] || 'rotate-animati
setMode(cssName);
thisImg.classList.add(modeRef.current);
};

thisImg.addEventListener('click', start);
thisImg.addEventListener('animationend', end);
thisImg.addEventListener('animationcancel', end);
return () => {
thisImg.removeEventListener('click', start);
thisImg.removeEventListener('animationend', end);
thisImg.removeEventListener('animationcancel', end);
};
};
};

供養 🪦

const end = () => {
cssNames.forEach((cssName) => {
thisImg.classList.remove(cssName);
});
};
[ 39 / 46 ]

43.

MyIcon .astro 呼び出し含む ファイル化 SelfIntroduction.tsx を 資料作ってるうちにこっちの方がシンプルで説明しやすいことに気づいて採用 `MyIcon` を 1 コンポーネント 1 要素にするよう修正 Id を外から渡す形式に変更(大きいアイコンの見回していた初期の内容) `SelfIntroduction.tsx` を `.astro` の形式に書き換える `index.astro` から `SelfIntroduction.astro` を呼び出す 新たにできた `SelfIntroduction.astro` から `MyIcon` を呼び出し、`MyIcon` に `client:idle` を付 与する [ 40 / 46 ]

44.
[beta]
`SelfIntroduction.astro`
--// <
:
>
import { MyIcon } from '@/components/MyIconRandomLargeOnly';
const iconPath = /*
*/;
// <
:
>
--<Section>
<div className="flex flex-col items-center md:flex-row md:justify-between md:gap-x-24">
<div>
<h1 className="hidden text-3xl font-bold md:block"> {/*
*/} </h1>
<div className="flex flex-row justify-between md:hidden md:gap-x-24">
{/*
*/}
<h1 className="text-3xl font-bold"> {/*
*/} </h1>
<div class="my-icon h-20 w-20" id="my-icon-small">
<MyIcon client:idle iconPath={iconPath} iconId="my-icon-small" /> {/*
*/}
</div>
</div>
<p className="mt-6 text-xl leading-9"> {/*
*/} </p>
<div className="mt-3 flex flex-wrap gap-1"> {/* SNS
*/} </div>
</div>
<div className="hidden shrink-0 md:block">
<div class="my-icon h-72 w-72" id="my-icon-large">
<MyIcon client:idle iconPath={iconPath} iconId="my-icon-large" /> {/*
*/}
</div>
</div>
</div>
</Section>

省略 色々なインポート
アイコンのパス
省略 その他処理

あいさつ

スマホ表示用

あいさつ

小さいアイコン

自己紹介

リンク

大きいアイコン

[ 41 / 46 ]

45.
[beta]
`SelfIntroduction.astro`
--// <
:
>
import { MyIcon } from '@/components/MyIconRandomLargeOnly';
const iconPath = /*
*/;
// <
:
>
--<Section>
<div className="flex flex-col items-center md:flex-row md:justify-between md:gap-x-24">
<div>
<h1 className="hidden text-3xl font-bold md:block"> {/*
*/} </h1>
<div className="flex flex-row justify-between md:hidden md:gap-x-24">
{/*
*/}
<h1 className="text-3xl font-bold"> {/*
*/} </h1>
<div class="my-icon h-20 w-20" id="my-icon-small">
<MyIcon client:idle iconPath={iconPath} iconId="my-icon-small" /> {/*
*/}
</div>
</div>
<p className="mt-6 text-xl leading-9"> {/*
*/} </p>
<div className="mt-3 flex flex-wrap gap-1"> {/* SNS
*/} </div>
</div>
<div className="hidden shrink-0 md:block">
<div class="my-icon h-72 w-72" id="my-icon-large">
<MyIcon client:idle iconPath={iconPath} iconId="my-icon-large" /> {/*
*/}
</div>
</div>
</div>
</Section>

省略 色々なインポート
アイコンのパス
省略 その他処理

あいさつ

スマホ表示用

あいさつ

小さいアイコン

自己紹介

リンク

大きいアイコン

[ 41 / 46 ]

46.
[beta]
index.astro
--// <
:
>
import SelfIntroduction from '@/components/SelfIntroduction.astro';

省略 色々なインポート
省略 その他処理

// <
:
>
--...
<SelfIntroduction />
...

[ 42 / 46 ]

47.

1 つのアイコンに複数のアニメーションクラスが付与されなくなった 🥳 [ 43 / 46 ]

48.

.astro ファイル化してみて 良かったこと `.tsx` を `.astro` に置き換えるのは簡単だった `.astro` になることで動的に JS を実行したい場合に実装がシンプルになった イマイチなこと `.tsx` から `.astro` を呼び出せない 今後困ることがあるかもしれない 実際に動的にしたい部分だけ `.tsx` にしておいて、配置の際は `.astro` でラップしたものを使えば 良さそう [ 44 / 46 ]

49.

まとめ を駆使して画像を回転させられる クリックするたび回転させたかったら、回転する CSS をクリック時に付与、アニメーション終了時に削除 すればできる ランダムに回転させたい、かつ、なんらかの理由で同じ画像のコンポーネントを複数配置したい、かつ、 Astro を使っている場合は頭を捻る必要がある Astro で動的に JS を実行したい場合は `client:*` の書きどころに注意 動的なコンポーネントを呼び出す部分を `.astro` にするのも良さげ CSS もっといい方法を知っている人はぜひ教えてください!! [ 45 / 46 ]

50.

ご清聴ありがとうございました! ちなみに、このスライドは Slidev で作成しました 。 [1] [2] 1. https://sli.dev/ 2. https://github.com/korosuke613/zenn-articles/pull/376 [ 46 / 46 ]