Skip to content

Commit b2f021e

Browse files
committed
fix: improve footer and scroll-to-top accessibility and UX
1 parent deb2297 commit b2f021e

3 files changed

Lines changed: 97 additions & 38 deletions

File tree

app/app.vue

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,17 @@ html {
7575
text-rendering: optimizeLegibility;
7676
}
7777
78+
/*
79+
* Enable CSS scroll-state container queries for the document
80+
* This allows the footer to query the scroll state using pure CSS
81+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
82+
*/
83+
@supports (container-type: scroll-state) {
84+
html {
85+
container-type: scroll-state;
86+
}
87+
}
88+
7889
body {
7990
margin: 0;
8091
background-color: #0a0a0a;

app/components/AppFooter.vue

Lines changed: 76 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,22 @@
11
<script setup lang="ts">
2+
const isMounted = ref(false)
23
const isVisible = ref(false)
34
const isScrollable = ref(true)
45
const lastScrollY = ref(0)
56
const footerRef = ref<HTMLElement>()
67
8+
// Check if CSS scroll-state container queries are supported
9+
// Once this becomes baseline, we can remove the JS scroll handling entirely
10+
const supportsScrollStateQueries = ref(false)
11+
712
function checkScrollable() {
8-
const mainContent = document.getElementById('main-content')
9-
if (!mainContent) return true
10-
return mainContent.scrollHeight > window.innerHeight
13+
return document.documentElement.scrollHeight > window.innerHeight
1114
}
1215
1316
function onScroll() {
17+
// Skip JS-based visibility logic if CSS scroll-state queries handle it
18+
if (supportsScrollStateQueries.value) return
19+
1420
const currentY = window.scrollY
1521
const diff = lastScrollY.value - currentY
1622
const nearBottom = currentY + window.innerHeight >= document.documentElement.scrollHeight - 50
@@ -37,55 +43,72 @@ function updateFooterPadding() {
3743
document.documentElement.style.setProperty('--footer-height', `${height}px`)
3844
}
3945
46+
function onResize() {
47+
isScrollable.value = checkScrollable()
48+
updateFooterPadding()
49+
}
50+
4051
onMounted(() => {
52+
// Feature detect CSS scroll-state container queries (Chrome 133+)
53+
// @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
54+
supportsScrollStateQueries.value = CSS.supports('container-type', 'scroll-state')
55+
4156
nextTick(() => {
4257
lastScrollY.value = window.scrollY
4358
isScrollable.value = checkScrollable()
4459
updateFooterPadding()
60+
// Only apply dynamic classes after mount to avoid hydration mismatch
61+
isMounted.value = true
4562
})
4663
4764
window.addEventListener('scroll', onScroll, { passive: true })
48-
window.addEventListener(
49-
'resize',
50-
() => {
51-
isScrollable.value = checkScrollable()
52-
updateFooterPadding()
53-
},
54-
{ passive: true },
55-
)
65+
window.addEventListener('resize', onResize, { passive: true })
5666
})
5767
5868
onUnmounted(() => {
5969
window.removeEventListener('scroll', onScroll)
70+
window.removeEventListener('resize', onResize)
6071
})
6172
</script>
6273

6374
<template>
6475
<footer
6576
ref="footerRef"
66-
class="border-t border-border bg-bg"
67-
:class="
68-
isScrollable
69-
? [
70-
'fixed bottom-0 left-0 right-0 z-40 transition-transform duration-300 ease-out',
71-
isVisible ? 'translate-y-0' : 'translate-y-full',
72-
]
73-
: 'mt-auto'
74-
"
77+
aria-label="Site footer"
78+
class="border-t border-border bg-bg/90 backdrop-blur-md"
79+
:class="[
80+
// Only apply dynamic positioning classes after mount to avoid hydration mismatch
81+
!isMounted
82+
? 'mt-auto'
83+
: // When CSS scroll-state queries are supported, use CSS-only approach
84+
supportsScrollStateQueries
85+
? 'footer-scroll-state'
86+
: // Fallback to JS-controlled classes
87+
isScrollable
88+
? [
89+
'fixed bottom-0 left-0 right-0 z-40 transition-transform duration-300 ease-out',
90+
isVisible ? 'translate-y-0' : 'translate-y-full',
91+
]
92+
: 'mt-auto',
93+
]"
7594
>
7695
<div class="container py-6 flex flex-col gap-3 text-fg-subtle text-sm">
7796
<div class="flex flex-col sm:flex-row items-center justify-between gap-4">
7897
<p class="font-mono m-0">a better browser for the npm registry</p>
79-
<div class="flex items-center gap-6">
98+
<div class="flex items-center gap-4 sm:gap-6">
8099
<a
81100
href="https://github.com/npmx-dev/npmx.dev"
82101
rel="noopener noreferrer"
83-
class="link-subtle font-mono text-xs"
102+
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
84103
>
85104
source
86105
</a>
87106
<span class="text-border">|</span>
88-
<a href="https://roe.dev" rel="noopener noreferrer" class="link-subtle font-mono text-xs">
107+
<a
108+
href="https://roe.dev"
109+
rel="noopener noreferrer"
110+
class="link-subtle font-mono text-xs min-h-11 min-w-11 flex items-center"
111+
>
89112
@danielroe
90113
</a>
91114
</div>
@@ -96,3 +119,33 @@ onUnmounted(() => {
96119
</div>
97120
</footer>
98121
</template>
122+
123+
<style scoped>
124+
/*
125+
* CSS scroll-state container queries (Chrome 133+)
126+
* @see https://developer.mozilla.org/en-US/docs/Web/CSS/@container#scroll-state_container_descriptors
127+
*
128+
* This provides a pure CSS solution for showing/hiding the footer based on scroll state.
129+
* The JS fallback handles browsers without support.
130+
* Once scroll-state queries become baseline, we can remove the JS scroll handling entirely.
131+
*/
132+
@supports (container-type: scroll-state) {
133+
.footer-scroll-state {
134+
position: fixed;
135+
bottom: 0;
136+
left: 0;
137+
right: 0;
138+
z-index: 40;
139+
/* Hidden by default (translated off-screen) */
140+
transform: translateY(100%);
141+
transition: transform 0.3s ease-out;
142+
}
143+
144+
/* Show footer when user can scroll up (meaning they've scrolled down) */
145+
@container scroll-state(scrollable: top) {
146+
.footer-scroll-state {
147+
transform: translateY(0);
148+
}
149+
}
150+
}
151+
</style>

app/components/ScrollToTop.vue

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -28,27 +28,22 @@ onUnmounted(() => {
2828
</script>
2929

3030
<template>
31-
<Transition name="fade">
31+
<Transition
32+
enter-active-class="transition-all duration-200"
33+
enter-from-class="opacity-0 translate-y-2"
34+
enter-to-class="opacity-100 translate-y-0"
35+
leave-active-class="transition-all duration-200"
36+
leave-from-class="opacity-100 translate-y-0"
37+
leave-to-class="opacity-0 translate-y-2"
38+
>
3239
<button
3340
v-if="isActive && isVisible"
3441
type="button"
35-
class="fixed top-16 left-1/2 -translate-x-1/2 z-50 p-3 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden active:scale-95"
42+
class="fixed bottom-4 right-4 z-50 w-12 h-12 bg-bg-elevated border border-border rounded-full shadow-lg md:hidden flex items-center justify-center text-fg-muted hover:text-fg transition-colors active:scale-95"
3643
aria-label="Scroll to top"
3744
@click="scrollToTop"
3845
>
39-
<span class="i-carbon-chevron-up w-5 h-5 block text-fg" aria-hidden="true" />
46+
<span class="i-carbon-arrow-up w-5 h-5" aria-hidden="true" />
4047
</button>
4148
</Transition>
4249
</template>
43-
44-
<style scoped>
45-
.fade-enter-active,
46-
.fade-leave-active {
47-
transition: opacity 0.2s ease;
48-
}
49-
50-
.fade-enter-from,
51-
.fade-leave-to {
52-
opacity: 0;
53-
}
54-
</style>

0 commit comments

Comments
 (0)