wui
组件

颗粒纹理遮罩 Grain Overlay

利用 SVG 分形噪点(feTurbulence)滤镜为卡片、大图与渐变背景增添电影级胶片质感与材质温度的纹理组件。

基础用法

在摄影封面上叠加胶片颗粒,并开启 animated 让颗粒像放映中的胶片一样跳动:

Loading…

安装与引入

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

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

import * as React from "react"

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

export interface GrainOverlayProps extends Omit<
  React.ComponentProps<"svg">,
  "opacity"
> {
  /** Grain opacity. @default 0.16 */
  opacity?: number
  /** Base turbulence frequency. @default 0.72 */
  frequency?: number
  /** Number of fractal noise octaves. @default 3 */
  octaves?: number
  /** Deterministic noise seed. @default 8 */
  seed?: number
  /** CSS blend mode used by the overlay. @default "soft-light" */
  blendMode?: React.CSSProperties["mixBlendMode"]
  /** Jitter the grain like projected film. The parent must clip overflow. @default false */
  animated?: boolean
  /** Grain frames per second when `animated` is enabled. @default 10 */
  fps?: number
}

/** Adds a scalable SVG fractal-noise texture over a positioned surface. */
function GrainOverlay({
  opacity = 0.16,
  frequency = 0.72,
  octaves = 3,
  seed = 8,
  blendMode = "soft-light",
  animated = false,
  fps = 10,
  className,
  style,
  ...props
}: GrainOverlayProps) {
  const svgRef = React.useRef<SVGSVGElement>(null)
  const filterId = `grain-${React.useId().replaceAll(":", "")}`

  React.useEffect(() => {
    const svg = svgRef.current
    if (!animated || !svg) return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return

    // The oversized layer is shifted between random offsets; the noise itself is rasterized once.
    const timer = window.setInterval(() => {
      const x = (Math.random() * 2 - 1) * 10
      const y = (Math.random() * 2 - 1) * 10
      svg.style.transform = `translate3d(${x}%, ${y}%, 0)`
    }, 1000 / fps)

    return () => window.clearInterval(timer)
  }, [animated, fps])

  return (
    <svg
      ref={svgRef}
      aria-hidden="true"
      data-slot="grain-overlay"
      className={cn(
        "pointer-events-none absolute inset-0 size-full select-none",
        className
      )}
      style={{
        ...(animated && {
          inset: "-50%",
          width: "200%",
          height: "200%",
          willChange: "transform",
        }),
        ...style,
        opacity,
        mixBlendMode: blendMode,
      }}
      {...props}
    >
      <filter id={filterId} x="-20%" y="-20%" width="140%" height="140%">
        <feTurbulence
          type="fractalNoise"
          baseFrequency={frequency}
          numOctaves={octaves}
          seed={seed}
          stitchTiles="stitch"
        />
        <feColorMatrix type="saturate" values="0" />
      </filter>
      <rect width="100%" height="100%" filter={`url(#${filterId})`} />
    </svg>
  )
}

export { GrainOverlay }

属性 Props

属性类型默认值说明
opacitynumber0.16噪点纹理的整体不透明度(0 到 1 之间)。
frequencynumber0.72SVG feTurbulence 的基底噪声频率。数值越高,颗粒越细腻紧密。
octavesnumber3分形噪声的倍频层数,层数越多细节越丰富。
seednumber8生成随机噪点的固定种子数,保证渲染结果一致确定。
blendModeReact.CSSProperties['mixBlendMode']"soft-light"CSS 混合模式(如 'soft-light'、'overlay'、'multiply'、'screen' 等)。
animatedbooleanfalse是否让颗粒逐帧跳动。实现方式是平移一张放大的噪点层,噪点本身只光栅化一次;父元素需要 `overflow-hidden`。
fpsnumber10`animated` 开启时颗粒的跳动帧率。8–12 帧最接近胶片质感。
classNamestring—应用于 SVG 纹理图层的 CSS 类名(默认包含 absolute inset-0 size-full pointer-events-none)。

事件 Events

该组件为纯装饰 SVG 滤镜遮罩,不包含业务交互事件。

使用场景与设计规范

GrainOverlay 适用于暗色模式渐变消除色阶断层、复古设计风、高阶品牌宣传卡片:

  • 解决 CSS 渐变色带问题(Color Banding):在暗色渐变(如从 #09090b 到 #18181b)中,人眼很容易察觉到阶梯状断层,叠加一层 opacity={0.18} 的噪点即可让过渡变得无比顺滑自然。
  • 纯矢量计算:基于 SVG 原生滤镜,文件体积近乎为零,不需要额外加载几十 KB 的 PNG 噪点贴图。

场景示例

强度对比

同一纯色表面上,不同不透明度的 soft-light 颗粒带来的质感差异:

Loading…

无障碍与交互 Accessibility

  • 绝对隔离:默认具备 pointer-events-none 与 user-select: none,绝不干扰用户选中卡片文字或点击按钮。
  • 无障碍树忽略:内置 aria-hidden="true",屏幕阅读器完全忽略该装饰图层。
  • 动效减弱适配:系统开启“减少动态效果”时,animated 不会启动,颗粒保持静止。