Weaving a Line Through Text in CSS
Earlier this year, I came across this demo by Florin Pop, which makes a line go either over or under the letters of a single line heading. I thought this was a cool idea, but there were a few little things about the implementation I felt I could simplify and improve at the same time.
First off, the original demo duplicates the headline text, which I knew could be easily avoided. Then there’s the fact that the length of the line going through the text is a magic number, which is not a very flexible approach. And finally, can’t we get rid of the JavaScript?
So let’s take a look into where I ended up taking this.
HTML structure
Florin puts the text into a heading element and then duplicates this heading, using Splitting.js to replace the text content of the duplicated heading with spans, each containing one letter of the original text.
Already having decided to do this without text duplication, using a library to split the text into characters and then put each into a span
feels a bit like overkill, so we’re doing it all with an HTML preprocessor.
- let text = 'We Love to Play';
- let arr = text.split('');
h1(role='image' aria-label=text)
- arr.forEach(letter => {
span.letter #{letter}
- });
Since splitting text into multiple elements may not work nicely with screen readers, we’ve given the whole thing a role
of image
and an aria-label
.
This generates the following HTML:
<h1 role="image" aria-label="We Love to Play">
<span class="letter">W</span>
<span class="letter">e</span>
<span class="letter"> </span>
<span class="letter">L</span>
<span class="letter">o</span>
<span class="letter">v</span>
<span class="letter">e</span>
<span class="letter"> </span>
<span class="letter">t</span>
<span class="letter">o</span>
<span class="letter"> </span>
<span class="letter">P</span>
<span class="letter">l</span>
<span class="letter">a</span>
<span class="letter">y</span>
</h1>
Basic styles
We place the heading in the middle of its parent (the body
in this case) by using a grid
layout:
body {
display: grid;
place-content: center;
}
We may also add some prettifying touches, like a nice font
or a background
on the container.
Next, we create the line with an absolutely positioned ::after
pseudo-element of thickness (height
) $h
:
$h: .125em;
$r: .5*$h;
h1 {
position: relative;
&::after {
position: absolute;
top: calc(50% - #{$r}); right: 0;
height: $h;
border-radius: 0 $r $r 0;
background: crimson;
}
}
The above code takes care of the positioning and height
of the pseudo-element, but what about the width
? How do we make it stretch from the left edge of the viewport to the right edge of the heading text?
Line length
Well, since we have a grid
layout where the heading is middle-aligned horizontally, this means that the vertical midline of the viewport coincides with that of the heading, splitting both into two equal-width halves:
Consequently, the distance between the left edge of the viewport and the right edge of the heading is half the viewport width (50vw
) plus half the heading width, which can be expressed as a %
value when used in the computation of its pseudo-element’s width
.
So the width
of our ::after
pseudo-element is:
width: calc(50vw + 50%);
Making the line go over and under
So far, the result is just a crimson line crossing some black text:
What we want is for some of the letters to show up on top of the line. In order to get this effect, we give them (or we don’t give them) a class of .over
at random. This means slightly altering the Pug code:
- let text = 'We Love to Play';
- let arr = text.split('');
h1(role='image' aria-label=text)
- arr.forEach(letter => {
span.letter(class=Math.random() > .5 ? 'over' : null) #{letter}
- });
We then relatively position the letters with a class of .over
and give them a positive z-index
.
.over {
position: relative;
z-index: 1;
}
My initial idea involved using translatez(1px)
instead of z-index: 1
, but then it hit me that using z-index
has both better browser support and involves less effort.
The line passes over some letters, but underneath others:
Animate it!
Now that we got over the tricky part, we can also add in an animation
to make the line enter in. This means having the crimson line shift to the left (in the negative direction of the x-axis, so the sign will be minus) by its full width
(100%
) at the beginning, only to then allow it to go back to its normal position.
@keyframes slide { 0% { transform: translate(-100%); } }
I opted to have a bit of time to breathe before the start of the animation
. This meant adding in the 1s
delay which, in turn, meant adding the backwards
keyword for the animation-fill-mode
, so that the line would stay in the state specified by the 0%
keyframe before the start of the animation
:
animation: slide 2s ease-out 1s backwards;
A 3D touch
Doing this gave me another idea, which was to make the line go through every single letter, that is, start above the letter, go through it and finish underneath (or the other way around).
This requires real 3D and a few small tweaks.
First off, we set transform-style
to preserve-3d
on the heading since we want all its children (and pseudo-elements) to a be part of the same 3D assembly, which will make them be ordered and intersect according to how they’re positioned in 3D.
Next, we want to rotate each letter around its y-axis, with the direction of rotation depending on the presence of the randomly assigned class (whose name we change to .rev
from “reverse” as “over” isn’t really suggestive of what we’re doing here anymore).
However, before we do this, we need to remember our span elements are still inline ones at this point and setting a transform
on an inline element has absolutely no effect.
To get around this issue, we set display: flex
on the heading. However, this creates a new issue and that’s the fact that span elements that contain only a space (" "
) get squished to zero width
.
A simple fix for this is to set white-space: pre
on our .letter
spans.
Once we’ve done this, we can rotate our spans by an angle $a
… in one direction or the other!
$a: 2deg;
.letter {
white-space: pre;
transform: rotatey($a);
}
.rev { transform: rotatey(-$a); }
Since rotation around the y-axis squishes our letters horizontally, we can scale them along the x-axis by a factor ($f
) that’s the inverse of the cosine of $a
.
$a: 2deg;
$f: 1/cos($a)
.letter {
white-space: pre;
transform: rotatey($a) scalex($f)
}
.rev { transform: rotatey(-$a) scalex($f) }
If you wish to understand the why behind using this particular scaling factor, you can check out this older article where I explain it all in detail.
And that’s it! We now have the 3D result we’ve been after! Do note however that the font used here was chosen so that our result looks good and another font may not work as well.
The post Weaving a Line Through Text in CSS appeared first on CSS-Tricks.