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>
);
}
.button {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-full);
background: linear-gradient(
to right,
var(--color-accent),
var(--color-accent-secondary),
var(--color-accent)
);
background-size: 200% auto;
animation: gradient-shift 12s ease infinite;
color: var(--color-bg);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&.md {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
&.md_base {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_base {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
@media (min-width: 480px) {
&.md_mobile {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_mobile {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 768px) {
&.md_tablet {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_tablet {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 1024px) {
&.md_desktop {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_desktop {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 1440px) {
&.md_wide {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_wide {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.floating {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.3s ease,
visibility 0.3s ease;
&.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
}
@keyframes gradient-shift {
0% {
background-position: 0% center;
}
100% {
background-position: 200% center;
}
}
<script setup lang="ts">
import { computed, ref, watchEffect } from "vue";
import { PhCaretUp } from "@phosphor-icons/vue";
type Size = "md" | "lg";
interface ResponsiveSize {
base: Size;
mobile?: Size;
tablet?: Size;
desktop?: Size;
wide?: Size;
}
interface ButtonScrollProps {
size: Size | ResponsiveSize;
ariaLabel: string;
floating?: boolean;
}
const SHOW_FROM = 80;
const { size, ariaLabel, floating } = defineProps<ButtonScrollProps>();
const visible = ref(false);
const sizeClasses = computed(() => {
if (typeof size === "string") {
return size;
}
const classes = [`${size.base}_base`];
if (size.mobile) classes.push(`${size.mobile}_mobile`);
if (size.tablet) classes.push(`${size.tablet}_tablet`);
if (size.desktop) classes.push(`${size.desktop}_desktop`);
if (size.wide) classes.push(`${size.wide}_wide`);
return classes.join(" ");
});
watchEffect(
(onCleanup) => {
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.
visible.value = window.scrollY > 0 && percent > SHOW_FROM;
};
window.addEventListener("scroll", onScroll);
onScroll();
onCleanup(() => window.removeEventListener("scroll", onScroll));
},
{ flush: "post" },
);
const onClick = () => {
const reduce = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
window.scrollTo({ top: 0, behavior: reduce ? "auto" : "smooth" });
};
</script>
<template>
<button
type="button"
class="button"
:class="[sizeClasses, { visible, floating }]"
:aria-label="ariaLabel"
@click="onClick"
>
<PhCaretUp aria-hidden="true" />
</button>
</template>
<style scoped>
.button {
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: var(--radius-full);
background: linear-gradient(
to right,
var(--color-accent),
var(--color-accent-secondary),
var(--color-accent)
);
background-size: 200% auto;
animation: gradient-shift 12s ease infinite;
color: var(--color-bg);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
&.md {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
&.md_base {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_base {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
@media (min-width: 480px) {
&.md_mobile {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_mobile {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 768px) {
&.md_tablet {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_tablet {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 1024px) {
&.md_desktop {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_desktop {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (min-width: 1440px) {
&.md_wide {
width: var(--size-40);
height: var(--size-40);
font-size: var(--text-20);
}
&.lg_wide {
width: var(--size-48);
height: var(--size-48);
font-size: var(--text-24);
}
}
@media (prefers-reduced-motion: reduce) {
animation: none;
}
&:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
&.floating {
opacity: 0;
visibility: hidden;
pointer-events: none;
transition:
opacity 0.3s ease,
visibility 0.3s ease;
&.visible {
opacity: 1;
visibility: visible;
pointer-events: auto;
}
}
}
@keyframes gradient-shift {
0% {
background-position: 0% center;
}
100% {
background-position: 200% center;
}
}
</style>
Props
| Prop | Type | Required | Description |
|---|---|---|---|
| size | "md" | "lg" | ResponsiveSize | required | Button diameter. Fixed or responsive. |
| aria-label | string | required | Accessible name of the button (icon-only: without it, a screen reader would announce nothing). |
| floating | boolean | — | Hides it until the bottom of the viewport passes 80% of the page. Fixed positioning comes from the container. |