【React】飛沫が飛んで垂れるボタンを実装する

<!-- =========================================================== -->
# 概要
<!-- =========================================================== -->
**ホバーすると飛沫が飛んで垂れるボタン**を実装します。

![output(video-cutter-js.com) (4).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/b709d256-eb58-2181-f474-def54d001097.gif)

:::note info
<strong>gooey effect</strong>については、以下を参考してください。
:::

https://qiita.com/nemutas/items/4a023db35e71ccfedf86

:::note info
スタイリングには、<strong>emotion/css</strong>（CSS in JS）を使用しています。
:::

https://emotion.sh/docs/@emotion/css

https://qiita.com/nemutas/items/dac73aa645f27cde92cd

<!-- =========================================================== -->
# 実装
<!-- =========================================================== -->
以下のGIFは、**gooey effect**を外したときの描画です。
これに**gooey effect**を足すことで、滑らかな飛沫を表現しています。

![output(video-cutter-js.com) (5).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/358498/4b9e8ed2-3629-6342-746d-40c6071ae5fc.gif)

```.tsx
import React, { useRef, useState, VFC } from 'react';
import { VscGithub } from 'react-icons/vsc';
import { css, cx } from '@emotion/css';

export const GooeyEffectButton: VFC = () => {
	const splashCount = 10 // 飛沫の数
	const splashSizeRange = [20, 30] // 飛沫の大きさの範囲（px）
	const splashRange = [-80, 80] // 飛び散る範囲（px）
	const dripRange = [0, 100] // 飛沫の垂れ具合（px）

	const [hover, setHover] = useState(false)
	const splashParamsRef = useRef<{ size: number; mx: number; my: number; drip: number }[]>(
		[...Array(splashCount)].map(() => {
			return {
				size: 25,
				mx: 0,
				my: 0,
				drip: 0
			}
		})
	)

	/** 指定範囲のランダムな整数を返す */
	const getRandomInt = (min: number, max: number) => {
		const ratio = max - min + 1 // contain max val
		return Math.floor(Math.random() * ratio) + min
	}

	const mouseEnterHandler = () => {
		splashParamsRef.current = splashParamsRef.current.map(() => {
			return {
				size: getRandomInt(splashSizeRange[0], splashSizeRange[1]),
				mx: getRandomInt(splashRange[0], splashRange[1]),
				my: getRandomInt(splashRange[0], splashRange[1]),
				drip: getRandomInt(dripRange[0], dripRange[1])
			}
		})
		setHover(true)
	}

	return (
		<div className={styles.container}>
			{/* filter */}
			<svg style={{ width: 0, height: 0 }}>
				<filter id="gooey">
					<feGaussianBlur in="SourceGraphic" stdDeviation="10" />
					<feColorMatrix
						values="
						1 0 0 0 0
						0 1 0 0 0
						0 0 1 0 0
						0 0 0 20 -5
						"
					/>
				</filter>
			</svg>
			{/* contents */}
			<div className={styles.outer}>
				<div className={styles.splashContainer}>
					<div className={styles.circle}>
						{splashParamsRef.current.map((p, i) => (
							<div
								key={i}
								className={cx(styles.splash(p.size), {
									[styles.hover(p.mx, p.my, p.drip)]: hover
								})}>
								{/* さらに遠くに飛沫を飛ばす */}
								<div
									className={cx(styles.splash(p.size * 0.9), {
										[styles.hover(p.mx * 1.1, p.my * 1.1, p.drip)]: hover
									})}
								/>
							</div>
						))}
					</div>
				</div>
				<VscGithub
					className={styles.icon}
					onMouseEnter={mouseEnterHandler}
					onMouseLeave={() => setHover(false)}
				/>
			</div>
		</div>
	)
}

const templates = {
	flex: css`
		display: flex;
		justify-content: center;
		align-items: center;
	`
}

const styles = {
	container: css`
		position: relative;
		width: 100vw;
		height: 100vh;
		${templates.flex}
	`,
	outer: css`
		position: absolute;
		width: 500px;
		height: 500px;
		${templates.flex}
		border: 1px solid #404040;
		border-radius: 50%;
	`,
	splashContainer: css`
		position: relative;
		width: 100%;
		height: 100%;
		${templates.flex}
		border-radius: 50%;
		overflow: hidden;
		filter: url(#gooey);
	`,
	circle: css`
		position: relative;
		width: 50px;
		height: 50px;
		${templates.flex}
		background-color: white;
		border-radius: 50%;
		box-shadow: 0 0 30px white;
	`,
	splash: (size: number) => css`
		position: absolute;
		width: ${size}px;
		height: ${size}px;
		${templates.flex}
		background-color: white;
		border-radius: 50%;
		box-shadow: 0 0 30px white;
		transform: translate(0px, 0px);
		transition: transform 0.5s;

		&::before {
			content: '';
			position: absolute;
			width: ${size * 0.8}px;
			height: ${size * 0.8}px;
			border-radius: 50%;
			background-color: white;
			transform: translate(0px, 0px);
			transition: transform 2s;
		}
		&::after {
			content: '';
			position: absolute;
			width: ${size * 0.4}px;
			height: ${0}px;
			background-color: white;
			transform: translate(0px, 0px);
			transition: 2s;
		}
	`,
	hover: (mx: number, my: number, drip: number) => css`
		transform: translate(${mx}px, ${my}px);

		&::before {
			transform: translate(0px, ${drip}px);
		}
		&::after {
			height: ${drip}px;
			transform: translate(0px, ${drip / 2}px);
		}
	`,
	icon: css`
		position: absolute;
		width: 70px;
		height: 70px;
		font-size: 2rem;
		border-radius: 50%;
		cursor: pointer;
		color: #1e1e1e;
	`
}
```
* 飛沫のサイズ、飛ぶ範囲、垂れ具合はすべてランダムに決めています。<br>
GitHubアイコンをホバーしたタイミングで、ランダムな値を決めて、hover状態をtrueにしています。

```.tsx
/** 指定範囲のランダムな整数を返す */
const getRandomInt = (min: number, max: number) => {
	const ratio = max - min + 1 // contain max val
	return Math.floor(Math.random() * ratio) + min
}

const mouseEnterHandler = () => {
	splashParamsRef.current = splashParamsRef.current.map(() => {
		return {
			size: getRandomInt(splashSizeRange[0], splashSizeRange[1]),
			mx: getRandomInt(splashRange[0], splashRange[1]),
			my: getRandomInt(splashRange[0], splashRange[1]),
			drip: getRandomInt(dripRange[0], dripRange[1])
		}
	})
	setHover(true)
}
```

* 飛沫は2段階にわけて飛ばしています。

```.tsx
<div
	key={i}
	className={cx(styles.splash(p.size), {
	[styles.hover(p.mx, p.my, drip)]: hover
	})}>
		{/* さらに遠くに飛沫を飛ばす */}
		<div
			className={cx(styles.splash(p.size * 0.9), {[styles.hover(p.mx * 1.1, p.my * 1.1, drip)]: hover})}
		/>
</div>
```