Ink reveal
A block of text that is already entirely on the page, pale, and takes its ink as the page is scrolled through it.
Documentation
-
Copy to Webflow
Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: Webflow removes any font your site has not installed. -
Add custom CSS
Custom CSS in WebflowThis CSS already ships inside the Webflow embed, so a pasted section needs nothing added. Paste it yourself, in the page or the site custom code, only if the effect runs on several pages. The tutorial explains where.
See the tutorialThe colour of the text belongs to the page: the ink is the page's own colour, and the pale state is a share of it, so the effect works on any ground. A block near the end of the page needs no room under it: its run ends when the page stops scrolling, so the last word always takes ink. -
Add custom JS
Custom JavaScript in WebflowThis script already ships inside the Webflow embed, settings block included. You paste it yourself, before the closing body tag of the page or the site custom code, only when the effect has to run on several pages, and the tutorial says how to choose.
See the tutorial -
Use it on your own elements
How to add a custom attribute in WebflowA custom attribute is a pair, a name and a value, typed in the Settings panel of the element you selected, under Custom attributes. The name is what the script looks for, the value is what it reads. The tutorial shows the panel.
See the tutorialCustom attributeSelect the element on the canvas and open the Settings panel. Under Custom attributes, add a pair: the name on the left, the value on the right.Custom attribute What it does data-ink-reveal Marks the block whose words take ink. It takes no value: leave the value field empty. It goes on the element that carries the words: a paragraph, a heading, or a box holding several paragraphs, which then fill one after the other as a single run. SettingsEvery setting is a custom attribute too, added the same way and on the same element. An attribute always wins over the defaults written in the code.Attribute Default What it is data-ink-reveal-rest 0.12 How much ink a word holds before its turn, as a share of the page's own text colour. data-ink-reveal-softness 0.14 How much of the run one word takes to fill, which is how many words are filling at the same time. data-ink-reveal-from 0.78 Where the run starts, as a share of the window height measured from the top: 1 is the bottom edge. data-ink-reveal-to 0.34 Where the run ends, as a share of the window height: the run is over when the block's bottom reaches that line. data-ink-reveal-unit word What takes ink at a time. Options: word (the default, and the one that reads), char. data-ink-reveal-rewind true Whether the ink drains again when the page is scrolled back up. Options: true, false (the ink stays where it got to). data-ink-reveal-ease none The shape of one word going from pale to full. Options: any GSAP ease name, none (even with the scroll), power1.out, power2.inOut, and so on. data-ink-reveal-debug false Writes the state, the unit count and the progress in data-ink-reveal-debug-* attributes. Options: false, true.
Two files, no build step. It also needs GSAP and ScrollTrigger on the page, loaded with the tags in step 1. Without them the text stays at full ink and no word is ever dimmed.
It runs in the browser only. Load it with a script tag in plain HTML, in a
client-side script in Astro, never in the frontmatter. In React, call
InkReveal.mount(ref.current) in useEffect and
destroy() in its cleanup.
-
Tune it, either way
Per element with an attribute, or once for the whole site by editingDEFAULTSat the top of the file.Attribute Default What it is Lower Higher data-ink-reveal-rest 0.12 How much ink a word holds before its turn, as a share of the page's own text colour. The waiting words nearly vanish, and the block reads as text appearing rather than filling The waiting words are almost as dark as the inked ones, and the effect becomes a whisper data-ink-reveal-softness 0.14 How much of the run one word takes to fill, which is how many words are filling at the same time. A hard front: one word at a time, a cursor crossing the text A soft front: a long gradient of half inked words, and at 1 the whole block fills together with no reading order at all data-ink-reveal-from 0.78 Where the run starts, as a share of the window height measured from the top: 1 is the bottom edge. The block waits until it is well inside the screen, then fills fast The ink starts before the block is on screen data-ink-reveal-to 0.34 Where the run ends, as a share of the window height: the run is over when the block's bottom reaches that line. The last word fills only near the top of the screen, so the fill lags behind the reading The last word fills early, while the block is still low on the screen data-ink-reveal-unit word What takes ink at a time. Options: word (the default, and the one that reads), char. data-ink-reveal-rewind true Whether the ink drains again when the page is scrolled back up. Options: true, false (the ink stays where it got to). data-ink-reveal-ease none The shape of one word going from pale to full. Options: any GSAP ease name, none (even with the scroll), power1.out, power2.inOut, and so on. data-ink-reveal-debug false Writes the state, the unit count and the progress in data-ink-reveal-debug-* attributes. Options: false, true. -
Drive it from JavaScript
When the page reacts to a block reaching full ink, or when text is added after the page has loaded.const element = document.querySelector('[data-ink-reveal]'); element.addEventListener('inkreveal:done', (e) => { console.log(e.detail); }); const block = InkReveal.mount(element); // the block auto() mounted, or a new one block.refresh(); // after changing an attribute // Text added after the page loaded: mount everything marked inside it. InkReveal.auto(container); // When the block leaves the page: InkReveal.destroy(element); // the words go back exactly as they were
-
Fit it to your page
- Mark the block:
data-ink-revealon the element carrying the words. One mark on a wrapper of three paragraphs gives one run across the three; one mark per paragraph gives three runs. A marked block inside a marked block is left out of its parent's run and keeps its own. - Settings go on the same element, as more attributes:
data-ink-reveal-rest="0.3",data-ink-reveal-softness="0.05". Three levels, least specific first:DEFAULTS, thenwindow.InkRevealSettings = { rest: 0.3 }declared before the engine, then the element's own attributes. - The pale state is an opacity of the text colour, so the effect needs to know nothing about the ground: it works on white, on charcoal and over an image. To pale the words by a different amount for one block without touching an attribute, set
--ink-reveal-reston it in CSS. - From JavaScript:
InkReveal.mount(el, overrides)returns{ element, settings, units, refresh(), destroy() }.refresh()reads the attributes again, splits the words again ifunitchanged and lays the run out afresh.destroy()puts the words back exactly as they were, spaces included.InkReveal.destroy(el),InkReveal.refresh(el),InkReveal.auto(root)andInkReveal.defaultscomplete it. - Events, all bubbling:
inkreveal:readyonce the words are split and the run is laid out ({ element, units, unit, reduced }),inkreveal:enterandinkreveal:leaveas the block comes into the window and leaves it ({ element, units, progress }), andinkreveal:donethe first time the block reaches full ink ({ element, units }). - **Under
prefers-reduced-motion: reduce** the text is at full ink immediately and nothing moves: the words are not split at all, nothing is dimmed, and no scroll trigger is created.inkreveal:readystill fires, withreduced: trueandunits: 0. - The text stays live the whole way. It is real text in the document: it is selectable, copied with its spaces, found by the browser's search across two words, read as a sentence by a screen reader, and a link inside the block is still a link. Nothing is ever
visibility: hiddenand nothing is moved out of the accessibility tree. - The words of the markup are the preview's, and they ship as they are: pasted, the page reads the way the preview did. Put the user's own content in their place whenever they want, in the page's language.
- On Webflow, add the fonts first. Add Geist and Geist Mono to your site's fonts (Site settings, Fonts, Google Fonts) before pasting: the paste names them, and the Designer drops a font the site does not have.
- Mark the block:
-
Avoid the pitfalls
- A block at the very bottom of the page fills faster. Its run is cut to the last scroll position the page has, so the last word takes ink exactly when the page stops, and the run is never shorter than a quarter of the window (
MIN_RUN). Found in Webflow on 18/09/2026, where the signature at the foot of the page kept its last word pale. Give the block room below it for the full, slower run. - A block taller than three or four screens fills slowly by design, since the run is the block's own travel: the ink follows a reader. For a long page of text, mark each paragraph rather than the whole article, and each one gets its own run.
- **
charis not the accessible default.** The reading is the same, because the word keeps a box of its own and the spaces are untouched, but splitting a word into letters breaks kerning and ligatures inside it and multiplies the number of animated spans. Use it on a headline of a few words, never on a paragraph. - Do not put the mark on a box that holds anything but text. The engine walks the text nodes, so an image or a button inside the block is left alone, but the whole block is still the trigger: a box with a picture in it gives a run whose travel has little to do with its words.
- A page that never gets GSAP shows plain text. The pale state is written only once the words are split, which happens only once both libraries are there. That is on purpose: a text effect must never be able to leave text unreadable.
- The stylesheet is what pales a word. Without
ink-reveal.cssthe words are split and driven but every one of them stays at full ink, and the engine says so in the console. - **
restis a custom property**, so a page-wide rule of yours on--ink-reveal-restchanges the block as surely as an attribute does, and loses to an attribute the engine wrote. - **Webflow loads its own GSAP, and
Webflow.destroy()kills every ScrollTrigger on the page**, this engine's included. The words would then freeze at the ink they had. The engine looks at itself 0.7 s, 2.2 s and 6 s after its build and lays the run out again when its trigger has been taken away; a page that callsWebflow.destroy()later than that gets a frozen block, andInkReveal.refresh(el)brings it back. - **A
::first-letteror a::first-linerule still applies**, but a:first-childrule aimed at the first word of a paragraph will now match the engine's span. Aim at the paragraph, not at what is inside it.
- A block at the very bottom of the page fills faster. Its run is cut to the last scroll position the page has, so the last word takes ink exactly when the page stops, and the run is never shorter than a quarter of the window (
One click on Copy the AI prompt copies everything an assistant needs to build this effect: the complete code, the markup, the dependency, every setting and every pitfall.
What it does
Ink reveal is a text animation for Webflow and vanilla JavaScript, built on GSAP 3 and ScrollTrigger. Also called text fill on scroll, word by word text reveal on scroll or scroll text color change.
A block of text that is already entirely on the page, pale, and takes its ink as the page is scrolled through it. Word by word, in reading order, left to right and top to bottom, each word goes from a faint tint of the page's own text colour to full ink, and the front of the fill follows the scroll position exactly: scroll back up and the ink drains again. Nothing appears and nothing moves; the text fills. The words are never hidden, never made transparent and never taken out of the document: the engine wraps each word in a bare inline span, leaves the spaces between them as they were, and animates one number per word that the stylesheet turns into an opacity, so reading order, tab order, selection, text search and screen readers are the ones the markup already had. Distinct from pixel-reveal, which writes text in block by block when it arrives on screen: here the text is present from the first paint and only its ink travels. Pattern type scroll fill text, ink fill, text opacity on scroll, reading highlight, word by word fill.
Updated
Questions
Add the custom attribute data-ink-reveal, with an empty value, to the paragraph, heading or box that carries the words, under Custom attributes in the Settings panel. One mark on a box of three paragraphs gives one run across the three, and one mark per paragraph gives three runs.
No. It uses GSAP 3.12.5 and its ScrollTrigger plugin, loaded from the CDN, and no other plugin: the engine splits the words itself. On a page where the libraries never load, the text stays at full ink and no word is dimmed.
data-ink-reveal-rest is the ink a waiting word holds, 0.12 by default: lower and the waiting words nearly vanish, higher and they are almost as dark as the inked ones. data-ink-reveal-softness, 0.14 by default, is how many words fill at the same time: lower gives a hard front of one word at a time, higher a long gradient of half inked words.
It starts when the top of the block reaches from, 0.78 of the window height measured from the top, and is over when the bottom of the block reaches to, 0.34. Scrolling back up drains the ink again unless you set data-ink-reveal-rewind="false". A block at the very bottom of the page fills faster, because its run is cut to the last scroll position the page has.
Yes. The words are never hidden or made transparent: each one is wrapped in a bare inline span and the spaces stay real text nodes, so the text is selectable, found by the browser's search and read as a sentence. Under prefers-reduced-motion: reduce the text is at full ink immediately and the words are not split at all.