mask-reveal-transition

Compare original and translation side by side

🇺🇸

Original

English
🇨🇳

Translation

Chinese

Skill: mask-reveal-transition

Skill: mask-reveal-transition

Component vanilla HTML/CSS/JS — ảnh lộ ra qua một mask hình dạng hữu cơ (vòng loang mực, tâm cố định, bán kính tăng dần) thay vì crossfade/opacity thường. Hai biến thể dùng chung một cơ chế nền: đo tiến trình cuộn TRONG section bằng
getBoundingClientRect()
, map về 0..1, rồi ghi bán kính loang lên CSS custom property.
基于 vanilla HTML/CSS/JS 的组件——图像通过有机形状的 mask 显露(墨迹飞溅斑块,中心固定,半径逐渐增大),而不是常见的 crossfade/opacity。这两个变体共享同一套底层机制:在 section 内使用
getBoundingClientRect()
测量滚动进度,映射到 0..1,再将斑块半径写入 CSS custom property。

WHAT

WHAT(是什么)

Purpose và context

目的与上下文

  • Purpose: lộ/chuyển ảnh qua mask hình dạng hữu cơ (vòng loang mực) theo tiến trình cuộn, hoặc pin split-screen sticky đổi ảnh qua mask theo panel đang đọc — ưu tiên code gốc nguyên văn ở
    Rheinmir/uiux-asset
    ,
    assets/
    là bản vanilla nhẹ dự phòng.
  • Trigger (when to use):
    • Chuyển ảnh nghệ thuật/portfolio kiểu "vết mực loang" đồng bộ theo cuộn (không phải crossfade phẳng).
    • Layout case-study/portfolio 2 cột: ảnh cố định bên trái, đoạn văn cuộn bên phải, ảnh đổi theo đoạn đang đọc.
  • Non-goals: KHÔNG dùng cho reveal đơn giản (mờ + trượt nhẹ khi vào viewport) — đó là
    skills/scroll-effects
    pattern #1, không có mask hình dạng. KHÔNG dùng cho chuyển ảnh toàn màn hình kiểu zoom-blur nặng GPU — đó là
    skills/blur
    (Three.js/shader). Skill này chỉ CSS
    mask-image
    , không canvas/WebGL.
  • 目的: 根据滚动进度,通过有机形状的 mask(墨迹斑块)显露或切换图像,或在阅读中的 active panel 变化时,为 pinned split-screen sticky 布局通过 mask 切换图像——优先使用
    Rheinmir/uiux-asset
    中的原版代码,
    assets/
    是轻量级 vanilla 备用版本。
  • 触发条件(何时使用):
    • 艺术化或 portfolio 图像切换,呈现与滚动同步的“墨迹飞溅”效果(不是平面 crossfade)。
    • 两列 case-study/portfolio 布局:图像固定在左侧,正文在右侧滚动,并根据当前阅读的段落切换图像。
  • 非目标: 不用于简单 reveal(进入 viewport 时淡入并轻微滑动)——那是
    skills/scroll-effects
    的 pattern #1,不包含形状 mask。也不用于全屏重度 GPU zoom-blur 图像切换——那是
    skills/blur
    (Three.js/shader)。本 skill 仅使用 CSS
    mask-image
    ,不使用 canvas/WebGL。

Mental model

心智模型

section → đo progress cuộn (getBoundingClientRect → 0..1) hoặc panel active (IntersectionObserver) → --mrt-r → mask-image radial-gradient trên ảnh "trên" → lỗ trong suốt loang rộng → lộ ảnh "dưới"
. Hai biến thể:
[data-mrt-blob]
(ghi
--mrt-r
mỗi frame, không transition) và
[data-mrt-split]
(đổi rời rạc theo panel, có transition qua
@property
).
section → 测量滚动进度(getBoundingClientRect → 0..1)或 active panel(IntersectionObserver)→ --mrt-r → 对“上层”图像应用 mask-image radial-gradient → 透明斑块空洞 → 显露“下层”图像
。两个变体:[data-mrt-blob](每帧写入
--mrt-r
,不设置 transition)和 [data-mrt-split](根据 panel 离散切换,通过
@property
实现 transition)。

Input và output contract

输入与输出约定

