Stroke Animation
Progressive left-to-right fill of outlined text via a pseudo-element and animated clip-path — 100% CSS, zero JavaScript required.
You'll get access to the interactive demo with a free account.
This effect is part of Effect.Labs — 811 vanilla effects, some free, some premium. Text has 56 effects, including 4 free. Explore the category →
3 usage examples



How it works
The effect relies on two stacked layers: the .text-stroke element carries -webkit-text-stroke: 2px #6366f1 and -webkit-text-fill-color: transparent — the text appears as a hollow outline. A ::before pseudo-element is generated with content: attr(data-text) and positioned absolute at left: 0; top: 0, forming an exact filled copy of the text on top of the outline.
The reveal is achieved via an animated clip-path: ::before starts at clip-path: inset(0 100% 0 0) (100% clipping from the right — the filled text is fully hidden) and the @keyframes strokeFill animation progresses it to clip-path: inset(0) (no clipping — text fully visible). The visual result is a left-to-right wipe that progressively reveals the fill color through the outline.
The animation-direction: alternate property reverses the cycle at each iteration: the text fills, then empties right-to-left, then fills again. Switching to animation-direction: normal with animation-iteration-count: 1 and animation-fill-mode: forwards produces a one-shot reveal — the text stays filled at the end.
The embedded JS defines a helper function splitChars(el) that splits the element's text into individual <span class="char"> elements for character-by-character animations with staggered delays. It is not called on startup — the default animation applies to the whole word via CSS. The document.getElementById('fadeInText') call returns null in the supplied configuration (no fadeInText id in the HTML): no JS side effects on initialization.
Accessibility
- prefers-reduced-motion absent: no media query is present in the supplied code — the animation runs unconditionally. Recommended fix:
@media (prefers-reduced-motion: reduce) { .text-stroke::before { animation: none; clip-path: inset(0) } }(text stays visible and static). - The text is real DOM content (the
<span>element's textContent) — read natively by screen readers without additional ARIA attributes. - The fill color
#6366f1on background#0a0a0freaches a contrast ratio of ≈ 3.5:1 — meets WCAG AA for large text (≥ 24 px / weight ≥ 700), insufficient for body text. During the outline-only phase, the thin stroke may fall below threshold. - No keyboard interaction — the effect is 100% CSS, no implicit focus generated.
- No
aria-labelorroleis set on the element. Add anaria-labelto the wrapper if the text is heavily styled or if color alone makes it ambiguous (e.g. text on a complex background).
Browser compatibility
Relies on -webkit-text-stroke, -webkit-text-fill-color, and clip-path: inset() — supported in all modern browsers since 2019. The -webkit- prefix is kept by convention but works without it on Firefox and Edge.
Without <code>-webkit-text-fill-color</code> support, the text displays with its inherited CSS color (no transparent outline). Without <code>clip-path</code>, the <code>::before</code> pseudo-element is fully visible on load — the text remains readable, but the reveal animation does not occur.
The code
HTML structure to paste into your page (CSS + JS available with a premium account):
<div class="demo-preview">
<!-- data-text must exactly match the element's textContent
(both are read by screen readers) -->
<span
class="demo-text text-stroke"
data-text="YOUR TEXT"
>YOUR TEXT</span>
</div>
Full HTML + CSS + JS, copy-paste ready — with hundreds of premium effects.
Customize
Options passed to the API or data-* attributes:
| Option / property | Default | Effect |
|---|---|---|
| -webkit-text-stroke on .text-stroke | 2px #6366f1 | Stroke thickness (px) and color (hex). 1px for a fine line, 4px for large display use. The color may differ from the fill for a two-tone effect. |
| -webkit-text-fill-color on .text-stroke::before | #6366f1 | Fill color. Usually matches the stroke color, but can differ intentionally (e.g. white stroke, gold fill). |
| animation-duration on .text-stroke::before | 2s | Duration of one half-cycle (fill or empty). 1s = fast and punchy, 4s = slow and cinematic. |
| animation-direction on .text-stroke::before | alternate | Set to normal for a one-way left-to-right wipe without reversal. Combine with animation-iteration-count: 1 and animation-fill-mode: forwards. |
| animation-timing-function on .text-stroke::before | ease-in-out | Wipe speed curve. linear = steady progression, cubic-bezier(.4,0,.2,1) = slow start then dramatic acceleration. |
| font-size on .demo-text | 2.5rem | Text size. The effect reads best from 2rem up — below that, the thin outline can make the text hard to read. |
| data-text on the element | STROKE | Displayed text AND the value fed into content: attr(data-text) on the pseudo-element. Must match the textContent — both are read by accessibility tools. |
| Initial clip-path on .text-stroke::before | inset(0 100% 0 0) | Sets the wipe start edge. inset(0 0 0 100%) reverses direction (right-to-left reveal). inset(100% 0 0 0) produces a bottom-to-top wipe. |
FAQ
splitChars(el) for letter-by-letter animation, but it is not called on startup. You can omit that JS block with no impact on the base animation.animation: 2s ease-in-out 0s infinite alternate with animation: 2s ease-in-out 0s 1 normal none forwards strokeFill. Key values: 1 (single iteration), normal (left-to-right fill without reversal), forwards (the text stays filled at the end).splitChars(el) function provided in the JS: it splits the text into individual <span class="char"> elements and returns the NodeList. Then apply an increasing animation-delay to each: chars.forEach((c, i) => c.style.animationDelay = i * 0.12 + 's'). Add position: relative to .char so the CSS ::before positions correctly on each letter.