Gooey Blur Text Reveal — SplitText Lines Sharpening Under an Alpha-Threshold SVG Filter
Goal
Build a long editorial page (hero → banner photo → two paragraphs → banner → five-line index → banner → paragraph → banner → outro) where every heading and paragraph materialises line by line out of a soft blob. Each line is wrapped in an inner span that starts at a large blur (0.35em) and animates to sharp; the outer line carries an SVG feColorMatrix alpha-threshold filter, which hardens the blurred alpha into a solid, gooey mass — so instead of a fade the letters *pool* into shape, like ink. Three trigger modes per block: on load (with delay), once on scroll (enter the viewport), and scrubbed (tied to scroll between two points). Banner photos settle from a slight zoom as they pass. GSAP SplitText + ScrollTrigger, Lenis smooth scroll.
Tech
Vanilla HTML/CSS/JS with ES module imports. gsap (npm) with SplitText and ScrollTrigger, plus lenis. Fonts: Anton (headings), DM Sans (base).
Layout / HTML
<div class="gb">
<svg class="gb-filter" aria-hidden="true"><defs>
<filter id="gb-goo" x="-50%" y="-50%" width="200%" height="200%">
<feColorMatrix in="SourceGraphic" type="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 255 -140" />
</filter>
</defs></svg>
<section class="gb-hero"><h1 class="gb-copy" data-mode="load" data-delay="0.8">The weight of old light</h1></section>
<div class="gb-banner"><img src="img1.jpg" alt="" /></div>
<section class="gb-room">
<h3 class="gb-copy" data-mode="scroll">Before the camera there was only the patient hand…</h3>
<h3 class="gb-copy" data-mode="scroll">A room of long shadow…</h3>
</section>
<div class="gb-banner"><img src="img2.jpg" alt="" /></div>
<section class="gb-index">
<div class="gb-copy" data-mode="scrub"><h1>Ground pigment</h1><h1>Falling shadow</h1><h1>Warm flesh</h1><h1>Suspended gesture</h1><h1>Held silence</h1></div>
</section>
<div class="gb-banner"><img src="img3.jpg" alt="" /></div>
<section class="gb-collection"><h3 class="gb-copy" data-mode="scrub">We collect the paintings that hold their breath…</h3></section>
<div class="gb-banner"><img src="img4.jpg" alt="" /></div>
<section class="gb-outro"><h1 class="gb-copy" data-mode="scroll">Step closer</h1></section>
</div>
The alpha row 0 0 0 255 -140 multiplies alpha by 255 and subtracts 140: anything below ~55% alpha vanishes, anything above becomes solid — the threshold that makes blur look gooey.
Styling
:root: --gb-paper:#f4f3ef, --gb-ink:#17181a.
.gb—background:var(--gb-paper); color:var(--gb-ink); font-family:"DM Sans"..gb-filter—position:absolute; width:0; height:0; pointer-events:none.h1, h3—margin:0 0 2rem; font-family:"Anton"; font-weight:400; line-height:1.02; text-transform:uppercase; letter-spacing:.005em;h1 { clamp(2.2rem,10vw,6.5rem) },h3 { clamp(1.5rem,6.5vw,4rem) }.section—position:relative; width:100%; min-height:100svh; padding:4rem. Hero/outro centre their text,h1 { width:50% }..gb-index— centred column..gb-banner—width:100%; height:100svh; overflow:hidden;imgobject-fit:cover; will-change:transform..gb-line(SplitText line) —display:block; filter:url(#gb-goo) blur(0.4px); -webkit-filter: url(#gb-goo) blur(0.4px); will-change:filter..gb-line__inner—display:inline-block; will-change:filter.@media (max-width:1000px): section padding2rem, hero/outroh1 { width:100% }.
GSAP effect (be exact)
gsap.registerPlugin(SplitText, ScrollTrigger). Build in mount(config) → destroy().
const DEFAULTS = { blurStart: .35, duration: 1.5, stagger: .1, bannerZoom: 1.15, scrubStart: 75 };
Lenis — lenis = new Lenis(); lenis.on("scroll", ScrollTrigger.update); gsap.ticker.add(t => lenis.raf(t * 1000)); gsap.ticker.lagSmoothing(0).
Inside a gsap.context(() => { … }, root):
Per .gb-copy block — mode = data-mode || "load", delay = data-delay || 0; targets = the block itself if it is an h1/h3, otherwise its children. For each target: SplitText.create(target, { type: "lines", linesClass: "gb-line" }); for each line create <span class="gb-line__inner">, move the line's child nodes into it, append it to the line; collect the inners.
gsap.set(inners, { filter: "blur(0.35em)" }).- base tween
{ filter: "blur(0em)", ease: "power3.out", stagger: .1 }: scrub→ addscrollTrigger: { trigger: block, start: "top 75%", end: "bottom 75%", scrub: true }.scroll→duration: 1.5, delay, scrollTrigger: { trigger: block, start: "top 80%", once: true }.load→duration: 1.5, delay.
Banners — gsap.fromTo(img, { scale: bannerZoom }, { scale: 1, ease: "none", scrollTrigger: { trigger: img.parentElement, start: "top bottom", end: "bottom top", scrub: true } }).
destroy() — ctx.revert() (kills the ScrollTriggers and reverts the splits), remove the ticker callback, lenis.destroy().
Assets / images
Four full-bleed landscape photos (≥1600px wide): natural scenes with soft tonal range (canopy, garden path, lake, forest). They are dividers between text sections.
Behavior notes
- The blur is in
em, so the effect scales with type size; heavy faces work best — thin strokes fall below the alpha threshold and break up. - The threshold filter sits on the *line*, the animated blur on the *inner span*: that separation is the effect. Putting both on one element gives a plain blur.
- Lenis owns page scrolling; if the host page already has Lenis, reuse that instance and keep the
ScrollTrigger.updatewiring once. - Only the prefixed variables are global.
Images
This component ships with 4 reference assets, served publicly. Use them as-is to reproduce the demo faithfully, then swap in your own — the layout expects the same aspect ratios.
https://motionprompts.dev/c/gooey-blur-text-reveal/img1.jpg
https://motionprompts.dev/c/gooey-blur-text-reveal/img2.jpg
https://motionprompts.dev/c/gooey-blur-text-reveal/img3.jpg
https://motionprompts.dev/c/gooey-blur-text-reveal/img4.jpg
They are hotlinkable for prototyping. For anything you ship, replace them: they are licensed for demonstration of this component, not for redistribution.
Using this outside its demo page
This component is written as a complete page — that is how the demo is meant to look. If you are dropping it into an existing project, or combining it with other components, these are the things it declares at document level and that you need to move or reconcile first.
- Palette on
:root—--gb-paper,--gb-ink. These names are not namespaced and they collide:--inkis defined by 164 of the 219 components in this catalogue,--paperby 94,--mutedby 80, each with different values — and they will also collide with whatever your own project defines. Move them onto the component's wrapper (.my-section { --ink: … }) or rename them with a prefix. - Rules on
html, body— the demo owns the whole document, so these set the page background, typography and resets. Dropped into an existing project they restyle the entire page, not just this section. Re-target them at the component's wrapper before using it. - Smooth scroll (Lenis) — this creates its own Lenis instance, and a page may only have one. If your project already runs Lenis, drop the setup shown above and reuse the existing instance, keeping the
lenis.on("scroll", ScrollTrigger.update)wiring once. Two instances fight over the same scroll and stutter visibly, with no error in the console.