wui
组件

滚动展开 Scroll Expand

随着滚动进度将画框中的媒体内容平滑展开至全屏无缝铺满(或从全屏收缩为卡片)的沉浸式视差组件。

第三方依赖 · motion

基础用法

向下滚动,带有圆角与内边距的画框媒体将平滑扩展为铺满视口边缘的全画幅大图:

Loading…

安装与引入

通过 CLI 自动添加组件,或手动复制源码至项目中:

pnpm dlx @wui-design/cli@latest add @wui/scroll-expand
安装基础依赖与动效库
pnpm add motion lucide-react class-variance-authority clsx tailwind-merge
复制组件源码到 components/ui/scroll-expand.tsx
components/ui/scroll-expand.tsx
"use client"

import * as React from "react"
import {
  easeInOut,
  motion,
  useReducedMotion,
  useScroll,
  useSpring,
  useTransform,
  type HTMLMotionProps,
} from "motion/react"

import { cn } from "@/lib/utils"

export interface ScrollExpandProps extends Omit<
  HTMLMotionProps<"section">,
  "children"
> {
  /** Media or hero content that expands or collapses in the pinned viewport. */
  children: React.ReactNode
  /** Scroll direction of the visual transformation. @default "expand" */
  direction?: "expand" | "collapse"
  /** Initial inset on every edge, as a percentage. @default 10 */
  inset?: number
  /** Corner radius at the compact state in pixels. @default 28 */
  radius?: number
  /** Section length in viewport heights. @default 1.8 */
  scrollLength?: number
  /** Scale of the inner content at the compact state, creating a subtle zoom as it expands. @default 1.08 */
  contentScale?: number
  /** Ease scroll progress through a spring so the frame settles softly. @default true */
  smooth?: boolean
  /** Scrollable element to observe instead of the page. */
  container?: React.RefObject<HTMLElement | null>
  /** Classes applied to the sticky viewport. */
  viewportClassName?: string
  /** Classes applied to the transformed content frame. */
  frameClassName?: string
}

function getViewportHeight(container?: HTMLElement | null) {
  if (!container) return window.innerHeight
  const style = window.getComputedStyle(container)
  return (
    container.clientHeight -
    parseFloat(style.paddingTop) -
    parseFloat(style.paddingBottom)
  )
}

/** Pins media while scroll progress expands it to full bleed or collapses it away. */
function ScrollExpand({
  children,
  direction = "expand",
  inset = 10,
  radius = 28,
  scrollLength = 1.8,
  contentScale = 1.08,
  smooth = true,
  container,
  className,
  viewportClassName,
  frameClassName,
  style,
  ...props
}: ScrollExpandProps) {
  const sectionRef = React.useRef<HTMLElement>(null)
  const [viewportHeight, setViewportHeight] = React.useState(0)
  const reduceMotion = useReducedMotion()
  const { scrollYProgress } = useScroll({
    target: sectionRef,
    container,
    offset: ["start start", "end end"],
  })
  const springProgress = useSpring(scrollYProgress, {
    stiffness: 160,
    damping: 30,
    mass: 0.35,
    restDelta: 0.0005,
  })
  const progress = smooth ? springProgress : scrollYProgress
  const compactClip = `inset(${inset}% round ${radius}px)`
  const expandedClip = "inset(0% round 0px)"
  const clipPath = useTransform(
    progress,
    [0, 1],
    direction === "expand"
      ? [compactClip, expandedClip]
      : [expandedClip, compactClip],
    { ease: easeInOut }
  )
  const scale = useTransform(
    progress,
    [0, 1],
    direction === "expand" ? [contentScale, 1] : [1, contentScale],
    { ease: easeInOut }
  )

  React.useLayoutEffect(() => {
    const measure = () => {
      setViewportHeight(getViewportHeight(container?.current))
    }
    measure()
    const observer = new ResizeObserver(measure)
    if (container?.current) observer.observe(container.current)
    window.addEventListener("resize", measure)
    return () => {
      observer.disconnect()
      window.removeEventListener("resize", measure)
    }
  }, [container])

  return (
    <motion.section
      ref={sectionRef}
      data-slot="scroll-expand"
      data-direction={direction}
      className={cn("relative", className)}
      style={{
        ...style,
        height: viewportHeight
          ? viewportHeight * scrollLength
          : `${scrollLength * 100}vh`,
      }}
      {...props}
    >
      <div
        data-slot="scroll-expand-viewport"
        className={cn("sticky top-0 overflow-hidden", viewportClassName)}
        style={{ height: viewportHeight || "100vh" }}
      >
        <motion.div
          data-slot="scroll-expand-frame"
          className={cn("h-full w-full overflow-hidden", frameClassName)}
          style={{
            clipPath: reduceMotion ? expandedClip : clipPath,
          }}
        >
          <motion.div
            data-slot="scroll-expand-content"
            className="h-full w-full will-change-transform"
            style={{ scale: reduceMotion ? 1 : scale }}
          >
            {children}
          </motion.div>
        </motion.div>
      </div>
    </motion.section>
  )
}

export { ScrollExpand }

属性 Props

属性类型默认值说明
childrenReact.ReactNode—在视口中展开或收缩的视频、高分辨率图片或沉浸式 Hero 内容。
direction"expand" | "collapse""expand"变换方向。`expand` 表示随向下滚动由紧凑卡片展开为满屏;`collapse` 表示由满屏收缩为卡片。
insetnumber10紧凑初始状态下画框四周的内缩百分比(%)。
radiusnumber28紧凑状态下画框的圆角大小(像素)。在完全展开态下会自动平滑过渡至 0px。
scrollLengthnumber1.8动画占用的总滚动距离(以当前视口高度的倍数计算,例如 1.8 代表 1.8 个视口高度)。
contentScalenumber1.08紧凑状态下内部内容的缩放倍率,展开过程中回到 1,形成轻微的推近感。
smoothbooleantrue通过弹簧缓动滚动进度,并对裁切与缩放应用 easeInOut 曲线,让展开起止更柔和。
containerReact.RefObject<HTMLElement | null>—局部滚动容器的 ref 引用(不传时监听整个全局页面滚动)。
viewportClassNamestring—应用于吸顶固定视口容器(Sticky Viewport)的 CSS 类名。
frameClassNamestring—应用于由 clip-path 驱动的裁切画框的 CSS 类名。
classNamestring—应用于外层占位容器的 CSS 类名。

使用场景与设计规范

ScrollExpand 适用于大片级产品宣传、全景摄影展示、旗舰设备发布会网页:

  • 从精致卡片过渡到震撼大图:用户在阅读前置文字时处于克制的信息接收状态,一旦开始滚动,画框向四周炸开铺满,带来强烈的沉浸视觉冲击。
  • 高性能 Clip-path 裁切:使用 CSS clip-path: inset(...) 与 GPU 硬件加速缩放,避免修改 DOM 物理宽高带来的重排(Reflow)性能损耗。

场景示例

逆向收缩为卡片(Collapse)

从全幅封面随滚动收缩为画框,把注意力交还给后续正文:

Loading…

无障碍与交互 Accessibility

  • 减少动效环境处理:当系统启用 prefers-reduced-motion: reduce 时,clip-path 将锁定为直接全铺状态,且取消任何缩放变换,保证视力敏感用户顺畅浏览。
  • 内容文字可访问:覆盖在大图之上的标题与文案依然使用标准语义标签(如 <h1>、<p>),屏幕阅读器能准确提取全部语义。