Mask Reveal Up
The string is split on newlines. Each line lives inside its own
overflow: hidden wrapper and starts 30px below that wrapper, 6px out of
focus and fully transparent. Because the line travels 30px while the mask is
only as tall as the line, it enters through the bottom edge of the mask rather
than fading in place. Line i starts 90ms after line i - 1.
The mask is doing most of the hiding here. At word or character granularity the blur alone is enough — see Blur Out Up and Soft Blur In. Here the travel distance is five times the blur radius, so the blur only softens the leading edge as it crosses; the wrapper is what actually clips.
Usage
<template>
<div class="text-center text-3xl font-bold">
<MMaskRevealUp
:text="'Every line rises\nout of its own mask'"
/>
</div>
</template>
Examples
Reveal on scroll
60ms between lines, once the block is on screen.
<template>
<div class="text-center">
<MMaskRevealUp
class="text-3xl font-bold"
:stagger="60"
:text="'Masked reveal\non scroll'"
trigger-on-view
/>
<p class="mt-3 text-sm text-muted">
60ms between lines, once the block is on screen.
</p>
</div>
</template>
Set trigger-on-view to hold the reveal until the element enters the viewport,
which is what you want for a headline below the fold. stagger here is
tightened to 60ms because the block is being read as it arrives.
Passing an array
Lines passed as an array, starting 200ms after mount.
<template>
<div class="text-center">
<MMaskRevealUp
class="text-2xl font-bold"
:delay="200"
:lines="['Copy that already', 'lives as an array', 'rises line by line']"
text="Copy that already lives as an array rises line by line"
/>
<p class="mt-3 text-sm text-muted">
Lines passed as an array, starting 200ms after mount.
</p>
</div>
</template>
lines takes precedence over text and skips the newline split entirely. This
is the shape to reach for when the copy already lives as an array. delay holds
the first line back 200ms so the block does not start on the same frame as the
rest of the page.
Customize
Customize
Props
| Prop | Type | Default | Description |
|---|---|---|---|
textrequired | string | - | The text to reveal. Split on \n unless lines is supplied. Also the source of
the aria-label, which is why it is required even when lines is used. |
lines | string[] | - | Supply the lines directly. Takes precedence over text. |
stagger | number | 90 | Milliseconds added per line. 0 reveals every line at once. |
delay | number | 0 | Milliseconds to wait before the first line starts. |
triggerOnView | boolean | false | Wait until the element scrolls into view before playing. Ignored under reduced motion. |
class | string | - | Classes for the root span. Type styles set here are inherited by every line. |
Accessibility
The root span carries an aria-label built by joining the resolved lines with a
single space. A screen reader would otherwise pause between the block-level line
wrappers, so the label restores the sentence the newlines were standing in for.
Each animated line is aria-hidden="true".
Under prefers-reduced-motion the reveal runs with duration: 0 rather than
being skipped. motion-v treats "nothing to animate" as "write nothing", so
skipping it would strand every line at blur(6px), opacity: 0, y: 30px
permanently. Collapsing the duration lands them at rest within a frame.
The same reasoning governs the server render: the hidden state is the initial
state on the server too. The server cannot know the visitor's motion preference,
and initial is only read when the visual element is created — so a client that
wanted the reduced-motion starting state could never clear the attributes the
server had already written, and the two trees would disagree. Suppressing the
transition instead keeps one identical tree.
Performance
- Each line animates
opacity,filterandy; the transform and opacity work runs on the compositor and never touches layout. - The
blur()on each line is the remaining cost, and it is proportional to the painted area of the line. Becauseoverflow: hiddenlets the browser clip cheaply, each mask wrapper is a single non-animated extra node per line. - The block is a single
onceIntersectionObserverfortrigger-on-view; nothing is scheduled after the last line lands and there is no scroll listener.
How the timing works
Line i waits delay + i × stagger milliseconds, then animates for 760ms.
The run is slower than the unmasked components because the line spends most of its time travelling through the mask. A faster run clips the blur into a hard edge — the softness is only readable while the line is still partially occluded.
At the 90ms default a four-line headline finishes in about 1.03s, and the
stagger is wide enough that the reader's eye is led down the block rather than
having the whole thing land at once.
The ease is (0.22, 1, 0.36, 1), shared with
Soft Blur In and
Blur Out Up.
It is display-aware
Unlike the per-word components, the root span here sets display: block
explicitly. Every element in the tree is a block box: the root, each mask
wrapper, and the animated line inside it.
That means max-width, text-center and width on class all behave the way
you would expect, and the component owns its own layout rather than deferring to
its parent's inline formatting context. It also means the line breaks are exactly
where you put them — the component never re-wraps, because it splits on \n and
never on spaces.
Lines come from newlines, not wrapping
text is split on \n. Because a Vue attribute string does not interpret escape
sequences, text="Line one\nLine two" passes a literal backslash-n, so pass the
string bound and let the JS literal do the work:
<MMaskRevealUp :text="'Line one\nLine two'" />
That is the reason the lines prop exists. Anything multi-line is more readable
as an array than as a string containing embedded newlines:
<MMaskRevealUp
:lines="['Lines can also', 'be passed in', 'as an array']"
text="Lines can also be passed in as an array"
/>
lines takes precedence over text when both are supplied, but text is still
required. That is deliberate: text is the source of the accessible label, so
requiring it guarantees the label is never empty even if lines is what actually
renders.
A line that wraps because it is wider than its container will have its second visual line clipped by the mask, since the mask is one line-box tall. Keep lines short, or widen the container.
Notes
- The component plays once.
triggerOnViewobserves withonce, and there is no replay prop — remount with a changing:keyto run it again. - The hidden and shown state objects are defined at module level on purpose.
motion-v compares
initialagainstanimateby reference, so a fresh literal on every render would make it believe every line re-animates on each update. - Keys are line indexes. Reordering
linesreuses the existing elements in place. overflow: hiddenis on the mask wrapper, not on the animated line, so the line is free to travel past the wrapper's edges without dragging a scroll container into the document.