# 遮罩揭开 Mask Reveal > 纯色层上挖出一个大字,往下滚时字越来越大,直到透出后面的整幅画面。 分类:动效 · 角色:主角(占一整块、抢全部注意力,一页最多一个) [全部组件和流程](https://www.dachuiai.com/llms.txt) · [动效选型规则](https://www.dachuiai.com/guides/motion.md) ## 给 AI:用户把这个组件发给了你 1. 先看用户的项目:框架、页面类型、现在的风格、手机还是电脑为主。 2. 对照下面的「适合 / 不适合」判断合不合适。不合适就直接说,并从[动效选型规则](https://www.dachuiai.com/guides/motion.md)或[全部组件](https://www.dachuiai.com/llms.txt)里另挑一个。 3. 合适就按「装进项目」装好,示例里的文字和图片换成用户自己的。 4. 告诉用户用了什么、放在哪个文件、以后想改快慢、颜色、大小改哪个参数。 ## 什么时候用 - 适合:品牌感强、信息不急的首屏:手上有一张好图,再配一个短英文词(品牌名、主题词),先给感觉再给信息。 - 不适合:要第一眼看到标题、按钮、地址的页面(门店、电商、SaaS 注册页,标题和按钮要滚过七成才出来);中文品牌名;没有好图。 - 适合的网站:官网、作品集、活动页 - 放在:首屏、转场 - 手机上:好用:竖屏放不下整个字时自动缩小、居中;height 写 100svh 正好一屏。 - 性能代价:低:只缩放一个 SVG 遮罩。 - 常搭配:[滚动出现 Scroll Reveal](https://www.dachuiai.com/c/scroll-reveal.md)、[速度跑马灯 Velocity Marquee](https://www.dachuiai.com/c/velocity-marquee.md) ## 装进项目 - 写法:React 18 以上 + TypeScript + Tailwind CSS v4,一个文件就能用,不引用别的组件。 - 依赖:`npm i lucide-react` - 文件:原样存成 `mask-reveal.tsx`(比如 `src/components/mask-reveal.tsx`)。能跑命令就下载[原文](https://www.dachuiai.com/code/mask-reveal.tsx),比手抄准:`curl -fsSL -o src/components/mask-reveal.tsx https://www.dachuiai.com/code/mask-reveal.tsx`(Windows 写 `curl.exe`)。下载完看一眼第一行是 `import`,不是网页。 - 导出:`MaskReveal` 是可以直接用的部分;最后的 `export default` 是演示示例,只作参考。 - 图片:示例用了本站的 [/art/ocean.jpg](https://www.dachuiai.com/art/ocean.jpg)。换成用户自己的图;只想先看效果,可以从本站同样的路径下载,放进项目的 public 目录。 - 注意:整页用时 scroller 不传,进度按这一段在页面里的位置算,放在页面中间也行;放在可滚动的盒子里时传这个盒子。首屏写 height="100svh",length 调要滚几屏才揭开(默认 3.2)。大字的三段位置按中间那个 I 的宽度写死:pivot 只能用 I 或 l,左右两段字母要差不多宽(SIP、MILK),中文名放到揭开后的标题里。 - 钉住效果靠 `position: sticky`:外面任何一层写了 `overflow: hidden` 或 `overflow-x: hidden`(常见在 body、main 上)都会失效,要防横向滚动条改用 `overflow-x: clip`。 - 保留代码里 `prefers-reduced-motion` 的处理:用户开了「减弱动态效果」时内容照样完整显示。 - Next.js(有 app 文件夹):文件第一行加 `'use client';`。 - 用 JavaScript 不用 TypeScript:Vite 和 Next.js 都能直接用 .tsx 文件;也可以删掉类型标注,改名 .jsx。 - 没有 Tailwind:先装 Tailwind CSS v4(Vite 项目 `npm i tailwindcss @tailwindcss/vite`,vite 配置的 plugins 里加 `tailwindcss()`,入口引入的那个 CSS 文件(一般是 `src/index.css`)第一行写 `@import "tailwindcss";`),或者把类名改写成项目用的样式。 - 已经装了 Tailwind v3:别为了一个组件升级。类名大多通用,少数 v4 写法要换,比如焦点框 `outline-2` 在 v3 里要写成 `outline outline-2`。 - 不是 React(Vue、Svelte、小程序、纯 HTML):照着代码的做法改写,时长、曲线、参数保持一样。Wix、有赞这类建站平台装不进代码,告诉用户去平台自带的动效设置里找。 ## 参数 | 参数 | 默认值 | 作用 | | --- | --- | --- | | `before` | | 大字左边那段 | | `pivot` | | 中间放大的字母,只能用 I 或 l | | `after` | | 大字右边那段 | | `image` | | 揭开后的画面 | | `cover` | `'#F4F1EA'` | 盖在上面的纯色 | | `ink` | `'#111'` | 向下小箭头的颜色 | | `maxScale` | `46` | 最后放大到几倍 | | `children` | | 包在里面的内容 | | `height` | `620` | 画面高,数字是像素,也可以写 '100svh' | | `length` | `3.2` | 外层盒子是几个画面高,越大滚得越久 | | `scroller` | | 放在可滚动的盒子里时传这个盒子 | ## 你可以说 > 首屏做成 Mask Reveal 遮罩揭开:纯色背景上挖出一个大字,透出后面的画面;往下滚时大字越来越大,直到画面铺满,再接正文。 ## 完整代码 ```tsx import {useEffect, useId, useLayoutEffect, useRef, useState, useSyncExternalStore, type ReactNode, type RefObject} from 'react'; import {ChevronDown} from 'lucide-react'; /* --------------------------------------------------------------- * MaskReveal 遮罩揭开 * 一个大字越滚越大,把后面的画面露出来。很适合首屏转到正文。 * 做法:盖在最上面的纯色层里挖一个字(SVG mask),滚动时把字放大。 * 放大的中心点要落在字的笔画上(这里是 I 的竖线),放到够大时, * 这一笔就把整个屏幕都"挖空"了,纯色层自然消失。 * 外层盒子比画面高,里面一层 sticky 钉住,滚完这一段正好揭开。 * 竖屏放不下整个字时自动缩小;开了「减弱动态效果」直接显示揭开后的画面。 * before:大字左边那段;pivot:中间放大的字母,只能用 I 或 l;after:大字右边那段。 * image:揭开后的画面;cover:盖在上面的纯色;ink:向下小箭头的颜色;maxScale:最后放大到几倍。 * height:画面高,数字是像素,也可以写 '100svh';length:外层盒子是几个画面高,越大滚得越久。 * scroller:放在可滚动的盒子里时传这个盒子;整页滚动时不传。 * ------------------------------------------------------------- */ type Props = {before: string; pivot: string; after: string; image: string; cover?: string; ink?: string; maxScale?: number; children?: ReactNode; height?: number | string; length?: number; scroller?: RefObject}; const REDUCE = '(prefers-reduced-motion: reduce)'; const watchReduce = (cb: () => void) => { const mq = matchMedia(REDUCE); mq.addEventListener('change', cb); return () => mq.removeEventListener('change', cb); }; /** 这一段滚了多少(0–1):外层盒子顶到滚动区顶开始,钉住的画面滚完为止 */ function useProgress(outer: RefObject, panel: RefObject, scroller: RefObject | undefined, off: boolean) { const [p, setP] = useState(0); useEffect(() => { if (off) return; const el = scroller?.current ?? null, target = el ?? window; let raf = 0, cur = -1, goal = 0; const tick = () => { cur = cur < 0 ? goal : cur + (goal - cur) * 0.16; // 第一次直接到位:刷新时停在中间也对 if (Math.abs(goal - cur) < 0.0005) cur = goal; setP(cur); raf = cur === goal ? 0 : requestAnimationFrame(tick); }; const read = () => { const top = outer.current!.getBoundingClientRect().top - (el ? el.getBoundingClientRect().top : 0); const range = outer.current!.offsetHeight - panel.current!.offsetHeight; goal = range > 0 ? Math.min(1, Math.max(0, -top / range)) : 0; if (!raf) raf = requestAnimationFrame(tick); }; read(); target.addEventListener('scroll', read, {passive: true}); addEventListener('resize', read); return () => { target.removeEventListener('scroll', read); removeEventListener('resize', read); cancelAnimationFrame(raf); }; }, [outer, panel, scroller, off]); return off ? 1 : p; } export function MaskReveal({before, pivot, after, image, cover = '#F4F1EA', ink = '#111', maxScale = 46, children, height = 620, length = 3.2, scroller}: Props) { const id = useId().replace(/:/g, ''); const outer = useRef(null), panel = useRef(null), word = useRef(null); const reduce = useSyncExternalStore(watchReduce, () => matchMedia(REDUCE).matches, () => false); const p = useProgress(outer, panel, scroller, reduce); const t = Math.min(1, p / 0.72); // 前 72% 的滚动用来放大 const scale = Math.exp(t * t * Math.log(maxScale)); // 指数放大,看起来才是匀速冲进去 const covered = t < 1; const after01 = Math.min(1, Math.max(0, (p - 0.72) / 0.2)); const font = {fontFamily: '"Helvetica Neue", Arial, "PingFang SC", sans-serif', fontWeight: 900, fontSize: 250, letterSpacing: '-.02em'}; const h = typeof height === 'number' ? `${height}px` : height; // 画面够宽时和 slice 一样铺满;竖屏放不下整个字时,把字缩小居中,左右各留 8% const [view, setView] = useState('0 0 1000 620'); useLayoutEffect(() => { if (!covered) return; const fit = () => { const box = panel.current?.getBoundingClientRect(), b = word.current?.getBBox(); if (!box?.width || !box.height || !b?.width) return; const slice = Math.max(box.width / 1000, box.height / 620), s = Math.min(slice, (box.width * 0.84) / b.width); const cx = s < slice ? b.x + b.width / 2 : 500; setView(`${cx - box.width / s / 2} ${310 - box.height / s / 2} ${box.width / s} ${box.height / s}`); }; fit(); const ro = new ResizeObserver(fit); ro.observe(panel.current!); return () => ro.disconnect(); }, [covered, before, pivot, after]); const letters = ( <> {before} {pivot} {after} ); return (
{covered && ( {letters} {letters} )}
0.5 ? 'auto' : 'none'}}>{children}
); } /* ---------------- 示例:从一个词潜进深海 ---------------- */ export default function MaskRevealDemo() { const box = useRef(null); return (

海面之下 200 米

光线在这里只剩下 1%,但所有声音都安静下来。

开始下潜
); } ```