FieldRequired?Ý nghĩa
Inảnh (2 ảnh cho blob; mỗi panel một ảnh cho split)cónội dung thay vào bản gốc/asset
Inràng buộc dự án có được kéo lib/file ngoài khôngkhông (mặc định: được)không được → dùng
assets/
vanilla
Outtrang/component HTML có hiệu ứng mask revealcóchạy qua HTTP local, có
-webkit-mask-image
, có nhánh
prefers-reduced-motion
Outbằng chứng playwrightcóảnh trước/giữa/sau (biến thể 1), assert đổi ảnh + pin (biến thể 2)
字段必需?含义
In图像(blob 需要 2 张;split 的每个 panel 各 1 张)是用于替换原版或 asset 中的内容
In项目是否允许引入外部库或文件否(默认:允许)不允许 → 使用
assets/
vanilla 版本
Out带 mask reveal 效果的页面或 component HTML是通过本地 HTTP 运行,包含
-webkit-mask-image
,并具有
prefers-reduced-motion
分支
Outplaywright 验证证据是前、中、后截图(变体 1),断言图像切换和 pin 效果(变体 2)

Rules và capabilities

规则与能力

  • RULE-01 (MUST): Hỗ trợ trình duyệt
    mask-image
    : Safari (kể cả Safari hiện đại) chỉ nhận
    -webkit-mask-image
    /
    -webkit-mask-repeat
    — PHẢI viết cả
    mask-image
    chuẩn LẪN
    -webkit-mask-image
    (đã có sẵn trong CSS asset, đừng xoá prefix khi adapt/minify). Thiếu prefix = ảnh trên không bao giờ có lỗ hổng trên Safari, coi như hiệu ứng không chạy.
  • RULE-02 (MUST):
    position: sticky
    không hoạt động nếu BẤT KỲ phần tử cha nào (kể cả xa, lên tới
    <body>
    ) có
    overflow: hidden
    /
    auto
    /
    scroll
    hoặc
    clip
    — đây là lỗi hay gặp nhất khi nhúng
    .mrt-split-sticky
    vào layout có sẵn (nhiều trang đặt
    overflow: hidden
    trên wrapper để chặn scroll ngang, vô tình giết sticky của mọi phần tử con). Kiểm tra toàn bộ chuỗi cha trước khi báo "sticky không chạy" là bug.
  • RULE-03 (MUST): Biến thể 2 dùng
    @property --mrt-r
    để trình duyệt animate được custom property nằm trong
    calc()
    /gradient — thiếu khai báo này, đổi
    --mrt-r
    bằng JS sẽ nhảy tức thì thay vì mượt qua transition.
  • RULE-04 (MUST):
    prefers-reduced-motion: reduce
    → cả 2 biến thể set
    --mrt-r
    = bán kính tối đa ngay lập tức (lộ ảnh hoàn toàn), tắt animate/transition — đã code sẵn trong JS/CSS, đừng xoá nhánh này khi adapt.
  • RULE-05 (MUST): Một scroll listener rAF-throttle DUY NHẤT cho mọi
    [data-mrt-blob]
    trên trang; một
    IntersectionObserver
    DUY NHẤT mỗi
    [data-mrt-split]
    cho các panel của nó — không gắn nhiều listener
    scroll
    rời hoặc nhiều observer trùng vai trò.
  • RULE-06 (MUST): Verify bằng
    playwright-verify
    : chụp trước/giữa/sau khi cuộn qua biến thể 1 để thấy vòng loang lớn dần, và assert biến thể 2 đổi đúng ảnh khi mỗi panel vào giữa khung nhìn + cột ảnh thực sự pin (không cuộn theo trang) trong lúc đọc.
  • Capabilities: đọc/copy file từ repo asset ngoài hoặc
    assets/
    của skill; ghi file HTML/CSS/JS dự án; chạy trình duyệt headless để verify.
  • RULE-01 (MUST):支持
    mask-image
    的浏览器。
    Safari(包括现代 Safari)只识别
    -webkit-mask-image
    /
    -webkit-mask-repeat
    ——必须同时编写标准
    mask-image
    和
    -webkit-mask-image
    (CSS asset 中已经包含,适配或压缩时不要删除 prefix)。缺少 prefix 时,上层图像在 Safari 上永远不会出现空洞,应视为效果未运行。
  • RULE-02 (MUST):
    position: sticky
    无法正常工作的情况:
    任何父级元素(无论距离多远,直至
    <body>
    )设置了
    overflow: hidden
    /
    auto
    /
    scroll
    或
    clip
    ,
    position: sticky
    都会失效——这是将
    .mrt-split-sticky
    嵌入现有布局时最常见的错误(许多页面会在 wrapper 上设置
    overflow: hidden
    来阻止横向滚动,却无意中让所有子元素的 sticky 失效)。在将问题判定为 bug 之前,必须检查整条父级链。
  • RULE-03 (MUST):变体 2 使用
    @property --mrt-r
    ,使浏览器能够对位于
    calc()
    或 gradient 内的 custom property 进行动画;缺少此声明时,通过 JavaScript 修改
    --mrt-r
    会立即跳变,而不是平滑 transition。
  • RULE-04 (MUST):当
    prefers-reduced-motion: reduce
    时,两个变体都应立即将
    --mrt-r
    设置为最大半径(完整显露图像),并关闭 animate/transition——JavaScript/CSS 中已经实现此分支,适配时不要删除。
  • RULE-05 (MUST):页面上所有
    [data-mrt-blob]
    只能使用一个经过 rAF 节流的 scroll listener;每个
    [data-mrt-split]
    的 panels 只能使用一个
    IntersectionObserver
    ——不要添加多个独立 scroll listener 或多个职责重复的 observer。
  • RULE-06 (MUST):使用
    playwright-verify
    进行验证:滚动经过变体 1 时截取前、中、后画面,确认斑块逐渐增大;断言变体 2 在每个 panel 进入视口中央时切换到正确图像,并确认阅读期间图像列真正固定在原位、不随页面滚动。
  • 能力:从外部 repo asset 或本 skill 的
    assets/
    中读取或复制文件;写入项目的 HTML/CSS/JS 文件;运行 headless 浏览器进行验证。

