Vanilla HTML/CSS/JS component — the image is revealed through a mask with an organic shape (ink-splatter ring, fixed center, increasing radius) instead of a normal crossfade/opacity. Two variants share one underlying mechanism: measure scroll progress WITHIN the section using getBoundingClientRect(), map to 0..1, then write the splatter radius to a CSS custom property.
WHAT
Purpose and context
- Purpose: reveal/switch image through an organic-shaped mask (ink-splatter ring) according to scroll progress, or a sticky split-screen pin that changes image through a mask according to the panel being read — prioritize the original verbatim code at
Rheinmir/uiux-asset,assets/is a lightweight vanilla fallback. - Trigger (when to use):
- Artistic image transition/portfolio in the style of an "ink splatter" synchronized with scroll (not a flat crossfade).
- Case-study/portfolio 2-column layout: image fixed on the left, paragraph scrolling on the right, image changes according to the paragraph being read.
- Non-goals: NOT used for simple reveals (fade + slight slide on entering viewport) — that is
skills/scroll-effectspattern #1, without a shape mask. NOT used for full-screen image transitions of the GPU-heavy zoom-blur type — that isskills/blur(Three.js/shader). This skill is CSS onlymask-image, no 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". Two variants: [data-mrt-blob] (write --mrt-r each frame, no transition) and [data-mrt-split] (discrete change per panel, with transition via @property).
Input and output contract
| Field | Required? | Meaning | |
|---|---|---|---|
| In | image (2 images for blob; one image per panel for split) | yes | content substituted into the original/asset |
| In | project constraint on whether external libs/files may be pulled in | no (default: allowed) | not allowed → use assets/ vanilla |
| Out | HTML page/component with mask reveal effect | yes | run over local HTTP, with -webkit-mask-image, with branch prefers-reduced-motion |
| Out | playwright evidence | yes | before/during/after images (variant 1), assert image change + pin (variant 2) |
Rules and capabilities
- RULE-01 (MUST): Browser support
mask-image: Safari (including modern Safari) only accepts-webkit-mask-image/-webkit-mask-repeat— MUST write both the standardmask-imageAND-webkit-mask-image(already present in the CSS asset, do not remove the prefix when adapting/minifying). Missing prefix = the top image will never have a hole on Safari, meaning the effect does not run. - RULE-02 (MUST):
position: stickydoes not work if ANY parent element (even far up, up to<body>) hasoverflow: hidden/auto/scrollorclip— this is the most common error when embedding.mrt-split-stickyinto an existing layout (many pages placeoverflow: hiddenon a wrapper to block horizontal scroll, accidentally killing sticky for all child elements). Check the entire parent chain before reporting "sticky not working" as a bug. - RULE-03 (MUST): Variant 2 uses
@property --mrt-rso the browser can animate a custom property contained incalc()/gradient — without this declaration, changing--mrt-rvia JS will jump instantly instead of smoothly through a transition. - RULE-04 (MUST):
prefers-reduced-motion: reduce→ both variants set--mrt-r= maximum radius immediately (fully revealing the image), disable animate/transition — already coded in JS/CSS, do not remove this branch when adapting. - RULE-05 (MUST): A SINGLE rAF-throttled scroll listener for all
[data-mrt-blob]on the page; a SINGLEIntersectionObserverper[data-mrt-split]for its panels — do not attach multiple separatescrolllisteners or multiple observers with duplicate roles. - RULE-06 (MUST): Verify with
playwright-verify: capture before/during/after scrolling through variant 1 to see the spreading ring grow, and assert variant 2 changes the correct image as each panel enters the middle of the viewport + the image column actually pins (does not scroll with the page) while reading. - Capabilities: read/copy files from an external asset repo or the skill's
assets/; write project HTML/CSS/JS files; run a headless browser to verify.
Failure boundaries
- Effect does not run on Safari → missing
-webkit-mask-image→ fix the prefix, do not conclude it is a different bug. - Sticky does not pin → scan the entire parent chain for
overflow/clipbefore reporting a bug. - Opened via
file://→ invalid; rerun over HTTP. - The request is actually for an opacity/translate reveal or a full-screen shader image transition → not part of this skill, hand off to
skills/scroll-effectsorskills/blur.
HOW
Main workflow
| Step | Type | Inputs | Action | Outputs/exit | Failure/next |
|---|---|---|---|---|---|
| W01 | judgment | request | Choose the variant (blob or split) and source: original (default) or assets/ vanilla |
choice | external lib must not be pulled in → B01 |
| W02 | effect | original pen | Open the link to check the effect is correct → copy the entire file .html (+ _assets/) → keep the attribution comment → only edit content/images/colors |
file in the project | — |
| W03 | deterministic | page | Run locally over HTTP, verify with playwright-verify |
image + assert | Safari/sticky error → see Failure boundaries, fix, repeat |
Step-by-step details (source of truth for W01–W03):
Original version — use BEFORE (copy the file as-is, do not rewrite)
The author's EXACT original code (public CodePen = MIT) is in the external repo Rheinmir/uiux-asset — live gallery: https://rheinmir.github.io/uiux-asset/scroll-effects/ .
Process: open link run to see the exact effect → copy the file as-is .html (+ folder _assets/ if the file references it) into the project → keep the attribution comment on the first line → only edit content/images/colors. Run locally over HTTP, not file://.
git clone --depth 1 https://github.com/Rheinmir/uiux-asset if you need the whole set offline.
| Original Pen — author | Link |
|---|---|
| Ink Transition Scroll Effect — iamryanyu | run · code |
| Pinned Split-Screen Mask Reveal — gridmorphic | run · code |
| Parallax Jungle Leaves Reveal — Mamboleoo | run · code |
assets/ in this skill is just a self-written vanilla trimmed version — use when the project is NOT allowed to pull external libs; by default use the original version above.
Assets
assets/demo.html + assets/mask-reveal-transition.css + assets/mask-reveal-transition.js — read then ADAPT (change images, --mrt-edge edge softness, panel content).
Branches
| ID | Kind | Guard | Behavior | Skip / failure | Rejoin |
|---|---|---|---|---|---|
| B01 | conditional_required | project is NOT allowed to pull external libs/files | use assets/demo.html + .css + .js, read then ADAPT (images, --mrt-edge, panel content) |
— | W03 |
| B02 | user_optional | need the whole set offline | git clone --depth 1 https://github.com/Rheinmir/uiux-asset |
— | W02 |
Validation and stopping
Deterministic (playwright): variant 1 captures before/during/after scrolling and sees the ink blot grow; variant 2 asserts the image changes exactly when each panel enters the middle of the viewport + the image column truly pins. Needs review: the "ink spreading" feel in the right direction (revealing gradually, not covering gradually). Stop when the above asserts are green.
Examples
- Positive: portfolio case-study 2 columns "image fixed on left, text scrolls on right" → copy as-is
pinned-split-screen-mask-reveal.htmlfromRheinmir/uiux-asset, change panel images → playwright assert each[data-mrt-panel]entering the middle of the viewport changes the image and the image column does not scroll with the page. - Boundary/failure: embed
.mrt-split-stickyinto a layout with a wrapperoverflow: hidden→ image scrolls with the page, does not pin → inspect the parent chain, removeoverflowon the wrapper, do not report it as a bug of the component. Minify removes-webkit-mask-image→ Safari has no mask bug → restore the prefix.
Reference — Common underlying mechanism
For each section ([data-mrt-blob] or [data-mrt-split]): 2 images stacked absolutely within the same frame — the bottom image (low z-index, always visible) and the top image (high z-index, carrying the mask). The top image has
mask-image: radial-gradient(circle at 50% 50%, transparent 0, transparent var(--mrt-r), black calc(var(--mrt-r) + var(--mrt-edge)), black 100%);
— the region within radius --mrt-r transparent (the top image disappears there → revealing the bottom image), the region outside --mrt-r + --mrt-edge still solid (the top image fully covers). --mrt-r increasing = the transparent ring spreads outward = the bottom image "bleeds" out from the center, exactly like an ink stain — not the reverse direction (a solid dot growing larger), because that would cover gradually instead of revealing gradually.
Variant 1 — Blob/ink mask reveal ([data-mrt-blob])
<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 writes directly each frame (no CSS transition) according to scroll progress: an rAF-throttled scroll listener measures stage.getBoundingClientRect(), progress = (vh - rect.top) / (vh + rect.height) clamped to 0..1, multiplied by the maximum radius (frame diagonal / 2 + margin) → over.style.setProperty('--mrt-r', ...). Do NOT use transition in this variant — transition would make the spreading ring lag behind the actual scroll position (same reason svg-stroke-reveal mode "scroll" doesn't use transition).
Variant 2 — Pinned split-screen mask reveal ([data-mrt-split])
<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>
The image column uses pure CSS position: sticky (NO GSAP/ScrollTrigger — pinning the cheapest way the browser natively supports). JS only has the task of switching image/mask according to the active panel: an IntersectionObserver (threshold 0.5) attached to each [data-mrt-panel], when a panel enters the center of the viewport → call revealTo(src): push the current image to the "top" layer (fully covering, --mrt-r = 0 instantly, force reflow), place the new image on the "bottom" layer, then requestAnimationFrame set --mrt-r to the maximum radius so the CSS transition (registered via @property --mrt-r { syntax: "<length>"; ... }) runs smoothly on its own. Here --mrt-r changes DISCRETELY according to panel events, not per scroll pixel — so (unlike variant 1) a CSS transition on --mrt-r is the right fit.
Origin
Batch 180926 — the original VERBATIM version is located at Rheinmir/uiux-asset/scroll-effects/ (see the "Original" section above); assets/ is a self-written simplified vanilla version.