Hello Stack Overflow community,
I'm trying to create an SVG logo animation for my "MPLAY" logo where the fill color appears to "flow" or "draw" along specific predefined paths within each letter shape. The effect should follow the direction indicated by the red arrows in this image:
Here is a GIF animation of what I'm trying to achieve:
here is a Gif of my current animation:
My Current Approach:
SVG Setup:
I have defined individual elements for each logical part of the logo (M1, M2, P, L, A1, A2, Y1, Y2) using the final outline shapes of these parts.
I've manually created separate guide elements (with IDs like M1-way, P-way, etc., and class .flow-path) which follow the desired centerline of the animation flow shown by the red arrows.
Each .flow-path is then clipped using its corresponding clipPath attribute (e.g., <path id="P-way" class="flow-path" clip-path="url(#PClip)" ... />).
Animation Technique:
I'm using GSAP to create a timeline.
The timeline animates the strokeDashoffset property for each .flow-path from its getTotalLength() (calculated via JS) down to 0.
Styling for Fill Effect:
The .flow-path elements have fill: none;.
They are styled with a very large stroke-width (e.g., 50-60px, adjusted via CSS variable --stroke-width) and stroke: #E50914; (red) to simulate the fill color.
I'm using stroke-linecap: square; to get a sharp "leading edge" of the fill.
This approach, using individual clipPaths per letter part, significantly reduced artifacts between letters compared to using one global clip path.
The Problem:
While the direction and sequence are now correct thanks to the ...-way paths, I'm still facing issues with visual consistency and artifacts within the clipped areas, primarily due to the variable width of the logo's strokes/shapes.
Inconsistent Coverage: A single stroke-width value large enough to cover the widest parts of a letter often proves too thick for narrower sections or sharp corners within the same letter shape.
Artifacts/Glitches: This "too thick" stroke gets clipped by the clipPath, but can still cause visual glitches:
Small squares or blocks of color appearing prematurely at sharp corners of the flow-path (even with stroke-linejoin: round).
Parts of the fill appearing "outside" the intended flow path near the edges of the clipPath.
Sometimes the fill doesn't perfectly reach the edges in very narrow or sharp areas.
I've tried using different --stroke-width values for specific problematic paths via CSS (stroke-width: calc(var(--stroke-width) * 0.8);), but finding the right balance for every segment is extremely tedious and doesn't fully eliminate the issues. I also tried stroke-linecap: round, which helps slightly at the ends but doesn't solve the core problem in corners or varying widths.
My Question:
How can I achieve a clean and visually consistent "flowing fill" animation along these specific ...-way paths within their respective clipped letter shapes, effectively handling the variable width of the underlying logo design?
Are there alternative or more robust techniques using SVG and GSAP (or maybe other libraries/approaches) to simulate this path-following fill effect that would avoid the artifacts and coverage issues inherent in the thick stroke-dashoffset method when applied to complex, variable-width shapes?
Any suggestions, alternative approaches, or refinements to my current method would be greatly appreciated! Thank you.
here is the full source code:
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MPLAY Logo - GSAP (Individual ClipPaths)</title>
<style>
:root {
/* -- Basic stroke width -- */
--stroke-width: 50; /* Tunable value */
}
body {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
min-height: 100vh;
margin: 0;
background-color: #141414;
font-family: Arial, sans-serif;
color: #fff;
}
.logo-container {
width: 90%;
max-width: 750px;
aspect-ratio: 1107 / 317.365;
position: relative;
}
#mplay-logo-svg {
display: block;
width: 100%;
height: 100%;
position: absolute;
top: 0;
left: 0;
overflow: visible;
}
/* Style for animated "flow" paths */
.flow-path {
fill: none;
stroke: #E50914;
stroke-width: var(--stroke-width, 50);
stroke-linecap: square;
stroke-linejoin: round;
stroke-dasharray: 1;
stroke-dashoffset: 1;
visibility: hidden;
}
/* --- OPTIONAL: Specific widths for some paths --- */
/* If the default width is not enough, you can adjust it here */
#M1-way { stroke-width: calc(var(--stroke-width, 50) * 2.75); }
#M2-way { stroke-width: calc(var(--stroke-width, 50) * 1.75); }
#P-way { stroke-width: calc(var(--stroke-width, 50) * 0.8); }
#A1-way, #A2-way { stroke-width: calc(var(--stroke-width, 50) * 0.85); }
#Y1-way { stroke-width: calc(var(--stroke-width, 50) * 0.8); }
#Y2-way { stroke-width: calc(var(--stroke-width, 50) * 0.75); }
/* --- End of optional widths --- */
.controls {
margin-top: 30px;
text-align: center;
}
button {
padding: 10px 20px;
background-color: #E50914;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 16px;
margin: 0 10px;
transition: background-color 0.2s ease;
}
button:hover {
background-color: #f40612;
}
button:disabled {
background-color: #555;
cursor: not-allowed;
}
</style>
</head>
<body>
<h1>MPLAY Logo - GSAP (Individual ClipPaths)</h1>
<div class="logo-container">
<svg id="mplay-logo-svg" xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 1107 317.365" preserveAspectRatio="xMidYMid meet">
<defs>
<!-- Define individual clipPaths using shapes from your SVG Type 1 -->
<!-- Note: Polygons/Polylines converted to Path for better clipPath compatibility -->
<clipPath id="M1Clip">
<path d="M8.238 5.661 L8.316 311.289 L67.119 280.307 L66.667 102.363 L225.536 188.34 L436.888 72.236 L436.606 5.449 L225.677 122.82 Z"/>
</clipPath>
<clipPath id="M2Clip">
<path d="M436.696 184.286 L436.762 114.422 L257.34 211.955 L436.762 311.657 L436.762 241.07 L385.367 212.086 Z"/>
</clipPath>
<clipPath id="PClip">
<!-- We will use the original path for P, it is a closed shape -->
<path d="M514.45 155.045l-61.303 0.192 0.014 95.167 36.434 -0.012 0.15 -64.311 35.972 -0.197c0.001,0 0.002,0 0.004,0 38.939,-2.914 51.653,-36.571 52.812,-51.584 3.563,-46.166 -36.98,-68.893 -52.11,-68.638l-73.264 -0.027 0.008 36.141 64.953 0.1c0.002,0 0.005,0 0.007,0 11.208,0.444 34.043,11.923 25.99,35.3 -1.996,5.795 -13.289,17.677 -29.667,17.869z"/>
</clipPath>
<clipPath id="LClip">
<path d="M634.386 212.303 L716.818 212.042 L716.937 250.458 L591.774 250.458 L591.857 65.661 L634.39 65.654 Z"/>
</clipPath>
<clipPath id="A1Clip">
<path d="M800.595 101.027 L737.063 250.187 L783.833 250.132 L823.019 155.162 Z"/>
</clipPath>
<clipPath id="A2Clip">
<!-- Polyline converted to path -->
<path d="M882.78 249.91 L810.451 65.648 L856.683 65.656 L930.988 250.044 L882.78 249.91"/>
</clipPath>
<clipPath id="Y1Clip">
<path d="M982.892 177.375 L917.762 65.662 L962.155 65.667 L1026.17 173.732 L1026.205 250.409 L982.893 250.355 Z"/>
</clipPath>
<clipPath id="Y2Clip">
<path d="M1022.463 120.704 L1055.705 64.33 L1101.708 64.752 L1044.521 158.659 Z"/>
</clipPath>
</defs>
<!-- Optional logo outline for visual inspection -->
<g id="ReferenceOutlines" fill="none" stroke="#444" stroke-width="1" opacity="0.5">
<use href="#M1Clip path" />
<use href="#M2Clip path" />
<use href="#PClip path" />
<use href="#LClip path" />
<use href="#A1Clip path" />
<use href="#A2Clip path" />
<use href="#Y1Clip path" />
<use href="#Y2Clip path" />
</g>
<!-- "Way" paths, each clipped by its own clipPath -->
<!-- !!! IT IS STILL NECESSARY TO CHECK/ADJUST 'd' WAY PATH ATTRIBUTES !!! -->
<path id="M1-way" class="flow-path" clip-path="url(#M1Clip)" d="M37.718 295.798c-2.646,-14.668 2.999,-100.213 2.999,-125.123 0,-21.6 1.484,-35.134 -2.714,-55.729 -1.447,-7.104 -6.419,-50.793 2.714,-51.139 0.292,-0.011 1.737,-2.049 11.29,-2.049 27.845,0 64.709,39.537 70.398,40.512 11.275,1.932 55.636,46.475 86.337,45.161 16.028,-0.686 26.269,10.763 47.817,-5.978 22.804,-17.716 70.27,-27.868 104.875,-55.293 13.218,-10.475 59.965,-40.693 75.313,-47.317"/>
<path id="M2-way" class="flow-path" clip-path="url(#M2Clip)" d="M436.762 276.363c-18.559,-17.564 -92.497,-34.777 -99.901,-71.096 -0.857,-4.207 97.114,-56.678 99.868,-55.913"/>
<path id="P-way" class="flow-path" clip-path="url(#PClip)" d="M453.162 79.026c4.643,-0.347 54.651,2.842 55.424,3.263 10.3,5.609 25.111,-7.867 42.41,18.482 9.279,14.134 17.907,32.496 4.25,48.652 -15.323,18.128 -29.038,11.2 -39.183,18.425 -6.237,4.441 -40.587,2.777 -42.505,6.641 -4.278,8.622 -7.145,65.174 -5.567,76.885l3.387 -0.976"/>
<path id="L-way" class="flow-path" clip-path="url(#LClip)" d="M613.123 65.658c-9.842,14.964 -4.924,73.53 -5.904,96.44 -0.053,1.248 3.321,24.045 3.321,34.478 0,53.708 34.149,26.808 72.333,34.592 3.979,0.811 28.152,-0.065 34.004,0.082"/>
<path id="A1-way" class="flow-path" clip-path="url(#A1Clip)" d="M760.447 250.159c3.397,-9.194 35.526,-89.013 35.992,-92.33 0.639,-4.547 12.391,-27.043 15.368,-29.735"/>
<path id="A2-way" class="flow-path" clip-path="url(#A2Clip)" d="M833.567 65.652c1.612,10.807 34.665,82.929 41.296,105.194 4.522,15.186 22.904,49.806 23.852,61.593 0.24,2.981 6.533,11.56 8.169,17.538"/>
<path id="Y1-way" class="flow-path" clip-path="url(#Y1Clip)" d="M939.958 65.664c15.402,11.849 24.952,58.609 38.737,74.955 2.865,3.398 24.355,32.53 24.345,32.883 -0.391,13.549 3.129,42.279 -1.822,54.288 -1.175,2.852 -0.835,18.275 -0.835,22.581"/>
<path id="Y2-way" class="flow-path" clip-path="url(#Y2Clip)" d="M1033.492 139.681c10.766,-20.678 5.838,-19.305 25.164,-35.419 6.351,-5.295 9.927,-27.648 17.931,-32.656 2.161,-1.352 2.662,-5.154 2.12,-7.065"/>
</svg>
</div>
<div class="controls">
<button id="playBtn">Play</button>
<button id="pauseBtn">Pause</button>
<button id="restartBtn">Restart</button>
</div>
<!-- Loading GSAP -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/gsap/3.12.5/gsap.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', () => {
const flowPaths = gsap.utils.toArray(".flow-path");
const playBtn = document.getElementById('playBtn');
const pauseBtn = document.getElementById('pauseBtn');
const restartBtn = document.getElementById('restartBtn');
if (!flowPaths.length) {
console.error("No .flow-path elements were found.");
gsap.set([playBtn, pauseBtn, restartBtn], { pointerEvents: "none", opacity: 0.5 });
return;
}
// Preparing paths (same as before)
flowPaths.forEach(path => {
try {
const length = path.getTotalLength();
if (isNaN(length) || length <= 0) {
console.warn(`Way ${path.id || 'without ID'} has an invalid length: ${length}. I'm hiding it..`);
gsap.set(path, { visibility: 'hidden' });
return;
}
gsap.set(path, {
strokeDasharray: length + 1, // Add a small piece just to be safe
strokeDashoffset: length + 1,
visibility: 'visible'
});
} catch (e) {
console.error(`Error processing path ${path.id || 'without ID'}:`, e);
gsap.set(path, { visibility: 'hidden' });
}
});
// GSAP Timeline (same as before)
const tl = gsap.timeline({
paused: true,
defaults: { duration: 0.8, ease: "power1.inOut" },
onComplete: () => {
gsap.set(playBtn, { pointerEvents: "none", opacity: 0.5 });
gsap.set(pauseBtn, { pointerEvents: "none", opacity: 0.5 });
gsap.set(restartBtn, { pointerEvents: "auto", opacity: 1 });
},
onStart: () => {
gsap.set(playBtn, { pointerEvents: "none", opacity: 0.5 });
gsap.set(pauseBtn, { pointerEvents: "auto", opacity: 1 });
gsap.set(restartBtn, { pointerEvents: "auto", opacity: 1 });
},
onPause: () => {
gsap.set(playBtn, { pointerEvents: "auto", opacity: 1 });
gsap.set(pauseBtn, { pointerEvents: "none", opacity: 0.5 });
},
onReverseComplete: () => {
gsap.set(playBtn, { pointerEvents: "auto", opacity: 1 });
gsap.set(pauseBtn, { pointerEvents: "none", opacity: 0.5 });
gsap.set(restartBtn, { pointerEvents: "auto", opacity: 1 });
}
});
// Add animations to the Timeline (same as before, but paths are now clipped individually)
tl.to("#M1-way", { strokeDashoffset: 0, duration: 1.5 }, "start")
.to("#M2-way", { strokeDashoffset: 0, duration: 0.7 }, "-=0.8")
.to("#P-way", { strokeDashoffset: 0, duration: 1.2 }, "-=0.4")
.to("#L-way", { strokeDashoffset: 0, duration: 0.8 }, "-=0.8")
.to("#A1-way", { strokeDashoffset: 0, duration: 0.7 }, "-=0.5")
.to("#A2-way", { strokeDashoffset: 0, duration: 0.7 }, "-=0.5")
.to("#Y1-way", { strokeDashoffset: 0, duration: 0.9 }, "-=0.4")
.to("#Y2-way", { strokeDashoffset: 0, duration: 0.7 }, "-=0.7");
// Control buttons (same as before)
playBtn.onclick = () => tl.play();
pauseBtn.onclick = () => tl.pause();
restartBtn.onclick = () => tl.restart();
gsap.set(playBtn, { pointerEvents: "auto", opacity: 1 });
gsap.set(pauseBtn, { pointerEvents: "none", opacity: 0.5 });
gsap.set(restartBtn, { pointerEvents: "none", opacity: 0.5 });
});
</script>
</body>
</html>
here is also the svg source:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Creator: CorelDRAW (Verze OEM) -->
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" width="1107px" height="317px" version="1.1" style="shape-rendering:geometricPrecision; text-rendering:geometricPrecision; image-rendering:optimizeQuality; fill-rule:evenodd; clip-rule:evenodd"
viewBox="0 0 1107 317.365"
xmlns:xlink="http://www.w3.org/1999/xlink"
xmlns:xodm="http://www.corel.com/coreldraw/odm/2003">
<defs>
<style type="text/css">
<![CDATA[
.str0 {stroke:black;stroke-width:0.999;stroke-miterlimit:22.9256}
.fil0 {fill:none}
]]>
</style>
</defs>
<g id="Vrstva_x0020_1">
<metadata id="CorelCorpID_0Corel-Layer"/>
<polygon id="Y2" class="fil0 str0" points="1022.463,120.704 1055.705,64.33 1101.708,64.752 1044.521,158.659 "/>
<polygon id="Y1" class="fil0 str0" points="982.892,177.375 917.762,65.662 962.155,65.667 1026.17,173.732 1026.205,250.409 982.893,250.355 "/>
<polyline id="A2" class="fil0 str0" points="882.78,249.91 810.451,65.648 856.683,65.656 930.988,250.044 882.78,249.91 "/>
<polygon id="A1" class="fil0 str0" points="800.595,101.027 737.063,250.187 783.833,250.132 823.019,155.162 "/>
<polygon id="L" class="fil0 str0" points="634.386,212.303 716.818,212.042 716.937,250.458 591.774,250.458 591.857,65.661 634.39,65.654 "/>
<path id="P" class="fil0 str0" d="M514.45 155.045l-61.303 0.192 0.014 95.167 36.434 -0.012 0.15 -64.311 35.972 -0.197c0.001,0 0.002,0 0.004,0 38.939,-2.914 51.653,-36.571 52.812,-51.584 3.563,-46.166 -36.98,-68.893 -52.11,-68.638l-73.264 -0.027 0.008 36.141 64.953 0.1c0.002,0 0.005,0 0.007,0 11.208,0.444 34.043,11.923 25.99,35.3 -1.996,5.795 -13.289,17.677 -29.667,17.869z"/>
<polygon id="M2" class="fil0 str0" points="436.696,184.286 436.762,114.422 257.34,211.955 436.762,311.657 436.762,241.07 385.367,212.086 "/>
<polygon id="M1" class="fil0 str0" points="8.238,5.661 8.316,311.289 67.119,280.307 66.667,102.363 225.536,188.34 436.888,72.236 436.606,5.449 225.677,122.82 "/>
</g>
</svg>
This is a great animation idea, as well as the execution of this idea using SVG. It's an amazing technology for this kind of job.
As for GSAP, consider it is just a clever way to orderly and sequentially set any HTML or SVG elements' property over duration of time. If there are other libraries that give the same functionality it is always welcomed.
Some folks may prefer composing animation using timeline, some might find it's not that visually comprehensible and playback controllable. Personally, having separate pieces of animation stitched together using onComplete
callbacks gives one more control over course of movements of animation parts. This is of course just a matter of personal preference.
Your approach to use getTotalLength()
of an SVG path, and then set that length to the path's stroke-dashoffset
and stroke-dasharray
is a sure way to go.
Then you're animating/simulating path drawing by setting stroke-dashoffset
towards 0
it will appear as if path just start drawing itself from the start to end.
Instead of creating big thick path and then cut-out letter from it, you could just draw the letter using the path in the first place. Much nicer, much cleaner and it doesn't require too much clipping them paths afterwards.
However, to achieve those sharp edges at the ends of the path (talking about capital "M" letter bottom left end, where the animation starts, and triangular shape in the second half of "M"), you do need to cut out those parts of the path manually.
You could use SVG mask
for this purpose.
<mask id="mask_1" maskUnits='userSpaceOnUse'>
<rect fill="white" x="0" y="0" width="100%" height="100%" />
<rect fill="black" x="305" y="0" width="25" height="240" />
<polygon fill="black" points="50,195 100,170 100,195" />
</mask>
<g mask='url(#mask_1)'>
<path d="M72 192 72 48 192 120 312 48" />
<path d="M312 96 240 139.2 312 182.4" />
</g>
when applied to an SVG element, path
, or to a group
containing the path
,
the rule is that all the white regions in the mask
would be visible, all blacks regions will be subtracted/hidden.
(Specifying maskUnits='userSpaceOnUse'
attribute instead of default 'objectBoundingBox'
fixed/removed unneeded clipping near mask's border for me, so might better to keep that property set.)
To achieve other effects of animation, like scaling, moving, and then finally fading out:
Use pattern approved by GSAP.
Create an object with property that would be changed. Pass that object as a target to gsap calls, set object's property as a changing target and set desired value towards which the animation would progress. And most importantly use onUpdate
callback in the config where you have to update the real HTML or SVG element that needs to be animated.
This pattern is useful when you need to animate any other than CSS property, like SVG property offset
in the following example, or transform
property of SVG element.
For example:
This will update offset
attribute of the svgStopElement
element, from 0
to 1
. Despite that we update offsetUpdatedObject
we then apply the calculated value to svgStopElement
inside onUpdate
callback.
const offsetUpdatedObject = {
offset: 0
};
gsap.to(offsetUpdatedObject, {
offset: 1,
ease: "none",
duration: 4,
onUpdate: () => {
svgStopElement.setAttribute("offset", offsetUpdatedObject.offset);
}
});
To learn how you need to update transform
property of the SVG element in order to move it and scale it, refer to MDN Documentation - https://developer.mozilla.org/en-US/docs/Web/SVG/Tutorials/SVG_from_scratch/Basic_transformations
To get you an idea, here's CodePen draft - https://codepen.io/ajishiguma/pen/EaxdOMM
As a bonus, here is a really nice online tool to work with SVG paths, create and edit path
's commands with ease - https://yqnn.github.io/svg-path-editor/
*** For the paths in CodePen solution, if you decided to use stroke-linecap='square'
instead of default 'butt'
, then make bigger clipping regions in the mask
to take into account that square linecap gives slightly more path 'material' at its ends when you're stroking the path.
There's nice illustration about linecap in the MDN Docs - https://developer.mozilla.org/en-US/docs/Web/SVG/Reference/Attribute/stroke-linecap