Failure boundaries

失败边界

  • Hiệu ứng không chạy trên Safari → thiếu
    -webkit-mask-image
    → sửa prefix, không kết luận là bug khác.
  • Sticky không pin → rà toàn bộ chuỗi cha tìm
    overflow
    /
    clip
    trước khi báo bug.
  • Mở bằng
    file://
    → không hợp lệ; chạy lại qua HTTP.
  • Yêu cầu thực ra là reveal opacity/translate hoặc chuyển ảnh shader toàn màn hình → không thuộc skill, chuyển
    skills/scroll-effects
    hoặc
    skills/blur
    .
  • 效果在 Safari 上未运行 → 缺少
    -webkit-mask-image
    → 补上 prefix,不要归结为其他 bug。
  • Sticky 未固定 → 检查整条父级链中的
    overflow
    /
    clip
    ,然后再报告 bug。
  • 通过
    file://
    打开 → 无效;改用 HTTP 重新运行。
  • 实际需求是 opacity/translate reveal 或全屏 shader 图像切换 → 不属于本 skill;改用
    skills/scroll-effects
    或
    skills/blur
    。

HOW

HOW(如何实现)

Main workflow

主工作流

StepTypeInputsActionOutputs/exitFailure/next
W01judgmentyêu cầuChọn biến thể (blob hay split) và nguồn: bản gốc (mặc định) hay
assets/
vanilla
lựa chọnkhông được kéo lib ngoài → B01
W02effectpen gốcMở link chạy xem đúng hiệu ứng → copy nguyên file
.html
(+
_assets/
) → giữ comment ghi công → chỉ sửa nội dung/ảnh/màu
file trong dự án—
W03deterministictrangChạy local qua HTTP, verify bằng
playwright-verify
ảnh + assertlỗi Safari/sticky → xem Failure boundaries, sửa, lặp
Chi tiết từng bước (nguồn chân lý cho W01–W03):
步骤类型输入操作输出或退出条件失败或下一步
W01judgment需求选择变体(blob 或 split)以及来源:原版(默认)或
assets/
vanilla 版本
选择结果不能引入外部库 → B01
W02effect原版 Pen打开链接并运行以查看正确效果 → 复制完整
.html
文件(以及
_assets/
)→ 保留署名注释 → 仅修改内容、图像和颜色
项目中的文件—
W03deterministic页面通过本地 HTTP 运行,使用
playwright-verify
验证
截图和断言Safari/sticky 错误 → 查看失败边界,修复后重试
每个步骤的详细信息(W01–W03 的事实来源):

