Skip to content

Commit 5ceb71e

Browse files
authored
feat: improve back-to-top button behavior (satnaing#520)
Make the back-to-top button appear based on scroll position_ improving accessibility and UX.
1 parent 9a4304e commit 5ceb71e

2 files changed

Lines changed: 103 additions & 22 deletions

File tree

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
---
2+
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
3+
4+
/*
5+
In #btt-btn-container, left position is `left-[calc((var(--container-3xl)/2)+50%+1rem)]`.
6+
This is because we use `max-w-3xl` in `PostDetails.astro`.
7+
If `max-width` in `PostDetails.astro` get updated, make sure to update the `var(--container-3xl)` as well.
8+
max-w-3xl => var(--container-3xl)
9+
max-w-4xl => var(--container-4xl)
10+
max-w-5xl => var(--container-5xl)
11+
etc...
12+
*/
13+
---
14+
15+
<div
16+
id="btt-btn-container"
17+
class:list={[
18+
"fixed right-4 bottom-8 xl:right-auto xl:left-[calc((var(--container-3xl)/2)+50%+1rem)]",
19+
"translate-y-14 opacity-0 transition-all duration-500",
20+
]}
21+
>
22+
<button
23+
data-button="back-to-top"
24+
class:list={[
25+
"group relative",
26+
"size-14 rounded-full px-2 py-1 shadow-xl lg:h-8 lg:w-fit lg:rounded-md lg:shadow-none",
27+
"bg-background lg:bg-transparent",
28+
]}
29+
>
30+
<span
31+
id="progress-indicator"
32+
class="absolute inset-0 -z-10 block size-14 scale-110 rounded-full bg-transparent lg:hidden lg:h-8 lg:rounded-md"
33+
></span>
34+
<IconChevronLeft class="inline-block rotate-90 lg:hidden" />
35+
<span
36+
class="sr-only underline decoration-dashed decoration-2 underline-offset-4 group-hover:text-accent lg:not-sr-only"
37+
>
38+
<span class="text-xl" tabindex="0">&uarr;</span> Back To Top
39+
</span>
40+
</button>
41+
</div>
42+
43+
<script is:inline data-astro-rerun>
44+
/** Scrolls the document to the top when
45+
* the "Back to Top" button is clicked. */
46+
function backToTop() {
47+
const rootElement = document.documentElement;
48+
const btnContainer = document.querySelector("#btt-btn-container");
49+
const backToTopBtn = document.querySelector("[data-button='back-to-top']");
50+
const progressIndicator = document.querySelector("#progress-indicator");
51+
52+
if (!rootElement || !btnContainer || !backToTopBtn || !progressIndicator)
53+
return;
54+
55+
// Attach click event handler for back-to-top button
56+
backToTopBtn.addEventListener("click", () => {
57+
document.body.scrollTop = 0; // For Safari
58+
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
59+
});
60+
61+
// Handle button visibility according to scroll position
62+
let lastVisible = null;
63+
function handleScroll() {
64+
const scrollTotal = rootElement.scrollHeight - rootElement.clientHeight;
65+
const scrollTop = rootElement.scrollTop;
66+
const scrollPercent = Math.floor((scrollTop / scrollTotal) * 100);
67+
68+
progressIndicator.style.setProperty(
69+
"background-image",
70+
`conic-gradient(var(--accent), var(--accent) ${scrollPercent}%, transparent ${scrollPercent}%)`
71+
);
72+
73+
const isVisible = scrollTop / scrollTotal > 0.3;
74+
75+
if (isVisible !== lastVisible) {
76+
btnContainer.classList.toggle("opacity-100", isVisible);
77+
btnContainer.classList.toggle("translate-y-0", isVisible);
78+
btnContainer.classList.toggle("opacity-0", !isVisible);
79+
btnContainer.classList.toggle("translate-y-14", !isVisible);
80+
lastVisible = isVisible;
81+
}
82+
}
83+
84+
let ticking = false;
85+
document.addEventListener("scroll", () => {
86+
if (!ticking) {
87+
window.requestAnimationFrame(() => {
88+
handleScroll();
89+
ticking = false;
90+
});
91+
ticking = true;
92+
}
93+
});
94+
}
95+
backToTop();
96+
</script>

src/layouts/PostDetails.astro

Lines changed: 7 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import Datetime from "@/components/Datetime.astro";
88
import EditPost from "@/components/EditPost.astro";
99
import ShareLinks from "@/components/ShareLinks.astro";
1010
import BackButton from "@/components/BackButton.astro";
11+
import BackToTopButton from "@/components/BackToTopButton.astro";
1112
import { getPath } from "@/utils/getPath";
1213
import { slugifyStr } from "@/utils/slugify";
1314
import IconChevronLeft from "@/assets/icons/IconChevronLeft.svg";
@@ -109,22 +110,16 @@ const nextPost =
109110

110111
<EditPost class="sm:hidden" {hideEditPost} {post} />
111112

112-
<ul class="mt-4 mb-8 sm:my-8">
113-
{tags.map(tag => <Tag tag={slugifyStr(tag)} tagName={tag} />)}
114-
</ul>
113+
<BackToTopButton />
115114

116115
<div
117-
class="flex flex-col items-center justify-between gap-6 sm:flex-row sm:items-end sm:gap-4"
116+
class="flex flex-col items-center justify-between gap-6 sm:flex-row sm:items-start sm:gap-8"
118117
>
119-
<ShareLinks />
118+
<ul class="self-start">
119+
{tags.map(tag => <Tag tag={slugifyStr(tag)} tagName={tag} />)}
120+
</ul>
120121

121-
<button
122-
id="back-to-top"
123-
class="focus-outline py-1 whitespace-nowrap hover:opacity-75"
124-
>
125-
<IconChevronLeft class="inline-block rotate-90" />
126-
<span>Back to Top</span>
127-
</button>
122+
<ShareLinks />
128123
</div>
129124

130125
<hr class="my-6 border-dashed" />
@@ -270,16 +265,6 @@ const nextPost =
270265
}
271266
attachCopyButtons();
272267

273-
/** Scrolls the document to the top when
274-
* the "Back to Top" button is clicked. */
275-
function backToTop() {
276-
document.querySelector("#back-to-top")?.addEventListener("click", () => {
277-
document.body.scrollTop = 0; // For Safari
278-
document.documentElement.scrollTop = 0; // For Chrome, Firefox, IE and Opera
279-
});
280-
}
281-
backToTop();
282-
283268
/* Go to page start after page swap */
284269
document.addEventListener("astro:after-swap", () =>
285270
window.scrollTo({ left: 0, top: 0, behavior: "instant" })

0 commit comments

Comments
 (0)