2023-7-15

上にスクロールしたときにだけ表示されるちょっといい感じのボタンを作る

React

フロントエンド

ブログの見た目をリッチにしようと思い立って、上スクロールしたときにだけ表示されるボタンを作った。

完成したボタン

ざっくりとした要件はこんな感じ。

  • 下にスクロールしたときには表示されない
  • 上にスクロールしたときにだけ表示される
  • On, Off時にはいい感じにアニメーションする
  • 負荷やちらつきは最小限に

環境としてReact+Tailwindで動作するものを作る。ボタン一個だけどしっかりしようとしたものを作るのは結構大変。

ベースになるコンポーネント

普通にReactでコンポーネントを作成する。アイコンにはreact-iconsを使っている。styles.popupとstyles.dismissはアニメーション制御用のスタイル。 visibleに渡す値によって表示か非表示かを切り替えることができる。

ポイントとしてはuseStateで初回描画時のアニメーションを制御している。 初期状態で表示をオフにしたい場合には、この辺を適切に制御してあげないとチラついたりする。

react-iconsにはIconTypeという型が用意されているので、これを使ってあげれば外部から好きなアイコンを渡せる。

jsx
Copied!
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>
  );
};

CSS

長いのでアニメーション部分だけ。単純にpopup時とdismiss時のアニメーションをkeyframeで指定している。

css
Copied!
.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回だけ発火するように制御している。

jsx
Copied!
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]
  );
}

インテグレーション

あとは組み合わせるだけ。

jsx
Copied!
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がなかったら死んでいたかもしれない。