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.
Anyone else thought that 3d threading was impossible just ten minutes ago? That un-squashing of the rotated letters is genius.
Ana’s work never ceases to amaze me.
Hi
I just wanted to notice you that one of my former students (his name is Côme Gaillard) rewrote this without any library, simply in pure HTML-CSS-JS, I thought it would interest you :
The 3D version, when run on Firefox, has the red line slide over the text and only switches to weaving through the letters when the animation ends. I wonder whether that bug is in the browser or in the demo.
Oh, wow, I think it may be more complicated than just a browser or a demo bug…
What version of Firefox and what OS are you seeing this in? I just tested again both in Nightly and in stable on Windows 10 and it works in both as intended, the line goes through the letters as it slides to the right, not at the end of the animation.
I’m using 73.0.1 on Windows 10 with a GeForce GTX 750 Ti. Looking into my “Support information”, there may be a driver issue, though I have no idea why that would cause this particular problem.
Hm, restarting the browser made it work normally. Since it was originally started from a remote desktop connection, I think it’s an issue in the video driver that’s used for remote desktop or in how Firefox sets up its compositor when it can’t use hardware acceleration.
Although I enjoyed this demonstration, I found the above line to read a bit ironic, or hypocritical by accident, given that what this method does is essentially exactly what it claimed it wouldn’t do: use a library to split text into multiple individual spans. It’s merely shifting the execution cost from the client device/browser to a server side prerendering process.
It’s the eternal struggle between the execution cost of a small snippet of JavaScript code that runs on the client as opposed to the overhead of having a client download more bits of data from having all of the very small HTML fragments get expanded into much longer span clusters before they are sent over the wire. In this case, most often the trade-off is likely in the favor of the prerendering, but conservation of HTML energy means someone somewhere had to run the code to make that rendering happen…
Where’s the library? I don’t see one mentioned at all and wouldn’t classify a small hand-written script as one.
Nice work! The 3D version is highly dependent on the letters used. You can see the problem on the exclamation point, where the line goes through the it rather than weaving around it.
If we use monospaced font or so-called fixed-width font, a similar effect can be recreated with two dashed lines.
Lines are drawn with phase offset and
repeating-linear-gradient
with color stop of1ch
(which corresponds to width of a letter/character in monospaced font). Placing one dashed line below and another over the text gives illusion of continuous line.Theoretically, any monospaced font should be working in this demo. I’ve tested with few of them: PT Mono, Roboto Mono, Nova Mono and Courier Prime.
https://fonts.google.com/?category=Monospace