Bản gốc — dùng TRƯỚC (copy nguyên file, đừng viết lại)

原版 Pen——优先使用(复制完整文件,不要重写)

Code gốc NGUYÊN VĂN của tác giả (CodePen public = MIT) nằm ở repo ngoài
Rheinmir/uiux-asset
— gallery chạy thật: https://rheinmir.github.io/uiux-asset/scroll-effects/ . Quy trình: mở link chạy để xem đúng hiệu ứng → copy nguyên file
.html
(+ thư mục
_assets/
nếu file trỏ tới) vào dự án → giữ comment ghi công dòng đầu → chỉ sửa nội dung/ảnh/màu. Chạy local qua HTTP, không
file://
.
git clone --depth 1 https://github.com/Rheinmir/uiux-asset
nếu cần cả bộ offline.
Pen gốc — tác giảLink
Ink Transition Scroll Effect — iamryanyuchạy · code
Pinned Split-Screen Mask Reveal — gridmorphicchạy · code
Parallax Jungle Leaves Reveal — Mamboleoochạy · code
assets/
trong skill này chỉ là bản rút gọn vanilla tự viết — dùng khi dự án KHÔNG được kéo lib ngoài; mặc định dùng bản gốc ở trên.
作者原封不动的原始代码(CodePen public = MIT)位于外部仓库
Rheinmir/uiux-asset
——实际运行 gallery:https://rheinmir.github.io/uiux-asset/scroll-effects/ 。
流程:打开链接并运行以查看正确效果 → 将完整
.html
文件(以及文件引用的
_assets/
文件夹)复制到项目中 → 保留首行署名注释 → 仅修改内容、图像和颜色。通过本地 HTTP 运行,不要使用
file://
。
如果需要完整的离线版本,可执行:
git clone --depth 1 https://github.com/Rheinmir/uiux-asset
原版 Pen——作者链接
Ink Transition Scroll Effect — iamryanyu运行 · 代码
Pinned Split-Screen Mask Reveal — gridmorphic运行 · 代码
Parallax Jungle Leaves Reveal — Mamboleoo运行 · 代码
本 skill 中的
assets/
只是自行编写的精简 vanilla 版本——仅在项目不允许引入外部库时使用;默认使用上述原版。

Assets

Assets

assets/demo.html
+
assets/mask-reveal-transition.css
+
assets/mask-reveal-transition.js
— đọc rồi ADAPT (đổi ảnh,
--mrt-edge
độ mềm viền, nội dung panel).
assets/demo.html
+
assets/mask-reveal-transition.css
+
assets/mask-reveal-transition.js
——读取后进行适配(更换图像、调整
--mrt-edge
边缘柔和度、修改 panel 内容)。

Branches

分支

IDKindGuardHành viSkip / failureRejoin
B01conditional_requireddự án KHÔNG được kéo lib/file ngoàidùng
assets/demo.html
+
.css
+
.js
, đọc rồi ADAPT (ảnh,
--mrt-edge
, nội dung panel)
—W03
B02user_optionalcần cả bộ offline
git clone --depth 1 https://github.com/Rheinmir/uiux-asset
—W02
ID类型条件行为跳过或失败汇合点
B01conditional_required项目不允许引入外部库或文件使用
assets/demo.html
、
.css
和
.js
,读取后进行适配(图像、
--mrt-edge
、panel 内容)
—W03
B02user_optional需要完整的离线版本
git clone --depth 1 https://github.com/Rheinmir/uiux-asset
—W02

Validation và stopping

验证与停止条件

Tất định (playwright): biến thể 1 chụp trước/giữa/sau khi cuộn thấy vòng loang lớn dần; biến thể 2 assert đổi đúng ảnh khi mỗi panel vào giữa khung nhìn + cột ảnh thực sự pin. Cần review: cảm giác "mực loang" đúng chiều (lộ dần, không che dần). Dừng khi các assert trên xanh.
确定性验证(playwright):变体 1 截取滚动前、中、后的画面,确认斑块逐渐增大;变体 2 断言每个 panel 进入视口中央时切换到正确图像,并确认图像列真正固定。人工检查:确认“墨迹飞溅”方向正确(逐步显露,而不是逐步遮盖)。上述断言全部通过后停止。

