滚动隐藏顶栏
向下滚动阅读时顶栏滑走,一往上滚想找它,它就立刻滑回来。
用 React 制作向下滚动时隐藏、向上滚动时重新出现的顶栏。用 ref 保存上一次的滚动位置。每次 scroll 事件比较新的 scrollTop:如果向下移动超过 4px 且已超过顶栏高度,就给顶栏设置 data-hidden 属性;如果向上移动超过 4px,就移除它。顶栏使用 position: sticky 固定在顶部,隐藏时为 transform: translateY(-100%),transform 过渡 280ms,缓动 cubic-bezier(0.16, 1, 0.3, 1)。设置一个小阈值,避免触控板的细微抖动让它闪烁。焦点进入顶栏内部时始终显示。在 prefers-reduced-motion 下隐藏和显示不做滑动。品牌名为“田野笔记”。
源代码
'use client'
import { useRef } from 'react'
import s from './demo.module.css'
export default function HideHeader({ labels }: { labels: Record<string, string> }) {
const header = useRef<HTMLElement>(null)
const last = useRef(0)
const onScroll = (e: React.UIEvent<HTMLDivElement>) => {
const y = e.currentTarget.scrollTop
const dy = y - last.current
if (Math.abs(dy) < 4) return
header.current?.toggleAttribute('data-hidden', dy > 0 && y > 48)
last.current = y
}
return (
<div className={s.frame} tabIndex={0} onScroll={onScroll}>
<header ref={header} className={s.header}>
<span className={s.logo} aria-hidden="true" />
<strong className={s.brand}>{labels.brand}</strong>
<span className={s.menu} aria-hidden="true" />
</header>
<span className={s.hint}>{labels.hint}</span>
<div className={s.feed}>
{Array.from({ length: 6 }, (_, i) => (
<div key={i} className={s.post}>
<span className={s.thumb} />
<span className={s.lines}>
<span />
<span />
</span>
</div>
))}
</div>
</div>
)
}
.frame {
position: absolute;
inset: 0;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
.frame:focus-visible { outline: 2px solid var(--color-focus); outline-offset: -4px; }
.hint {
display: block;
font: 500 var(--text-xs) / 1 var(--font-mono);
letter-spacing: 0.06em;
text-transform: uppercase;
text-align: center;
color: var(--stage-muted);
}
.header {
position: sticky;
top: 0;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
height: 48px;
padding: 0 16px;
border-bottom: 1px solid var(--stage-rule);
background: var(--stage-surface);
transition: transform 280ms var(--ease-out);
}
.header[data-hidden]:not(:focus-within) { transform: translateY(-100%); }
.logo { width: 20px; height: 20px; border-radius: 6px; background: var(--stage-accent); }
.brand { flex: 1; font: 600 var(--text-sm) / 1 var(--font-display); color: var(--stage-ink); }
.menu {
width: 18px;
height: 10px;
border-block: 2px solid var(--stage-muted);
}
.frame > .hint { margin: 16px 0 4px; }
.feed {
display: grid;
gap: 10px;
max-width: 300px;
margin: 0 auto;
padding: 8px 16px 24px;
}
.post {
display: flex;
gap: 12px;
padding: 10px;
border: 1px solid var(--stage-rule);
border-radius: 12px;
}
.thumb { flex: none; width: 44px; height: 44px; border-radius: 8px; background: var(--stage-surface); }
.lines { display: grid; flex: 1; align-content: center; gap: 8px; }
.lines span { height: 8px; border-radius: 4px; background: var(--stage-surface); }
.lines span:last-child { width: 60%; }
@media (prefers-reduced-motion: reduce) {
.header { transition: none; }
}