Actions

ButtonScroll

Floating back-to-top button

Preview

size
floating

Code

import css from "./ButtonScroll.module.css";
import { useEffect, useState } from "react";

import { CaretUpIcon } from "@phosphor-icons/react/dist/csr/CaretUp";

type Size = "md" | "lg";

interface ResponsiveSize {
  base: Size;
  mobile?: Size;
  tablet?: Size;
  desktop?: Size;
  wide?: Size;
}

interface ButtonScrollProps {
  size: Size | ResponsiveSize;
  "aria-label": string;
  floating?: boolean;
}

const SHOW_FROM = 80;

function getSizeClasses(size: Size | ResponsiveSize): string {
  if (typeof size === "string") {
    return css[size];
  }

  const classes = [css[`${size.base}_base`]];

  if (size.mobile) classes.push(css[`${size.mobile}_mobile`]);
  if (size.tablet) classes.push(css[`${size.tablet}_tablet`]);
  if (size.desktop) classes.push(css[`${size.desktop}_desktop`]);
  if (size.wide) classes.push(css[`${size.wide}_wide`]);

  return classes.join(" ");
}

export default function ButtonScroll({
  size,
  "aria-label": ariaLabel,
  floating,
}: ButtonScrollProps) {
  const [visible, setVisible] = useState(false);

  useEffect(() => {
    if (!floating) return;

    const onScroll = () => {
      const scrolled = window.scrollY + window.innerHeight;
      const total = document.documentElement.scrollHeight;
      const percent = (scrolled / total) * 100;

      // A page with no scroll sits at ~100% from the start — never show at the top.
      setVisible(window.scrollY > 0 && percent > SHOW_FROM);
    };

    window.addEventListener("scroll", onScroll);
    onScroll();
    return () => window.removeEventListener("scroll", onScroll);
  }, [floating]);

  const onClick = () => {
    const reduce = window.matchMedia(
      "(prefers-reduced-motion: reduce)",
    ).matches;
    window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
  };

  return (
    <button
      type="button"
      className={`
        ${css.button}
        ${getSizeClasses(size)}
        ${visible ? css.visible : ""}
        ${floating ? css.floating : ""}
      `}
      aria-label={ariaLabel}
      onClick={onClick}
    >
      <CaretUpIcon aria-hidden="true" />
    </button>
  );
}

Props

PropTypeRequiredDescription
size"md" | "lg" | ResponsiveSizerequiredButton diameter. Fixed or responsive.
aria-labelstringrequiredAccessible name of the button (icon-only: without it, a screen reader would announce nothing).
floatingboolean—Hides it until the bottom of the viewport passes 80% of the page. Fixed positioning comes from the container.