React
フロントエンド
ブログの見た目をリッチにしようと思い立って、上スクロールしたときにだけ表示されるボタンを作った。
ざっくりとした要件はこんな感じ。
環境としてReact+Tailwindで動作するものを作る。ボタン一個だけどしっかりしようとしたものを作るのは結構大変。
普通にReactでコンポーネントを作成する。アイコンにはreact-iconsを使っている。styles.popupとstyles.dismissはアニメーション制御用のスタイル。 visibleに渡す値によって表示か非表示かを切り替えることができる。
ポイントとしてはuseStateで初回描画時のアニメーションを制御している。 初期状態で表示をオフにしたい場合には、この辺を適切に制御してあげないとチラついたりする。
react-iconsにはIconTypeという型が用意されているので、これを使ってあげれば外部から好きなアイコンを渡せる。
import { IconType } from "react-icons";
import classNames from "classnames";
import styles from "./IconButton.module.css";
import { useState } from "react";
interface IconButtonProps {
onClick: () => void;
visible?: boolean;
icon: IconType;
}
export const IconButton: React.FC<IconButtonProps> = ({
onClick,
visible,
icon: Icon,
}) => {
// 初めて表示されるまではアニメーションを表示しない。
const [isFirstTime, setIsFirstTime] = useState(true);
if (isFirstTime && visible === true) {
setIsFirstTime(false);
}
return (
<button
onClick={onClick}
className={classNames(
"flex rounded-full drop-shadow-lg items-center justify-center",
styles.iconButton,
isFirstTime && styles.firstTime,
visible && styles.popup,
!isFirstTime && !visible && styles.dismiss
)}
>
<Icon className="block" size="18px" color="#fff" />
</button>
);
};
長いのでアニメーション部分だけ。単純にpopup時とdismiss時のアニメーションをkeyframeで指定している。
.popup {
animation: popup-animation;
animation-duration: 0.1s;
}
.dismiss {
animation: dismiss-animation;
animation-duration: 0.1s;
animation-delay: 0;
transform: scale(0);
}
@keyframes popup-animation {
from {
transform: scale(0);
}
to {
transform: scale(1);
}
}
@keyframes dismiss-animation {
from {
transform: scale(1);
}
to {
transform: scale(0);
}
}
このzennの記事からコードを拝借して実装。 useThrottleは記事中のものをそのままコピーさせて頂いている。
基本的な実装方針としては、useStateで前回のスクロール位置を管理して現在のスクロール位置と比較することでスクロール方向を検出する。 ただナイーブに実装するとstateが書き換わりまくって膨大なレンダリングが発生するのでuseThrottleで発火頻度を落とす。 今回は200msに1回だけ発火するように制御している。
import { useEffect, useRef, useCallback, useState } from "react";
export const useScrollY = (): number => {
const [prevScroll, setPreviousScroll] = useState(0);
const [speedY, setSpeedY] = useState(0);
const handleScroll = useThrottle(() => {
const dy = window.scrollY - prevScroll;
setPreviousScroll(window.scrollY);
setSpeedY(dy);
}, 200);
useEffect(() => {
window.addEventListener("scroll", handleScroll);
return () => {
window.removeEventListener("scroll", handleScroll);
};
}, [handleScroll]);
return speedY;
};
// https://zenn.dev/catnose99/articles/0f0bb01ee6a940 からコピペ
function useThrottle<T>(
fn: (args?: T) => void,
durationMS: number // スロットルする時間
) {
const scrollingTimer = useRef<undefined | NodeJS.Timeout>();
return useCallback(
(args?: T) => {
if (scrollingTimer.current) return;
scrollingTimer.current = setTimeout(() => {
fn(args);
scrollingTimer.current = undefined;
}, durationMS);
},
[scrollingTimer, fn, durationMS]
);
}
あとは組み合わせるだけ。
import { FaRss } from "react-icons/fa";
import { IconButton } from "../Button";
import { useScrollY } from "@/lib/useScrollY";
import React from "react";
export const SocialIcons: React.FC = () => {
const dy = useScrollY();
return (
<div className="fixed bottom-0 right-0 flex items-center justify-end pr-4 pb-4 z-20">
<IconButton
icon={FaRss}
onClick={() => {console.log("click")}}
visible={dy < 0}
/>
</div>
);
};
hover時の挙動ももう少し凝りたかったが力尽きた。Copilotがなかったら死んでいたかもしれない。