{"id":8537,"date":"2026-02-11T10:04:14","date_gmt":"2026-02-11T15:04:14","guid":{"rendered":"https:\/\/frontendmasters.com\/blog\/?p=8537"},"modified":"2026-02-11T11:09:26","modified_gmt":"2026-02-11T16:09:26","slug":"how-to-create-a-css-only-elastic-text-effect","status":"publish","type":"post","link":"https:\/\/frontendmasters.com\/blog\/how-to-create-a-css-only-elastic-text-effect\/","title":{"rendered":"How to Create a CSS-only Elastic Text Effect"},"content":{"rendered":"\n<p id=\"how-to-create-a-css-only-elastic-text-effect\">Text effects where each letter animates separately are always cool and eye-catching. Such staggered animations are often achieved with JavaScript libraries, making their code a bit heavy for the relatively small design effect we&#8217;re usually shooting for. In this article, we will explore tricks to achieve a fancy text effect with just CSS and without the need of JavaScript (meaning will do the character-splitting by hand).<\/p>\n\n\n\n<p class=\"learn-more\">At the time of writing, only Chrome and Edge have full support of the features we will be using.<\/p>\n\n\n\n<p>Hover the text in the demo below and see the magic in play:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_xbOzxyp\" src=\"\/\/codepen.io\/anon\/embed\/xbOzxyp?height=450&amp;theme-id=1&amp;slug-hash=xbOzxyp&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed xbOzxyp\" title=\"CodePen Embed xbOzxyp\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Cool, right? We have a realistic elastic effect with nothing but CSS. It\u2019s also flexible and easy to adjust. Before we dig into the code, let me start with an important warning. It\u2019s a nice effect but it comes with several drawbacks.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"important-disclaimer\">Important Disclaimer About Accessibility<\/h2>\n\n\n\n<p>The effect we are making relies on splitting words into letters, which, in general, is a very bad idea.<\/p>\n\n\n\n<p>A simple link with a word in it is normally like this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-1\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span>&gt;<\/span>About<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-1\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>But we need to target and style individual letters, so we&#8217;ll be doing this:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-2\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>A<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>b<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>o<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>u<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>t<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-2\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>This has accessibility drawbacks. <\/p>\n\n\n\n<p>There is a strong temptation to use <code>aria-*<\/code> attributes to fix that up. Or that&#8217;s what I thought, anyway. I found a few online resources that recommend using a structure similar to this one:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-3\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span> <span class=\"hljs-attr\">aria-label<\/span>=<span class=\"hljs-string\">\"About\"<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span> <span class=\"hljs-attr\">aria-hidden<\/span>=<span class=\"hljs-string\">\"true\"<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>A<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>b<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>o<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>u<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>t<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-3\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Looks good, right? No! That structure is still terrible. Actually, most of the structures you will find online are bad. I am not an expert in the field, so I asked around, and two blog posts by <a href=\"https:\/\/adrianroselli.com\/\">Adrian Roselli<\/a> emerged:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/adrianroselli.com\/2026\/01\/barriers-from-links-with-aria.html\">Barriers from Links with ARIA<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/adrianroselli.com\/2026\/02\/you-know-what-just-dont-split-words-into-letters.html\">You Know What? Just Don\u2019t Split Words into Letters<\/a><\/li>\n<\/ul>\n\n\n\n<p>I highly recommend you read them to understand why splitting words is a bad idea (and what the potential solutions might be).<\/p>\n\n\n\n<p>So why am I making this demo anyway?<\/p>\n\n\n\n<p>I consider it more of a CSS experiment to explore modern features. That effect probably contains many properties that you are not aware of so it\u2019s a good opportunity to discover them. Use it for fun or within a side project, but think twice before including it anywhere in widespread use or mission critical.<\/p>\n\n\n\n<p>Now that you are warned, let\u2019s get started.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"how-does-it-work\">How Does It Work?<\/h2>\n\n\n\n<p>The idea is to use the <code>offset()<\/code> property and define a path that the letters should follow. That path will be a curve that we animate along. The <code>offset()<\/code> property is an underrated feature, but it has a lot of potential, especially when combined with modern features. I used it to create <a href=\"https:\/\/frontendmasters.com\/blog\/infinite-marquee-animation-using-modern-css\/\">an infinite marquee animation<\/a>, to perfectly <a href=\"https:\/\/css-tip.com\/images-circle\/\">position elements around a circle<\/a>, to create a <a href=\"https:\/\/css-tip.com\/circular-gallery\/\">fancy gallery of images<\/a>, and so on.<\/p>\n\n\n\n<p>Here is a simplified example to understand the trick we will be using:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_NPrBVGd\/27954986873d1066d35e718b6dc9f524\" src=\"\/\/codepen.io\/anon\/embed\/NPrBVGd\/27954986873d1066d35e718b6dc9f524?height=450&amp;theme-id=1&amp;slug-hash=NPrBVGd\/27954986873d1066d35e718b6dc9f524&amp;default-tab=css,result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed NPrBVGd\/27954986873d1066d35e718b6dc9f524\" title=\"CodePen Embed NPrBVGd\/27954986873d1066d35e718b6dc9f524\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>The demo above uses <code>path()<\/code> values, which comes from SVG. The three letters initially follow the first one. On hover, I switch to the second path. Thanks to the transition, we have a nice effect.<\/p>\n\n\n\n<p>Unfortunately, using SVG is not ideal because you can only create static pixel-based paths that cannot be controlled with CSS. Instead, we are going to rely on <a href=\"https:\/\/frontendmasters.com\/blog\/shape-a-new-powerful-drawing-syntax-in-css\/\">the new <code>shape()<\/code> function,<\/a> which allows us to define complex shapes (including curves) that we can easily control using CSS.<\/p>\n\n\n\n<p>In this article, I will consider a simple usage for <code>shape()<\/code> as we only need one curve, but if you want to explore this powerful function, here are some of my previous articles:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><a href=\"https:\/\/frontendmasters.com\/blog\/creating-flower-shapes-using-clip-path-shape\/\">Creating Flower Shapes using clip-path: shape()<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/frontendmasters.com\/blog\/creating-blob-shapes-using-clip-path-shape\/\">Creating Blob Shapes using clip-path: shape()<\/a><\/li>\n\n\n\n<li><a href=\"https:\/\/css-tricks.com\/better-css-shapes-using-shape-part-1-lines-and-arcs\/\">Better CSS Shapes Using shape()<\/a><\/li>\n<\/ul>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"lets-write-some-code\">Let\u2019s write some code<\/h2>\n\n\n\n<p>The HTML I will work with:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-4\" data-shcb-language-name=\"HTML, XML\" data-shcb-language-slug=\"xml\"><span><code class=\"hljs language-xml\"><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">ul<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">li<\/span>&gt;<\/span>\n    <span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">a<\/span> <span class=\"hljs-attr\">href<\/span>=<span class=\"hljs-string\">\"#\"<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>A<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>b<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>o<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>u<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;<span class=\"hljs-name\">span<\/span>&gt;<\/span>t<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">span<\/span>&gt;<\/span><span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">a<\/span>&gt;<\/span>\n  <span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">li<\/span>&gt;<\/span>\n  <span class=\"hljs-comment\">&lt;!-- more li elements --&gt;<\/span>\n<span class=\"hljs-tag\">&lt;\/<span class=\"hljs-name\">ul<\/span>&gt;<\/span><\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-4\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">HTML, XML<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">xml<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>The CSS:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-5\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">display<\/span>: flex;\n  <span class=\"hljs-attribute\">font-family<\/span>: monospace;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span> <span class=\"hljs-selector-tag\">span<\/span> {\n  <span class=\"hljs-attribute\">offset-path<\/span>: <span class=\"hljs-built_in\">shape<\/span>(???);\n  <span class=\"hljs-attribute\">offset-distance<\/span>: ???;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span><span class=\"hljs-selector-pseudo\">:hover<\/span> {\n  <span class=\"hljs-attribute\">offset-path<\/span>: <span class=\"hljs-built_in\">shape<\/span>(???);\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-5\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<details class=\"wp-block-details is-layout-flow wp-block-details-is-layout-flow\"><summary>Nothing fancy so far<\/summary>\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_emzjazr\/2a630d418787b3bd41a2bd25faba0ae8\" src=\"\/\/codepen.io\/anon\/embed\/emzjazr\/2a630d418787b3bd41a2bd25faba0ae8?height=450&amp;theme-id=1&amp;slug-hash=emzjazr\/2a630d418787b3bd41a2bd25faba0ae8&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed emzjazr\/2a630d418787b3bd41a2bd25faba0ae8\" title=\"CodePen Embed emzjazr\/2a630d418787b3bd41a2bd25faba0ae8\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p><\/p>\n<\/details>\n\n\n\n<p>A flexbox configuration to place the letters side-by-side and a monospace font because we need all the letters to have the same width.<\/p>\n\n\n\n<p>Next, we define the path using the following code:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-6\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">offset-path<\/span>: <span class=\"hljs-selector-tag\">shape<\/span>(<span class=\"hljs-selector-tag\">from<\/span> <span class=\"hljs-selector-tag\">Xa<\/span> <span class=\"hljs-selector-tag\">Ya<\/span>, <span class=\"hljs-selector-tag\">curve<\/span> <span class=\"hljs-selector-tag\">to<\/span> <span class=\"hljs-selector-tag\">Xb<\/span> <span class=\"hljs-selector-tag\">Yb<\/span> <span class=\"hljs-selector-tag\">with<\/span> <span class=\"hljs-selector-tag\">Xc<\/span> <span class=\"hljs-selector-tag\">Yc<\/span> \/ <span class=\"hljs-selector-tag\">Xd<\/span> <span class=\"hljs-selector-tag\">Yd<\/span> );<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-6\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>I am using the curve command to draw a Bezier curve from A to B, with two control points, C and D.<\/p>\n\n\n\n<figure class=\"wp-block-image size-full\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"693\" height=\"352\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/DU1tNgpr.png?resize=693%2C352&#038;ssl=1\" alt=\"A diagram illustrating a curve with points labeled A, B, C, and D. Points A and B are endpoints of the curve, while C and D are control points affecting the shape of the curve.\" class=\"wp-image-8540\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/DU1tNgpr.png?w=693&amp;ssl=1 693w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/DU1tNgpr.png?resize=300%2C152&amp;ssl=1 300w\" sizes=\"auto, (max-width: 693px) 100vw, 693px\" \/><\/figure>\n\n\n\n<p>Then I will animate the curve by adjusting the coordinates of the control points, specifically their Y value. When it is equal to the Y value of A and B, we get a straight line. When it\u2019s bigger, we get a curve.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"1024\" height=\"228\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/LFV3iqIt.png?resize=1024%2C228&#038;ssl=1\" alt=\"Illustration comparing two shapes: a curved path on the left and a straight line on the right, demonstrating the transition between the two forms.\" class=\"wp-image-8541\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/LFV3iqIt.png?resize=1024%2C228&amp;ssl=1 1024w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/LFV3iqIt.png?resize=300%2C67&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/LFV3iqIt.png?resize=768%2C171&amp;ssl=1 768w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/LFV3iqIt.png?w=1130&amp;ssl=1 1130w\" sizes=\"auto, (max-width: 1000px) 100vw, 1000px\" \/><\/figure>\n\n\n\n<p>The code of the curve will look like this:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">offset-path: shape(from Xa Y, curve to Xb Y with Xc Y1 \/ Xd Y1);<\/pre>\n\n\n\n<p>And the one of the line will look like this:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">offset-path: shape(from Xa Y, curve to Xb Y with Xc Y \/ Xd Y);<\/pre>\n\n\n\n<p>Notice how we are only changing the coordinate of the control points while everything else remains static.<\/p>\n\n\n\n<p>Now let\u2019s identify the different values. Two things to consider when working with offset:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>It\u2019s defined on the child elements, but the reference box is the parent container.<\/li>\n\n\n\n<li>By default, we consider the center of the element when placing it on the path.<\/li>\n<\/ol>\n\n\n\n<figure class=\"wp-block-image size-full\"><img data-recalc-dims=\"1\" loading=\"lazy\" decoding=\"async\" width=\"772\" height=\"263\" src=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/WSTDsr-l.png?resize=772%2C263&#038;ssl=1\" alt=\"Illustration showing the word 'PORTFOLIO' with red arrows pointing to points A and B, indicating the horizontal center along a dashed red line.\" class=\"wp-image-8542\" srcset=\"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/WSTDsr-l.png?w=772&amp;ssl=1 772w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/WSTDsr-l.png?resize=300%2C102&amp;ssl=1 300w, https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/WSTDsr-l.png?resize=768%2C262&amp;ssl=1 768w\" sizes=\"auto, (max-width: 772px) 100vw, 772px\" \/><\/figure>\n\n\n\n<p>The first letter should be at the beginning of the path, and the last one at the end, so A is at the center of the first letter and B at the center of the last one<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">Y = 50%<br>Xa = .5ch<br>Xb = 100% - Xa = 100% - .5ch<\/pre>\n\n\n\n<p>For C and D, we don\u2019t have any particular rules to follow, so you can specify any value for the X coordinate. I will pick <code>30%<\/code> for <code>Xc<\/code>, and <code>Xd<\/code> will be <code>100% - Xc = 70%<\/code>. Feel free to adjust the values to test different variations of the curve.<\/p>\n\n\n\n<p>Our path is now ready:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">offset-path: shape(from .5ch 50%, curve to calc(100% - .5ch) 50% with 30% Y \/ 70% Y);<\/pre>\n\n\n\n<p>The <code>Y<\/code> value is our variable, and it will be either <code>50%<\/code> (same as A and B) or another value, let\u2019s define it as <code>50% - H<\/code>. The bigger <code>H<\/code> will be, the more elasticity we will have.<\/p>\n\n\n\n<p>Let\u2019s try it:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_GgqBaLW\/21c70a7670255dc144ccc4aef38f48bc\" src=\"\/\/codepen.io\/anon\/embed\/GgqBaLW\/21c70a7670255dc144ccc4aef38f48bc?height=450&amp;theme-id=1&amp;slug-hash=GgqBaLW\/21c70a7670255dc144ccc4aef38f48bc&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed GgqBaLW\/21c70a7670255dc144ccc4aef38f48bc\" title=\"CodePen Embed GgqBaLW\/21c70a7670255dc144ccc4aef38f48bc\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>It\u2019s a mess! We didn\u2019t define the <code>offset-distance<\/code>, which makes all the letters overlap.<\/p>\n\n\n\n<p class=\"learn-more\">Should we define a position for each letter? Nah, that&#8217;s too much work.<\/p>\n\n\n\n<p>We are obliged to define a different position for each letter, but the good thing is that we can do it with one formula using the <code>sibling-index()<\/code>and <code>sibling-count()<\/code> functions.<\/p>\n\n\n\n<p>The first letter should be at <code>0%<\/code> and the last one at <code>100%<\/code>. We have N letters, which means we need a step equal to <code>100%\/(N - 1)<\/code> to place all the letters from <code>0%<\/code> to <code>100%<\/code>, hence the following formula:<\/p>\n\n\n\n<pre class=\"wp-block-preformatted\">offset-distance: (100% * i)\/(N - 1)<\/pre>\n\n\n\n<p>Where <code>i<\/code> is 0-indexed.<\/p>\n\n\n\n<p>Written in CSS, we get:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-7\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">offset-distance<\/span>: <span class=\"hljs-selector-tag\">calc<\/span>(100%*(<span class=\"hljs-selector-tag\">sibling-index<\/span>() <span class=\"hljs-selector-tag\">-<\/span> 1)\/(<span class=\"hljs-selector-tag\">sibling-count<\/span>() <span class=\"hljs-selector-tag\">-<\/span> 1))<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-7\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_emzjwOo\/13f9624d546be9a432291ad251fa7833\" src=\"\/\/codepen.io\/anon\/embed\/emzjwOo\/13f9624d546be9a432291ad251fa7833?height=450&amp;theme-id=1&amp;slug-hash=emzjwOo\/13f9624d546be9a432291ad251fa7833&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed emzjwOo\/13f9624d546be9a432291ad251fa7833\" title=\"CodePen Embed emzjwOo\/13f9624d546be9a432291ad251fa7833\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Almost perfect. All the letters are correctly placed except the last one. For some reason, the <code>0%<\/code> and <code>100%<\/code> value are the same. <code>offset-distance<\/code> is not limited to values between <code>0%<\/code> and <code>100%<\/code> but can take any value (including negative ones) and there is a modulo ting that creates a kind of loop. You can travel the entire path from <code>0%<\/code> to <code>100%<\/code>, and starting from <code>100%<\/code>, you return to the initial point, and you can repeat the same from <code>100%<\/code> to <code>200%<\/code>, and so on.<\/p>\n\n\n\n<p>Well, it\u2019s a bit strange and not intuitive, but the fix is simple: we change <code>100%<\/code> with <code>99.9%<\/code>. Hacky, but it works!<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_gbMjNpG\/f0d4e2cbfde383d78265cd148ee2fe1c\" src=\"\/\/codepen.io\/anon\/embed\/gbMjNpG\/f0d4e2cbfde383d78265cd148ee2fe1c?height=450&amp;theme-id=1&amp;slug-hash=gbMjNpG\/f0d4e2cbfde383d78265cd148ee2fe1c&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed gbMjNpG\/f0d4e2cbfde383d78265cd148ee2fe1c\" title=\"CodePen Embed gbMjNpG\/f0d4e2cbfde383d78265cd148ee2fe1c\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Now the placement is perfect, and on hover, you can see how the straight line becomes a curve.<\/p>\n\n\n\n<p>The last step is to add a transition, and we are done!<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_zxBLVvO\/030717c226d8c9dd59ebc081ba23968a\" src=\"\/\/codepen.io\/anon\/embed\/zxBLVvO\/030717c226d8c9dd59ebc081ba23968a?height=450&amp;theme-id=1&amp;slug-hash=zxBLVvO\/030717c226d8c9dd59ebc081ba23968a&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed zxBLVvO\/030717c226d8c9dd59ebc081ba23968a\" title=\"CodePen Embed zxBLVvO\/030717c226d8c9dd59ebc081ba23968a\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Maybe not quite done, as the animation seems broken. It\u2019s probably a bug (that I have filled <a href=\"https:\/\/issues.chromium.org\/issues\/482074624\">here<\/a>), but it\u2019s not a big deal because I was going to refactor the code to avoid writing the same shape twice and instead animate a variable.<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-8\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-keyword\">@property<\/span> --_s {\n  <span class=\"hljs-selector-tag\">syntax<\/span>: \"&lt;<span class=\"hljs-selector-tag\">number<\/span>&gt;\";\n  <span class=\"hljs-selector-tag\">initial-value<\/span>: 0;\n  <span class=\"hljs-selector-tag\">inherits<\/span>: <span class=\"hljs-selector-tag\">true<\/span>;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">--h<\/span>: <span class=\"hljs-number\">20px<\/span>; <span class=\"hljs-comment\">\/* control the effect *\/<\/span>\n \n  <span class=\"hljs-attribute\">display<\/span>: flex;\n  <span class=\"hljs-attribute\">font<\/span>: bold <span class=\"hljs-number\">40px<\/span> monospace;\n  <span class=\"hljs-attribute\">transition<\/span>: --_s .<span class=\"hljs-number\">3s<\/span>;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span><span class=\"hljs-selector-pseudo\">:hover<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: <span class=\"hljs-number\">1<\/span>;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span> <span class=\"hljs-selector-tag\">span<\/span> {\n  <span class=\"hljs-attribute\">offset-path<\/span>: \n    <span class=\"hljs-built_in\">shape<\/span>(\n      from .<span class=\"hljs-number\">5ch<\/span> <span class=\"hljs-number\">50%<\/span>, curve to calc(<span class=\"hljs-number\">100%<\/span> - .<span class=\"hljs-number\">5ch<\/span>) <span class=\"hljs-number\">50%<\/span> \n      with <span class=\"hljs-number\">30%<\/span> <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--_s)*<span class=\"hljs-built_in\">var<\/span>(--h)) \/ <span class=\"hljs-number\">70%<\/span> <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">50%<\/span> - var(--_s)*<span class=\"hljs-built_in\">var<\/span>(--h))\n    );\n  <span class=\"hljs-attribute\">offset-distance<\/span>: <span class=\"hljs-built_in\">calc<\/span>(<span class=\"hljs-number\">99.9%<\/span>*(sibling-index() - <span class=\"hljs-number\">1<\/span>)\/(<span class=\"hljs-built_in\">sibling-count<\/span>() - <span class=\"hljs-number\">1<\/span>));\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-8\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Now you have the <code>--h<\/code> variable you can adjust the control the curvature of the path and another internal variable that we animate from 0 to 1 to move from a straight line to a curve.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_LEZJpGN\/eee38841cac916498c3b0217b9979de6\" src=\"\/\/codepen.io\/anon\/embed\/LEZJpGN\/eee38841cac916498c3b0217b9979de6?height=450&amp;theme-id=1&amp;slug-hash=LEZJpGN\/eee38841cac916498c3b0217b9979de6&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed LEZJpGN\/eee38841cac916498c3b0217b9979de6\" title=\"CodePen Embed LEZJpGN\/eee38841cac916498c3b0217b9979de6\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Tada! The animation is now perfect! <em>But where is the elastic effect?<\/em><\/p>\n\n\n\n<p>To get the elastic effect, we need to update the easing and rely on <code>linear()<\/code>. That\u2019s the simplest part because I am going to use a <a href=\"https:\/\/linear-easing-generator.netlify.app\/\">generator<\/a> to get the value.<\/p>\n\n\n\n<p>Play with the config until you get what looks good to you. Here&#8217;s where I landed:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_xbOawOG\/8280e887a49220a6367bbce5a5aba186\" src=\"\/\/codepen.io\/anon\/embed\/xbOawOG\/8280e887a49220a6367bbce5a5aba186?height=450&amp;theme-id=1&amp;slug-hash=xbOawOG\/8280e887a49220a6367bbce5a5aba186&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed xbOawOG\/8280e887a49220a6367bbce5a5aba186\" title=\"CodePen Embed xbOawOG\/8280e887a49220a6367bbce5a5aba186\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Now it\u2019s good, but it can be improved if we adjust the curve slightly. Right now, the \u201cheight\u201d of the curve is the same for all the words, but it would be ideal to have it based on the length of the word. For this, I will include <code>sibling-count()<\/code>within the formula so that the height gets bigger when the word gets wider.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_xbOjoKR\" src=\"\/\/codepen.io\/anon\/embed\/xbOjoKR?height=450&amp;theme-id=1&amp;slug-hash=xbOjoKR&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed xbOjoKR\" title=\"CodePen Embed xbOjoKR\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"making-the-effect-direction-aware\">Making the effect direction-aware<\/h2>\n\n\n\n<p>Our effect is good, but while we&#8217;re here, let&#8217;s go the extra mile. Let\u2019s upgrade it and make it direction-aware. The idea is to have either a bottom curvature or a top one based on the direction of the mouse.<\/p>\n\n\n\n<p>We already have the top curve making the variable <code>--_s<\/code> equal to 1:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-9\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> <span class=\"hljs-selector-tag\">a<\/span><span class=\"hljs-selector-pseudo\">:hover<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: <span class=\"hljs-number\">1<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-9\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>If you change the value to <code>-1<\/code>, you get a bottom curve:<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_pvbObWB\/faac607f9d2c61b2ac7c368eda5619b4\" src=\"\/\/codepen.io\/anon\/embed\/pvbObWB\/faac607f9d2c61b2ac7c368eda5619b4?height=450&amp;theme-id=1&amp;slug-hash=pvbObWB\/faac607f9d2c61b2ac7c368eda5619b4&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed pvbObWB\/faac607f9d2c61b2ac7c368eda5619b4\" title=\"CodePen Embed pvbObWB\/faac607f9d2c61b2ac7c368eda5619b4\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Now, we need to combine both somehow. When hovering from the top, we should get the bottom curve <code>--_s: -1<\/code>, and when hovering from the bottom, we should get the top curve <code>--_s: 1<\/code>.<\/p>\n\n\n\n<p>First, I will add a pseudo-element of the <code>li<\/code> that fills the upper half of the element and is placed above the link:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-10\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span> {\n  <span class=\"hljs-attribute\">position<\/span>: relative;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span><span class=\"hljs-selector-pseudo\">:after<\/span> {\n  <span class=\"hljs-attribute\">content<\/span>: <span class=\"hljs-string\">\"\"<\/span>;\n  <span class=\"hljs-attribute\">position<\/span>: absolute;\n  <span class=\"hljs-attribute\">inset<\/span>: <span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">0<\/span> <span class=\"hljs-number\">50%<\/span>;\n  <span class=\"hljs-attribute\">cursor<\/span>: pointer;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-10\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_vEKzKPd\/d6ac5fba94d1a13fffa7ccb441f89340\" src=\"\/\/codepen.io\/anon\/embed\/vEKzKPd\/d6ac5fba94d1a13fffa7ccb441f89340?height=450&amp;theme-id=1&amp;slug-hash=vEKzKPd\/d6ac5fba94d1a13fffa7ccb441f89340&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed vEKzKPd\/d6ac5fba94d1a13fffa7ccb441f89340\" title=\"CodePen Embed vEKzKPd\/d6ac5fba94d1a13fffa7ccb441f89340\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>From there, we can define two different selectors. When we hover the pseudo-element, it means we are also hovering the <code>li<\/code> element, so we can use:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-11\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span><span class=\"hljs-selector-pseudo\">:hover<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: -<span class=\"hljs-number\">1<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-11\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>When we hover the <code>a<\/code> element, we are also hovering the <code>li<\/code> element, so the above will also get triggered. but if we are hovering the pseudo-element, we are not hovering <code>a<\/code>, so we can use the following:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-12\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span><span class=\"hljs-selector-pseudo\">:has(a<\/span><span class=\"hljs-selector-pseudo\">:hover)<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: <span class=\"hljs-number\">1<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-12\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>Are you a bit lost? Don\u2019t worry, let\u2019s place both selectors together and see what is happening:<\/p>\n\n\n<pre class=\"wp-block-code\" aria-describedby=\"shcb-language-13\" data-shcb-language-name=\"CSS\" data-shcb-language-slug=\"css\"><span><code class=\"hljs language-css\"><span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span><span class=\"hljs-selector-pseudo\">:hover<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: -<span class=\"hljs-number\">1<\/span>;\n}\n<span class=\"hljs-selector-tag\">ul<\/span> <span class=\"hljs-selector-tag\">li<\/span><span class=\"hljs-selector-pseudo\">:has(a<\/span><span class=\"hljs-selector-pseudo\">:hover)<\/span> <span class=\"hljs-selector-tag\">a<\/span> {\n  <span class=\"hljs-attribute\">--_s<\/span>: <span class=\"hljs-number\">1<\/span>;\n}<\/code><\/span><small class=\"shcb-language\" id=\"shcb-language-13\"><span class=\"shcb-language__label\">Code language:<\/span> <span class=\"shcb-language__name\">CSS<\/span> <span class=\"shcb-language__paren\">(<\/span><span class=\"shcb-language__slug\">css<\/span><span class=\"shcb-language__paren\">)<\/span><\/small><\/pre>\n\n\n<p>We can either hover our element from the top (through the pseudo-element) or from the bottom (through the <code>a<\/code> element). The first case will trigger the first selector because we are also hovering <code>li<\/code>, BUT will not trigger the second one because the <code>li<\/code> \u201cis not having its <code>a<\/code> hovered\u201d. Now, when hovering the <code>a<\/code> element, both selectors will get triggered, and the last one will win.<\/p>\n\n\n\n<p>We have our direction-aware feature!<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_NPrLRvW\/dbb4640d364f259f13991541444be319\" src=\"\/\/codepen.io\/anon\/embed\/NPrLRvW\/dbb4640d364f259f13991541444be319?height=450&amp;theme-id=1&amp;slug-hash=NPrLRvW\/dbb4640d364f259f13991541444be319&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed NPrLRvW\/dbb4640d364f259f13991541444be319\" title=\"CodePen Embed NPrLRvW\/dbb4640d364f259f13991541444be319\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>It works, but it\u2019s not as fluid as the demo I shared in the introduction. When the mouse moves the whole element, it abruptly stops one animation and triggers the other one.<\/p>\n\n\n\n<p>To fix this, we can play with the size of the pseudo-element. When we hover it, we increase its size so it fills the entire element. This will prevent the second animation from getting triggered as we can no longer hover the <code>a<\/code> element below it. And when hovering the <code>a<\/code> element, we make the size of the pseudo-element equal to 0 hence we cannot hover it and trigger the first animation.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_ogLPYdv\/79912c8e500b063e67ea7f0501ae21d8\" src=\"\/\/codepen.io\/anon\/embed\/ogLPYdv\/79912c8e500b063e67ea7f0501ae21d8?height=450&amp;theme-id=1&amp;slug-hash=ogLPYdv\/79912c8e500b063e67ea7f0501ae21d8&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed ogLPYdv\/79912c8e500b063e67ea7f0501ae21d8\" title=\"CodePen Embed ogLPYdv\/79912c8e500b063e67ea7f0501ae21d8\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<p>Much better! We make the pseudo-element transparent, and the illusion is perfect.<\/p>\n\n\n\n<div class=\"wp-block-cp-codepen-gutenberg-embed-block cp_embed_wrapper\"><iframe id=\"cp_embed_xbOzxyp\" src=\"\/\/codepen.io\/anon\/embed\/xbOzxyp?height=450&amp;theme-id=1&amp;slug-hash=xbOzxyp&amp;default-tab=result\" height=\"450\" scrolling=\"no\" frameborder=\"0\" allowfullscreen allowpaymentrequest name=\"CodePen Embed xbOzxyp\" title=\"CodePen Embed xbOzxyp\" class=\"cp_embed_iframe\" style=\"width:100%;overflow:hidden\">CodePen Embed Fallback<\/iframe><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"conclusion\">Conclusion<\/h2>\n\n\n\n<p>I hope you enjoyed this fun CSS experiment. I will repeat it again: think twice before using it in your project. It was a great demo to explore some modern features such as <code>shape()<\/code>, <code>linear()<\/code>, <code>sibling-index()<\/code>, etc., but it\u2019s not a good idea to break accessibility for such an effect.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>What can we say except BOINNNGGG BOINNGGGGGG. <\/p>\n","protected":false},"author":12,"featured_media":8546,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_acf_changed":false,"sig_custom_text":"","sig_image_type":"featured-image","sig_custom_image":0,"sig_is_disabled":false,"inline_featured_image":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"categories":[1],"tags":[134,7,260,352,250],"class_list":["post-8537","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-blog-post","tag-property","tag-css","tag-linear","tag-shape","tag-sibling-index"],"acf":[],"jetpack_featured_media_url":"https:\/\/i0.wp.com\/frontendmasters.com\/blog\/wp-content\/uploads\/2026\/02\/wiggle.jpg?fit=2000%2C1200&ssl=1","jetpack_sharing_enabled":true,"_links":{"self":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8537","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/users\/12"}],"replies":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/comments?post=8537"}],"version-history":[{"count":13,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8537\/revisions"}],"predecessor-version":[{"id":8588,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/posts\/8537\/revisions\/8588"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media\/8546"}],"wp:attachment":[{"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/media?parent=8537"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/categories?post=8537"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/frontendmasters.com\/blog\/wp-json\/wp\/v2\/tags?post=8537"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}