Examples

示例

  • Positive: portfolio case-study 2 cột "ảnh cố định trái, chữ cuộn phải" → copy nguyên
    pinned-split-screen-mask-reveal.html
    từ
    Rheinmir/uiux-asset
    , đổi ảnh panel → playwright assert mỗi
    [data-mrt-panel]
    vào giữa khung thì ảnh đổi và cột ảnh không cuộn theo trang.
  • Boundary/failure: nhúng
    .mrt-split-sticky
    vào layout có wrapper
    overflow: hidden
    → ảnh cuộn theo trang, không pin → rà chuỗi cha, bỏ
    overflow
    ở wrapper, không báo là bug của component. Minify xoá
    -webkit-mask-image
    → Safari không có lỗ hổng mask → trả lại prefix.
  • 正向示例: portfolio case-study 两列布局“左侧固定图像,右侧滚动文字” → 从
    Rheinmir/uiux-asset
    复制完整的
    pinned-split-screen-mask-reveal.html
    → 更换 panel 图像 → 使用 playwright 断言每个
    [data-mrt-panel]
    进入视口中央时图像发生切换,并且图像列不会随页面滚动。
  • 边界或失败案例: 将
    .mrt-split-sticky
    嵌入带
    overflow: hidden
    的 wrapper → 图像随页面滚动,无法固定 → 检查父级链,移除 wrapper 上的
    overflow
    ,不要将其报告为 component 的 bug。压缩代码时删除
    -webkit-mask-image
    → Safari 上不会出现 mask 空洞 → 恢复 prefix。

Reference — Cơ chế nền chung

参考——通用底层机制

Với mỗi section (
[data-mrt-blob]
hoặc
[data-mrt-split]
): 2 ảnh xếp lớp tuyệt đối trong cùng khung — ảnh dưới (z-index thấp, luôn hiện sẵn) và ảnh trên (z-index cao, mang mask). Ảnh trên có
css
mask-image: radial-gradient(circle at 50% 50%, transparent 0, transparent var(--mrt-r), black calc(var(--mrt-r) + var(--mrt-edge)), black 100%);
— vùng trong bán kính
--mrt-r
trong suốt (ảnh trên biến mất tại đó → lộ ảnh dưới), vùng ngoài
--mrt-r + --mrt-edge
vẫn đặc (ảnh trên che kín).
--mrt-r
tăng dần = vòng trong suốt loang rộng ra = ảnh dưới "loang" ra từ tâm, đúng cảm giác vết mực — không phải chiều ngược lại (chấm đặc lớn dần), vì như vậy sẽ che dần thay vì lộ dần.
对于每个 section(
[data-mrt-blob]
或
[data-mrt-split]
):两张图像在同一个 frame 中绝对定位叠放——下层图像(z-index 较低,始终可见)和上层图像(z-index 较高,带 mask)。上层图像使用:
css
mask-image: radial-gradient(circle at 50% 50%, transparent 0, transparent var(--mrt-r), black calc(var(--mrt-r) + var(--mrt-edge)), black 100%);
——半径
--mrt-r
内的区域是透明的(上层图像在此消失 → 显露下层图像),半径
--mrt-r + --mrt-edge
之外的区域仍然不透明(上层图像继续遮挡)。随着
--mrt-r
增大,透明斑块从中心向外扩散,下层图像像墨迹一样从中心晕开——而不是相反方向(逐渐扩大的不透明圆点),因为后者会遮盖图像,而不是显露图像。

Biến thể 1 — Blob/ink mask reveal (
[data-mrt-blob]
)

变体 1——Blob/ink mask reveal(
[data-mrt-blob]
)

html
<section data-mrt-blob>
  <div class="mrt-blob-frame">
    <img class="mrt-blob-under" src="B.jpg" alt="…">
    <img class="mrt-blob-over" src="A.jpg" alt="…">
  </div>
</section>
--mrt-r
ghi trực tiếp mỗi frame (không CSS transition) theo scroll progress: scroll listener rAF-throttle đo
stage.getBoundingClientRect()
,
progress = (vh - rect.top) / (vh + rect.height)
kẹp 0..1, nhân với bán kính tối đa (đường chéo khung / 2 + margin) →
over.style.setProperty('--mrt-r', ...)
. KHÔNG dùng transition ở biến thể này — transition sẽ làm vòng loang trễ nhịp so với vị trí cuộn thật (cùng lý do
svg-stroke-reveal
mode "scroll" không dùng transition).
html
<section data-mrt-blob>
  <div class="mrt-blob-frame">
    <img class="mrt-blob-under" src="B.jpg" alt="…">
    <img class="mrt-blob-over" src="A.jpg" alt="…">
  </div>
