AI 参与说明(Agent:Devin):本文根据一个已完成的滚动胶囊 Navbar 效果整理为公开、脱敏的技术备份。示例中的品牌、链接和页面内容均为通用占位内容,不包含原项目名称、认证逻辑、内部路径或商业组件源码;整理与验证日期为 2026-09-18。
本文只实现一个效果:Navbar 在页面顶部保持透明、宽幅和紧凑;页面向下滚动后,Navbar 收缩为居中的玻璃胶囊,同时增加高度、边框、阴影与背景模糊。向上回到顶部时恢复初始状态。
| 英文术语 | 中文名称 | 简要解释 |
|---|---|---|
| Navbar | 顶部导航栏 | 承载品牌、主要链接与全局操作的页面区域 |
| Hysteresis | 滞回 | 进入和退出状态使用不同阈值,避免临界位置反复切换 |
| Backdrop Filter | 背景滤镜 | 对元素背后的页面内容进行模糊和饱和度处理 |
| Clipping Plane | 裁剪平面 | 用圆角容器限制稳定背景层的可见范围 |
| Reduced Motion | 减少动态效果 | 根据系统偏好关闭宽度、高度与透明度动画 |
在线效果
下方演示把页面放在独立滚动区域内,便于反复观察两个状态。点击“查看胶囊”可直接滚动到收缩状态。
最终结构
实现由三层组成:
shell固定 Navbar 的页面位置,并提供外部留白;- Navbar 根节点负责宽度、高度和内容布局;
surface → surfaceClip → backdrop形成稳定的玻璃背景和圆角裁剪。
shell[data-scrolled]
└── nav.navbar
├── surface
│ └── surfaceClip
│ └── backdrop
└── header
├── brand
├── links
└── actions
Navbar 始终只渲染一份内容。React 只切换 data-scrolled,CSS 根据该属性完成全部视觉变化。
完整 React 组件
下面的组件可直接放入支持 CSS Modules 的 React 工程。示例监听 window;如果页面使用独立滚动容器,把 window.scrollY 和事件目标替换为对应元素的 scrollTop 即可。
import {useEffect, useLayoutEffect, useRef, useState} from 'react'
import styles from './ScrollCapsuleNavbar.module.css'
const collapseAt = 30
const expandAt = 8
const useBrowserLayoutEffect =
typeof window === 'undefined' ? useEffect : useLayoutEffect
const navigation = [
{href: '#overview', label: 'Overview'},
{href: '#features', label: 'Features'},
{href: '#stories', label: 'Stories'},
]
function useScrollState() {
const [isScrolled, setIsScrolled] = useState(false)
const scrolledRef = useRef(false)
useBrowserLayoutEffect(() => {
let frame = 0
const readPosition = () => {
frame = 0
const scrollY = Math.max(window.scrollY, 0)
const next = scrolledRef.current
? scrollY > expandAt
: scrollY > collapseAt
if (next !== scrolledRef.current) {
scrolledRef.current = next
setIsScrolled(next)
}
}
const scheduleRead = () => {
if (frame === 0) {
frame = requestAnimationFrame(readPosition)
}
}
readPosition()
window.addEventListener('scroll', scheduleRead, {passive: true})
return () => {
cancelAnimationFrame(frame)
window.removeEventListener('scroll', scheduleRead)
}
}, [])
return isScrolled
}
export function ScrollCapsuleNavbar() {
const isScrolled = useScrollState()
return (
<div className={styles.shell} data-scrolled={isScrolled}>
<nav className={styles.navbar} aria-label="Main navigation">
<span className={styles.surface} aria-hidden="true">
<span className={styles.surfaceClip}>
<span className={styles.backdrop} />
</span>
</span>
<div className={styles.header}>
<a className={styles.brand} href="/">Arc</a>
<div className={styles.links}>
{navigation.map(item => (
<a href={item.href} key={item.href}>{item.label}</a>
))}
</div>
<a className={styles.action} href="#start">Get started</a>
</div>
</nav>
</div>
)
}
完整 CSS Module
.shell {
position: fixed;
inset-inline: 0;
top: 0;
z-index: 50;
display: flex;
justify-content: center;
padding: 1rem 1.5rem;
pointer-events: none;
}
.navbar {
position: relative;
isolation: isolate;
width: 100%;
max-width: 72rem;
min-height: 2.25rem;
border: 0;
border-radius: 9999px;
background: transparent;
box-shadow: none;
pointer-events: auto;
transition:
max-width 500ms cubic-bezier(0.4, 0, 0.2, 1),
min-height 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shell[data-scrolled='true'] .navbar {
max-width: 64rem;
min-height: 3.5rem;
}
.surface {
position: absolute;
inset: 0;
z-index: 0;
border-radius: inherit;
opacity: 0;
pointer-events: none;
transition: opacity 320ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shell[data-scrolled='true'] .surface {
opacity: 1;
box-shadow: 0 18px 50px rgb(20 28 23 / 16%);
}
.surfaceClip {
position: absolute;
inset: 0;
overflow: hidden;
border-radius: inherit;
}
.surfaceClip::before,
.surfaceClip::after {
position: absolute;
inset: 0;
border-radius: inherit;
pointer-events: none;
content: '';
}
.surfaceClip::before {
z-index: 1;
background:
radial-gradient(
120% 180% at 12% -90%,
rgb(255 255 255 / 92%) 0%,
rgb(255 255 255 / 58%) 62%
),
linear-gradient(
180deg,
rgb(255 255 255 / 82%) 0%,
rgb(255 255 255 / 48%) 46%
);
}
.surfaceClip::after {
z-index: 2;
box-shadow:
inset 0 1px 0 rgb(255 255 255 / 72%),
inset 0 0 0 1px rgb(20 28 23 / 12%);
}
.backdrop {
position: absolute;
top: 50%;
left: 50%;
z-index: 0;
width: min(calc(100vw - 3rem), 72rem);
height: 3.5rem;
transform: translate3d(-50%, -50%, 0);
background: rgb(255 255 255 / 64%);
-webkit-backdrop-filter: blur(20px) saturate(130%);
backdrop-filter: blur(20px) saturate(130%);
}
.header {
position: relative;
z-index: 3;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
min-height: inherit;
gap: 1rem;
padding-inline: 0;
transition: padding 500ms cubic-bezier(0.4, 0, 0.2, 1);
}
.shell[data-scrolled='true'] .header {
padding-inline: 1.25rem 0.75rem;
}
.brand,
.links a,
.action {
color: #172019;
text-decoration: none;
}
.brand {
justify-self: start;
font-weight: 800;
letter-spacing: -0.04em;
}
.links {
display: flex;
align-items: center;
gap: 1.5rem;
}
.links a {
font-size: 0.875rem;
font-weight: 500;
}
.action {
justify-self: end;
padding: 0.625rem 0.875rem;
border-radius: 999px;
background: #172019;
color: white;
font-size: 0.875rem;
font-weight: 600;
}
.brand:focus-visible,
.links a:focus-visible,
.action:focus-visible {
outline: 2px solid #0b67d0;
outline-offset: 3px;
}
@media (max-width: 650px) {
.shell {
padding-inline: 1rem;
}
.navbar,
.shell[data-scrolled='true'] .navbar {
max-width: 100%;
}
.links {
display: none;
}
.header {
grid-template-columns: 1fr auto;
}
.backdrop {
width: calc(100vw - 2rem);
}
.shell[data-scrolled='true'] .header {
padding-inline: 1rem;
}
}
@media (prefers-reduced-motion: reduce) {
.navbar,
.surface,
.header {
transition: none;
}
}
双阈值滚动状态
进入胶囊状态使用 30px,恢复展开状态使用 8px:
展开状态 -- scrollY > 30 --> 胶囊状态
展开状态 <-- scrollY <= 8 -- 胶囊状态
8px 到 30px 是稳定区。Navbar 进入胶囊状态后,必须滚动回 8px 以内才恢复;因此滚动位置在 30px 附近轻微波动时不会反复切换。
监听器使用 {passive: true},并通过 requestAnimationFrame 把同一绘制周期中的多次 Scroll Event 合并为一次读取。组件卸载时同时移除 Listener 和待执行 Frame。
玻璃表面的稳定裁剪
玻璃层没有直接绑定到不断改变宽度的内容节点。backdrop 保持接近视口宽度,surfaceClip 使用 overflow: hidden 和继承圆角,只显示 Navbar 当前范围内的部分。
稳定的 backdrop
↓
圆角 surfaceClip 裁剪
↓
可见玻璃胶囊随 Navbar 宽度变化
这种结构可以避免宽度动画期间模糊背景的采样平面跟着剧烈缩放。surfaceClip::before 提供玻璃底色,surfaceClip::after 绘制内高光和细边框,真正的页面背景模糊由 backdrop-filter 完成。
页面内容需要预留顶部空间
Navbar 使用 position: fixed,不参与文档流。首屏应留出至少 Navbar 展开高度加外部 Padding 的空间,例如:
main {
padding-top: 6rem;
}
如果首屏本来就有较大的顶部留白,可以不额外增加占位元素。不要在滚动状态变化时修改正文的 padding-top,否则会产生 Layout Shift。
移动菜单接入点
完整产品可以在 .action 的位置渲染 Menu Button,并在移动端隐藏 .links。Menu Button 至少需要暴露:
<button
aria-controls="mobile-navigation"
aria-expanded={isMenuOpen}
aria-label="Toggle navigation"
onClick={() => setIsMenuOpen(open => !open)}
type="button"
>
Menu
</button>
移动菜单本身应处理 Escape 关闭、关闭后的焦点恢复、当前页面 aria-current="page" 和背景滚动策略。胶囊状态只控制 Navbar 表面,不应成为移动菜单状态的第二个来源。
验证清单
- 页面顶部时 Navbar 透明、宽幅且没有玻璃阴影。
scrollY > 30后只切换一次data-scrolled="true"。- 返回
scrollY <= 8后恢复展开状态。 8px–30px区间内保持当前状态,没有临界闪烁。- 收缩前后品牌、链接和操作保持同一 DOM,不重新挂载。
- 宽度与高度变化不推动正文,不产生 Layout Shift。
- 390px 和桌面宽度下没有水平溢出。
- 键盘焦点在透明状态与玻璃状态下都清晰可见。
prefers-reduced-motion: reduce下状态立即切换,不播放尺寸动画。- 组件卸载后 Scroll Listener 与 Animation Frame 均被清理。