Earlier this year, I thought it would be cool to try building a spiraling triangle of text using HTML and CSS. I suppose it had no practical benefit, but as I said, I thought it would be cool. And it was.
In this post, I'll take you through my process and introduce you to some of the mathematical friends I made along the way. Before I had any idea of the math I was going to have to do, I started sketching in my notebook to wrap my head around the ways in which I could achieve this effect. I landed on a technique that I'll call the "curly caterpillar" method. That's where our story begins...
First, I lay out each of my triangle sides horizontally with each one nested within the side before it. Then I use a CSS transform
to rotate()
each of the sides around their left axis. Because the sides are nested, a rotation to one side will also rotate its children, causing a "curling" effect.
Try it out below! As you apply rotation to the joints between the three segments below, they curl inward to form a triangle.
In React, one of these segment components might look something like the following code sample. Notice the segment width is applied to the inner span
and the rotation is applied to the outer flex parent. Each subsequent segment is nested within the previous one, after the span
using the children
prop.
function Segment({children, value, width, rotation}) {return (<divstyle={{display: 'flex',transformOrigin: 'left',transform: `rotate(${rotation}deg)`}}><spanstyle={{flexShrink: 0,width}}>{value}</span>{children}</div>);}
To create a triangle, nest three Segment
components within each other, and rotate all but the first segment 120°.
The triangle I'm trying to build is equilateral, meaning each of its side lengths and angles is the same. Since the sum of all interior angles in any triangle must equal 180°, each interior angle has to be 60°. Each exterior angle can be calculated by subtracting the interior angle from 180° (or π), meaning the exterior angle must be 120°. That's the amount that I need to rotate each segment to turn my flat line into a triangle.
To learn more about basic laws of triangles, check out this article that explains things much better and with diagrams. 🔥
function Triangle() {const width = 100; // in pixelsconst interiorAngle = 180 / 3; // in degreesconst exteriorAngle = 180 - interiorAngle;return (// the outermost segment gets no rotation<Segment value="first" width={width} rotation={0}><Segment value="second" width={width} rotation={exteriorAngle}>{/* the innermost segment gets no further children */}<Segment value="third" width={width} rotation={exteriorAngle} /></Segment></Segment>);}
The story doesn't end here. I am trying to create a spiral triangle, after all. This means that after drawing the first three sides, the shape needs to continue spiraling inward as long as there's room to create new segments.
The amount of room needed to draw a segment is related to the desired amount of space between parallel lines in the spiral. Let's use the following spiral with simplified measurements as an example. Its first two segments are 100 units long, and it has 20 units of spacing between its parallel lines.
In order to continue drawing this spiral, I needed to figure out the length of its third side. At first glance, the answer might seem obvious: just subtract 20 from the original side length of 100, and we get a side length of 80.
Unfortunately, it's not that simple. This calculation results in a side that's slightly too long to ensure the subsequent sides are correctly spaced.
To find the appropriate length for side 3 and on, we first need to calculate the length of the piece that needs to be removed from the original side length. In the diagram below, I've labeled that part as .
If you look closely at side , it's the hypotenuse of a right triangle that has an opposite side length equal to the gap between parallel lines in the spiral (20). We also know that one of the angles is 60° because it has to be equivalent to the triangle's interior angle.
With this knowledge, we can apply trigonometry to solve for . Remember SohCahToa? Since we have the length of the opposite side () and want to solve for the hypotenuse (), we must use the sine function in this calculation.
In this post, I'll be referring to this side measurement as the inset. Now solving for is as easy as subtracting the inset from the original side length of 100.
This process of subtracting the inset from the original side length continues for each subsequent side until there's no more room to draw additional sides. In the diagram below, you can see that each inset, marked as , is the same length.
Calculating the length of side is as simple as subtracting one inset from the original side length, but side requires us to subtract one inset along with two additional lengths that I call outsets, marked as in the diagram. Side is equal to the original side length minus two insets and two outsets, and so on.
The length of an outset can be calculated using another staple of trigonometry, the Pythagorean theorem. This equation, commonly expressed as , lets us solve for one side of a right triangle, given that we know the length of the two other sides. In this case, the spacing between parallel lines is one side , and the inset is the other .
Now that I know how to compute side lengths, I need to apply this technique recursively until all sides are drawn. To do this, I needed to find a pattern or formula to follow for each side.
I drew a big spiral in my notebook and counted the insets and outsets required for each side. I went 10 sides deep to see if any pattern would emerge. Here's what my results looked like:
Side | Insets | Outsets |
---|---|---|
1 | 0 | 0 |
2 | 0 | 0 |
3 | 1 | 0 |
4 | 1 | 2 |
5 | 2 | 2 |
6 | 3 | 2 |
7 | 3 | 4 |
8 | 4 | 4 |
9 | 5 | 4 |
10 | 5 | 6 |
The outsets column presents a clear pattern. For every three sides, the number of outsets increments by two. In mathematical notation, we could express this formula where is the number of outsets, is the current side, and is the total number of sides on the shape—3 in the case of a triangle.
💡 Did you know?
The notation above means "floor" or "round this number down"
The pattern for the insets was a lot more unclear, but after some trial and error, I came up with the following handy formula, where is the number of insets, and the variables and are the same as above.
In JavaScript, these relationships would look something like this:
const numInsets =Math.floor(side / numSides) + Math.floor(Math.max(side - 2, 0) / numSides);const numOutsets = 2 * Math.floor((side - 1) / numSides);
There are three key values at play in both of these calculations:
Math.floor(side / numSides)
Math.floor((side - 1) / numSides)
Math.floor(Math.max(side - 2, 0) / numSides)
Notice this progression of side
, side - 1
, and side - 2
? We can make this code more concise by creating these values iteratively and combining them appropriately.
const [a, b, c] = Array.from({length: 3}, (_, index) =>Math.floor(Math.max(side - index, 0) / numSides));const numInsets = a + c;const numOutsets = 2 * b;
So far in this post, I've explained how I was able to:
Now it's time to put all of these tools together to draw a spiral.
I never discovered a formula for calculating the total number of segments needed in a spiral, so I opted for a different strategy here. Using a while loop, I calculated the width of one segment at a time, adding them to an array as long as they were "long enough".
How long is long enough? For this project, I decided that I didn't want any segment to be shorter than the length of the inset. This would ensure consistent spacing within the spiral, down to the very last segment.
const segments = [];while (width > inset) {// get the current side based on the number of segments already createdconst side = segments.length + 1;// determine the number of insets and outsets for that sideconst [a, b, c] = Array.from({length: 3}, (_, index) =>Math.floor(Math.max(side - index, 0) / numSides));const numInsets = a + c;const numOutsets = 2 * b;// calculate the side length by subtracting the appropriate number of insets// and outsets from the original side lengthconst sideLength = width - inset * numInsets - outset * numOutsets;// if the calculated side length is less than the length of the inset, break// the loopif (sideLength < inset) {break;}// otherwise, add side length to the front of the array of segmentssegments.unshift(sideLength);}
Remember that in order to use the curly caterpillar method, I need to nest my segments within one another. In React, it's common to map
over an array and render components based on its items, but that would result in a flat list rather than the nested structure I'm after.
Instead, I opted to use Array.reduce
to recursively wrap HTML elements around each previous wrapped element. Using this strategy, I start from the innermost segment and continue wrapping outward to the first segment in the spiral. This is why I used Array.unshift
instead of Array.push
in the code sample above. I needed to make sure my final array would be ordered from innermost at the start to outermost at the end.
<>{segments.reduce((child, width, index, array) => (<divstyle={{display: 'flex',transformOrigin: 'left',transform:// don't rotate the first segment (last item in the array)index === array.length - 1 ? 'none' : `rotate(${exteriorAngle}rad)`}}><spanstyle={{flexShrink: 0,width,height: 2,backgroundColor: 'currentcolor'}}/>{/* render the previous child within this segment */}{child}</div>),null // previous child is null by default)}</>
Et voilà! I now have a component that renders a beautiful triangular spiral given a width and spacing value. Play around with the demo below to see this method in action. Notice how new segments get added or subtracted as the spiral size and spacing change.
As you change the width of the spiral in the example above, you'll notice the height of its container changing as well. The height of a triangle like this one can be calculated by dividing the triangle in two and measuring the long vertical side indicated by the dashed line in the diagram below.
Each half is a right triangle, and we already know two of its side lengths and all interior angles, so we have two options for calculating the remaining side:
Pick your poison—either method will yield the same result.
Drawing a spiral is cool, but my goal was to draw a spiral of text. Adding text to the segments is easy enough. I replace the height
and background-color
styles with text-align: center
and add the text as children to the span
.
<spanstyle={{flexShrink: 0,width,// height: 2,// backgroundColor: 'currentcolor'textAlign: 'center'}}>text goes here</span>
Since most sides of the spiral have variable lengths, the next challenge was making sure that the amount of text in each side was proportional to the amount of space available. For example, a string of text that would fit nicely into the first side would exceed the length of sides 3 and greater.
My first idea was to split my desired spiral text into an array of strings, each one corresponding to a side. As I create my segments, I save the segment's computed width and text value and access them as properties on the segment
object in the reduce function.
const text = ['this is some text','that i think will','fit into','the sides','of my','spiral'];while (width > inset) {// calculate inset/outset count and side length as beforesegments.unshift({width: sideLength,text: text[side - 1]});}return (<>{segments.reduce((child, segment) => (<div style={/* segment wrapper styles */}><span style={/* same as above */}>{/* add text as a child of the span */}{segment.text}</span>{child}</div>))}</>);
This strategy worked, but it required a lot of trial and error to come up with text strings that filled the space properly. It also meant that when I changed the size or spacing of my spiral, my carefully curated text strings would need to be updated to fit the new proportions. Super annoying. 🙄
I tried to imagine a better alternative. What if I could just pass a single string of text, and the spiral component would automatically distribute the words in the string across each side? If it runs out of words to fill space with, it would start over from the beginning of the string. Rinse and repeat this process until all the space is accounted for.
To do this, I need to understand how many characters will fit into each segment. If my spiral component accepts a fontSize
prop, I can make an approximation of the average character width based on that. Then I divide the side length by this approximate character width, and I've got my character allowance.
const charWidth = fontSize / 1.5;let numChars = Math.floor(sideLength / charWidth);
Then, I split the provided text (supplied as a children
prop) into individual words, and use a while loop to add words to a segment until there's no room for more characters. Every time I take one word from the words
array, I put it back on the end of the array for reuse later on.
const words = children.trim() // trim leading/trailing space.split(/\s+/); // split into array of wordslet text = '';while (numChars > 0) {const word = words.shift(); // get the first elementwords.push(word); // put it back on the end of the arraytext += word.text; // add it to the segment's textnumChars -= word.text.length; // adjust the number of remaining characters}segment.unshift({width: sideLength,text});
The only problem with this solution is it doesn't replace the spaces between words in my text string. Also, sometimes a single word is too long to fit into one side.
To account for this, I modified my while loop to insert a portion of a word if there aren't enough characters left to fit the whole thing. Then I put the remainder of the word back on the front of the words array so that it gets rendered first on the next segment, before continuing with the next word in the stack.
I need to be able to differentiate between full words and fragments to avoid pushing fragments onto the end of the words array as I'm doing with all words near the top of the while loop in the example above. Each word in the original array gets an isFullWord
flag that I can check in the while loop to decide whether to push
it back onto the words array.
const words = children// trim and split logic from before.map(text => ({text,isFullWord: true // mark as full word}));
After every full word, I add a space if there is more than one character slot remaining in that segment. This is to avoid having a space as the last character of any segment. Here's what the updated while loop body looks like now:
const word = words.shift(); // get the first element// return it to the end of the stack if it's a full wordif (word.isFullWord) {words.push(word);}// if there isn't enough space for the full wordif (word.text.length > numChars) {// grab the part of it that will fitconst fragment = word.text.slice(0, numChars);// and put the rest of it back into the front of the stackwords.unshift({text: word.text.slice(numChars),isFullWord: false // not a full word});text += fragment;numChars -= fragment.length;} else {// otherwise add the word to the segmenttext += word.text;numChars -= word.text.length;if (numChars > 1) {// add a space if there's roomtext += ' ';numChars -= 1;} else if (numChars) {// otherwise bail out and complete the segmentbreak;}}
I changed the way that I render the segment text so that its characters are spaced evenly within each segment, sort of like what text-align: justify
does for words in a line. First I split the segment text into an array of characters, and render them each as separate span
elements. Then I give their parent display: flex
and justify-content: space-evenly
, as well as white-space: pre
to preserve the room that space characters occupy.
<spanstyle={{flexShrink: 0,display: 'flex',whiteSpace: 'pre',justifyContent: 'space-evenly',width}}>{text.split('').map((char: string, index: number) => (<span key={index}>{char}</span>))}</span>
I also added some padding on the start and end of each segment to prevent text from overlapping at the points of my shape. I found that two-thirds of a character width was an amount that worked well for this.
const padding = charWidth / 1.5; // same as charWidth * (2 / 3)
I refactored the code in my segment creation code to account for this padding's effect on the amount of space available for text, and thus the number of characters that would fit in each segment.
const innerWidth = sideLength - padding * 2; // subtract padding from each endconst numChars = Math.floor(innerWidth / charWidth);
Finally, I add the padding to my segment elements.
<spanstyle={{// other segment text stylespadding}}>{/* render text */}</>
At last, a triangle spiral of text. 😌 If you've made it this far in the post, I applaud you. 👏 Enjoy one last demo where you can experiment with all of the variables at play in this component.
If I could make this work for a triangle, how hard could it be to make it work for other regular polygons?
💡 Did you know?
A regular polygon is any polygon where all of its sides and angles are equal, like the equilateral triangle in this post, or a square, hexagon, etc.
It turns out that a lot of the fundamental parts of this implementation can be applied to shapes with more sides. There were only a handful of adjustments I had to make for my spiral component to be ✨ multi-polygonal ✨.
The angle that I used to rotate the segments in my triangle was fixed at 120°, but as the number of sides on a shape changes, so too must this angle. The formula to find a regular polygon's exterior angle is where is the number of sides.
const exteriorAngle = (Math.PI * 2) / numSides;
Finding the height of a triangle was relatively easy; divide the triangle in two and measure the long vertical side. The process of measuring the height of a regular polygon is more difficult. The formula used differs depending on whether the polygon has an even number of sides, like a hexagon (6), or an odd number like a pentagon (5).
Imagine drawing a circle that touches every point on the polygon. This is called the circumcircle and it defines the center of the polygon. The radius of the circumcircle is called the circumradius, marked as . The distance between the center of the circle and the midpoint of any side is called the inradius, marked as .
Calculating the height of an evenly-sided polygon is as simple as multiplying the inradius by two. The height of an oddly-sided polygon is the sum of the circumradius and inradius, as visualized in the diagram below and the formula below that, where is the number of sides.
Calculating these radii is a complex task. If the number of sides is odd, the formula for the circumradius is as follows, where is the chosen width and is the number of sides as above.
We can then calculate the inradius by multiplying the circumradius by .
If the number of sides is even, the inradius can be calculated using the formula below. The equation used depends on whether half the number of sides () is even or odd. For example, a six-sided polygon would have an of 3 (odd), and an eight-sided polygon would have an of 4 (even).
For squares and triangles, the original side length is equal to the width of the shape—easy! For shapes with more than four sides, I need to calculate the side length. If a shape has an odd number of sides, this can be calculated using the circumradius and the exterior angle .
For shapes with an even number of sides, the side length calculation differs slightly depending on if is even or odd. In either case, I use the inradius and the exterior angle , only changing the trigonometric ratio used.
Now that I have the height of the shape, I would like to center it within a square bounding box. I create a div
with equal width and height, provided by the component's boxSize
prop. Then I find the difference between the box size and the shape height as calculated above. Divide that number by two, and that's the amount I need to offset my shape to vertically center it.
To horizontally center the shape, I need to move the first side into the middle of the box. I can do that by calculating the difference between the box size and side length and dividing by two as I did with the vertical padding.
<divstyle={{width: boxSize,height: boxSize,paddingTop: (boxSize - height) / 2,paddingLeft: (boxSize - sideLength) / 2}}>{/* spiral elements go here */}</div>
The last change I needed to make for this spiral to work with any regular polygon has to do with the calculation of inner side lengths—the outsets in particular. When I was rendering a triangle, I subtracted outsets from the original side length to end up with each inner side length. For shapes with more than four sides, I must instead add outsets to perform the calculation correctly.
Simple enough—I can perform a check after calculating my outset and invert the number if the shape has more than four sides.
let outset = Math.sqrt(inset ** 2 - spacing ** 2);if (numSides > 4) {outset *= -1;}
Ok, that's actually it. I learned a lot about math and geometry creating this component and writing this post, and I'm going to drop a few links below to some of the outstanding resources that I couldn't have done this without. I encourage you to check them out if you want to learn more about this sort of thing! 🌠
In case you want to use multi-polygonal text spirals in your own project, I published this component as an npm library. Check out the docs for usage instructions.
npm i react-spiral