跳至正文
React — 滚动收缩玻璃胶囊 Navbar:React 与 CSS 完整实现

滚动收缩玻璃胶囊 Navbar:React 与 CSS 完整实现

AI 参与说明(Agent:Devin):本文根据一个已完成的滚动胶囊 Navbar 效果整理为公开、脱敏的技术备份。示例中的品牌、链接和页面内容均为通用占位内容,不包含原项目名称、认证逻辑、内部路径或商业组件源码;整理与验证日期为 2026-09-18。

本文只实现一个效果:Navbar 在页面顶部保持透明、宽幅和紧凑;页面向下滚动后,Navbar 收缩为居中的玻璃胶囊,同时增加高度、边框、阴影与背景模糊。向上回到顶部时恢复初始状态。

英文术语中文名称简要解释
Navbar顶部导航栏承载品牌、主要链接与全局操作的页面区域
Hysteresis滞回进入和退出状态使用不同阈值,避免临界位置反复切换
Backdrop Filter背景滤镜对元素背后的页面内容进行模糊和饱和度处理
Clipping Plane裁剪平面用圆角容器限制稳定背景层的可见范围
Reduced Motion减少动态效果根据系统偏好关闭宽度、高度与透明度动画

在线效果

下方演示把页面放在独立滚动区域内,便于反复观察两个状态。点击“查看胶囊”可直接滚动到收缩状态。

Scroll-aware capsule向下滚动,观察全宽导航收缩为玻璃胶囊。

Interaction study · 01

One surface,
two spatial states.

顶部状态让导航融入首屏;滚动状态缩短宽度、增加高度,并显示稳定的玻璃表面。

02

Overview

导航只维护一个布尔状态,视觉变化全部交给 CSS。

03

Anatomy

外层控制位置与宽度,内层 nav 只负责品牌、链接和操作。

04

Motion

双阈值避免临界滚动位置反复切换,requestAnimationFrame 合并高频读取。

05

Verification

滚动超过 30px 后进入胶囊状态,回到 8px 内恢复展开状态。

最终结构

实现由三层组成:

  1. shell 固定 Navbar 的页面位置,并提供外部留白;
  2. Navbar 根节点负责宽度、高度和内容布局;
  3. surface → surfaceClip → backdrop 形成稳定的玻璃背景和圆角裁剪。
text
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 即可。

tsx
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

css
.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

text
展开状态 -- scrollY > 30 --> 胶囊状态
展开状态 <-- scrollY <= 8 -- 胶囊状态

8px30px 是稳定区。Navbar 进入胶囊状态后,必须滚动回 8px 以内才恢复;因此滚动位置在 30px 附近轻微波动时不会反复切换。

监听器使用 {passive: true},并通过 requestAnimationFrame 把同一绘制周期中的多次 Scroll Event 合并为一次读取。组件卸载时同时移除 Listener 和待执行 Frame。

玻璃表面的稳定裁剪

玻璃层没有直接绑定到不断改变宽度的内容节点。backdrop 保持接近视口宽度,surfaceClip 使用 overflow: hidden 和继承圆角,只显示 Navbar 当前范围内的部分。

text
稳定的 backdrop
      ↓
圆角 surfaceClip 裁剪
      ↓
可见玻璃胶囊随 Navbar 宽度变化

这种结构可以避免宽度动画期间模糊背景的采样平面跟着剧烈缩放。surfaceClip::before 提供玻璃底色,surfaceClip::after 绘制内高光和细边框,真正的页面背景模糊由 backdrop-filter 完成。

页面内容需要预留顶部空间

Navbar 使用 position: fixed,不参与文档流。首屏应留出至少 Navbar 展开高度加外部 Padding 的空间,例如:

css
main {
  padding-top: 6rem;
}

如果首屏本来就有较大的顶部留白,可以不额外增加占位元素。不要在滚动状态变化时修改正文的 padding-top,否则会产生 Layout Shift。

移动菜单接入点

完整产品可以在 .action 的位置渲染 Menu Button,并在移动端隐藏 .links。Menu Button 至少需要暴露:

tsx
<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 表面,不应成为移动菜单状态的第二个来源。

验证清单

  1. 页面顶部时 Navbar 透明、宽幅且没有玻璃阴影。
  2. scrollY > 30 后只切换一次 data-scrolled="true"
  3. 返回 scrollY <= 8 后恢复展开状态。
  4. 8px–30px 区间内保持当前状态,没有临界闪烁。
  5. 收缩前后品牌、链接和操作保持同一 DOM,不重新挂载。
  6. 宽度与高度变化不推动正文,不产生 Layout Shift。
  7. 390px 和桌面宽度下没有水平溢出。
  8. 键盘焦点在透明状态与玻璃状态下都清晰可见。
  9. prefers-reduced-motion: reduce 下状态立即切换,不播放尺寸动画。
  10. 组件卸载后 Scroll Listener 与 Animation Frame 均被清理。

参考资料

本文共 1842 字,创建于 Sep 18, 2026

相关标签:Frontend, React, UI, CSS, ByAI

博客助手

正在打开博客助手…