Hide Header on Scroll
The header slides away while you scroll down to read and slides back the moment you scroll up to look for it.
Create a header that hides on scroll down and reappears on scroll up, in React. Keep the last scroll position in a ref. On each scroll event, compare the new scrollTop: if it moved down by more than 4px and is past the header height, set a data-hidden attribute on the header; if it moved up by more than 4px, remove it. The header is position: sticky at the top and uses transform: translateY(-100%) when hidden, with a 280ms cubic-bezier(0.16, 1, 0.3, 1) transform transition. Use a small threshold so tiny trackpad jitters do not make it flicker. Always show the header when focus moves inside it. Under prefers-reduced-motion, hide and show without the slide. The brand reads "Field notes".
Source
'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; }
}