</section>
--mrt-r
根据 scroll progress 每帧直接写入(不使用 CSS transition):使用经过 rAF 节流的 scroll listener 测量
stage.getBoundingClientRect()
,计算
progress = (vh - rect.top) / (vh + rect.height)
并钳制到 0..1,再乘以最大半径(frame 对角线 / 2 + margin)→
over.style.setProperty('--mrt-r', ...)
。变体 1 中不要使用 transition——transition 会使斑块的节奏落后于实际滚动位置(这也是
svg-stroke-reveal
的“scroll”模式不使用 transition 的原因)。

Biến thể 2 — Pinned split-screen mask reveal (
[data-mrt-split]
)

变体 2——Pinned split-screen mask reveal(
[data-mrt-split]
)

html
<div class="mrt-split-section" data-mrt-split>
  <div class="mrt-split-sticky">
    <img class="mrt-split-under" alt="…">
    <img class="mrt-split-over" alt="">
  </div>
  <div class="mrt-split-scroll">
    <article data-mrt-panel data-mrt-src="1.jpg">…</article>
    <article data-mrt-panel data-mrt-src="2.jpg">…</article>
  </div>
</div>
Cột ảnh dùng thuần CSS
position: sticky
(KHÔNG GSAP/ScrollTrigger — pin theo cách rẻ nhất trình duyệt hỗ trợ sẵn). JS chỉ có nhiệm vụ đổi ảnh/mask theo panel đang active: một
IntersectionObserver
(threshold 0.5) gắn trên từng
[data-mrt-panel]
, khi panel vào tâm khung nhìn → gọi
revealTo(src)
: đẩy ảnh hiện tại lên lớp "trên" (che kín,
--mrt-r = 0
tức thì, ép reflow), đặt ảnh mới xuống lớp "dưới", rồi
requestAnimationFrame
set
--mrt-r
về bán kính tối đa để CSS transition (đăng ký qua
@property --mrt-r { syntax: "<length>"; ... }
) tự chạy mượt. Ở đây
--mrt-r
đổi RỜI RẠC theo sự kiện panel, không theo từng px cuộn — nên (khác biến thể 1) CSS transition trên
--mrt-r
là đúng chỗ.
html
<div class="mrt-split-section" data-mrt-split>
  <div class="mrt-split-sticky">
    <img class="mrt-split-under" alt="…">
    <img class="mrt-split-over" alt="">
  </div>
  <div class="mrt-split-scroll">
    <article data-mrt-panel data-mrt-src="1.jpg">…</article>
    <article data-mrt-panel data-mrt-src="2.jpg">…</article>
  </div>
</div>
图像列使用纯 CSS
position: sticky
(不使用 GSAP/ScrollTrigger——以浏览器原生支持的最廉价方式实现 pin)。JavaScript 只负责根据 active panel 切换图像和 mask:在每个
[data-mrt-panel]
上使用一个
IntersectionObserver
(threshold 为 0.5),当 panel 进入视口中央时调用
revealTo(src)
:将当前图像推入“上层”(不透明、
--mrt-r = 0
,立即触发 reflow),把新图像放到“下层”,然后通过
requestAnimationFrame
将
--mrt-r
设置为最大半径,使 CSS transition(通过
@property --mrt-r { syntax: "<length>"; ... }
注册)平滑运行。此处
--mrt-r
根据 panel 事件离散变化,而不是跟随每一个滚动像素,因此变体 2 适合在
--mrt-r
上使用 CSS transition。

Origin

来源

Batch 180926 — bản gốc NGUYÊN VĂN nằm ở
Rheinmir/uiux-asset/scroll-effects/
(xem mục "Bản gốc" ở trên);
assets/
là bản rút gọn vanilla tự viết.
Batch 180926——原封不动的原版位于
Rheinmir/uiux-asset/scroll-effects/
(见上文“原版”);
assets/
是自行编写的精简 vanilla 版本。