Hire a web Developer and Designer to upgrade and boost your online presence with cutting edge Technologies

Monday, June 17, 2024

The Modern Guide For Making CSS Shapes

 Creating shapes using CSS is, without any doubt, a classic exercise. In many cases, we try to use hacky code and workarounds, but CSS has evolved, and we have modern ways to create CSS Shapes with clean, reusable code. In this comprehensive guide, Temani Afif explores different techniques for creating common shapes with the smallest and most flexible code possible.

You have for sure googled “how to create [shape_name] with CSS” at least once in your front-end career if it’s not something you already have bookmarked. And the number of articles and demos you will find out there is endless.

Good, right? Copy that code and drop it into the ol’ stylesheet. Ship it!

The problem is that you don’t understand how the copied code works. Sure, it got the job done, but many of the most widely used CSS shape snippets are often dated and rely on things like magic numbers to get the shapes just right. So, the next time you go into the code needing to make a change to it, it either makes little sense or is inflexible to the point that you need an entirely new solution.

So, here it is, your one-stop modern guide for how to create shapes in CSS! We are going to explore the most common CSS shapes while highlighting different CSS tricks and techniques that you can easily re-purpose for any kind of shape. The goal is not to learn how to create specific shapes but rather to understand the modern tricks that allow you to create any kind of shape you want.

Table of Contents #

You can jump directly to the topic you’re interested in to find relevant shapes or browse the complete list. Enjoy!

Why Not SVG? #

I get asked this question often, and my answer is always the same: Use SVG if you can! I have nothing against SVG. It’s just another approach for creating shapes using another syntax with another set of considerations. If SVG was my expertise, then I would be writing about that instead!

CSS is my field of expertise, so that’s the approach we’re covering for drawing shapes with code. Choosing CSS or SVG is typically a matter of choice. There may very well be a good reason why SVG is a better fit for your specific needs.

Many times, CSS will be your best bet for decorative things or when you’re working with a specific element in the markup that contains real content to be styled. Ultimately, though, you will need to consider what your project’s requirements are and decide whether a CSS shape is really what you are looking for.

Your First Resource #

Before we start digging into code, please spend a few minutes over at my CSS Shape website. You will find many examples of CSS-only shapes. This is an ever-growing collection that I regularly maintain with new shapes and techniques. Bookmark it and use it as a reference as we make our way through this guide.

Is it fairly easy to modify and tweak the CSS for those shapes?

Yes! The CSS for each and every shape is optimized to be as flexible and efficient as possible. The CSS typically targets a single HTML element to prevent you from having to touch too much markup besides dropping the element on the page. Additionally, I make liberal use of CSS variables that allow you to modify things easily for your needs.

Most of you don’t have time to grasp all the techniques and tricks to create different shapes, so an online resource with ready-to-use snippets of code can be a lifesaver!

Clipping Shapes In CSS #

The CSS clip-path property — and its polygon() function — is what we commonly reach for when creating CSS Shapes. Through the creation of common CSS shapes, we will learn a few tricks that can help you create other shapes easily.

Hexagons #

Let’s start with one of the easiest shapes; the hexagon. We first define the shape’s dimensions, then provide the coordinates for the six points and we are done.

.hexagon {
  width: 200px;
  aspect-ratio: 0.866; 
  clip-path: polygon(
    0% 25%,
    0% 75%,
    50% 100%, 
    100% 75%, 
    100% 25%, 
    50% 0%);
}
See the Pen Hexagon shape using clip-path by Temani Afif.

Easy, right? But what if I told you that there’s an even easier way to do it? Instead of six points, we can get by with just four.

A little-known trick with the polygon() function is that we are allowed to set points that are outside the [0% 100%] range. In other words, we can cut outside the element — which becomes super useful for this shape as well many others, as we’ll see.

Comparing a hexagon with six points versus a hexagon clipped with four points.
Figure 1: Clipping a hexagon with four points. (Large preview)

We’re basically drawing the shape of a diamond where two of the points are set way outside the bounds of the hexagon we’re trying to make. This is perhaps the very first lesson for drawing CSS shapes: Allow yourself to think outside the box — or at least the shape’s boundaries.

Look how much simpler the code already looks:

.hexagon {
  width: 200px;
  aspect-ratio: cos(30deg); 
  clip-path: polygon(
    -50% 50%,
    50% 100%,
    150% 50%,
    50% 0
  );
}

Did you notice that I updated the aspect-ratio property in there? I’m using a trigonometric function, cos(), to replace the magic number 0.866. The exact value of the ratio is equal to cos(30deg) (or sin(60deg)). Besides, cos(30deg) is a lot easier to remember than 0.866.

Here’s something fun we can do: swap the X and Y coordinate values. In other words, let’s change the polygon() coordinates from this pattern:

clip-path: polygon(X1 Y1, X2 Y2, ..., Xn Yn)

…to this, where the Y values come before the X values:

clip-path: polygon(Y1 X1, Y2 X2, ..., Yn Xn)

What we get is a new variation of the hexagon:

See the Pen Another variation of the hexagon shape by Temani Afif.

Swapping the X and Y values will make a kind of switch between the vertical and horizontal axes, which will help to get a different shape. Note that I have also updated the ratio to 1/cos(30deg) instead of cos(30deg). Since we are switching both axes, the new ratio needs to be equal to its inverse, i.e., R (or R/1) becomes 1/R.

And since our CSS is nothing more than a single style rule on a single selector, we can apply it to more than a <div>. For example, the following demo includes both variations of the original hexagon, plus a third example that sets the styles on an <img> element.

See the Pen CSS-only hexagon shapes (the modern way) by Temani Afif.

There we go, our first shape! We are also walking away with two valuable lessons about creating shapes with CSS:

  • The polygon() function accepts points outside the [0% 100%] range.
    This allows us to clip shapes with fewer points in some cases but also opens up possibilities for creating additional shapes.
  • Switching axes is a solid approach for creating shape variations.
    In the case of a hexagon, swapping the values on the X and Y axes changes the hexagon’s direction.

Octagons #

An octagon is another geometric shape and it is very close in nature to the hexagon. Instead of working with six sides, we’re working with eight to get what looks like the shape of a common traffic stop sign.

Let’s take the first lesson we learned from working with hexagons and clip the element with coordinates outside the shape’s boundaries to keep our clipping efficient. Believe it or not, we can actually establish all eight octagon sides with only four points, just like we used only four points to establish the hexagon’s six sides.

Comparing an octagon with eight points versus an octagon clipped with four points.
Figure 2: Clipping an octagon with four points. (Large preview)

I know that visualizing the shape with outside points can be somewhat difficult because we’re practically turning the concept of clipping on its head. But with some practice, you get used to this mental model and develop muscle memory for it.

Notice that the CSS is remarkably similar to what we used to create a hexagon:

.octagon {
  width: 200px;  
  aspect-ratio: 1;  
  --o: calc(50% * tan(-22.5deg));
  clip-path: polygon(
    var(--o) 50%,
    50% var(--o),
    calc(100% - var(--o)) 50%,
    50% calc(100% - var(--o))
  );
}

Except for the small trigonometric formula, the structure of the code is identical to the last hexagon shape — set the shape’s dimensions, then clip the points. And notice how I saved the math calculation as a CSS variable to avoid repeating that code.

If math isn’t really your thing — and that’s totally fine! — remember that the formulas are simply one part of the puzzle. There’s no need to go back to your high school geometry textbooks. You can always find the formulas you need for specific shapes in my online collection. Again, that collection is your first resource for creating CSS shapes!

And, of course, we can apply this shape to an <img> element as easily as we can a <div>:

See the Pen CSS-only octagon shapes (the modern way) by Temani Afif.

There’s actually more we can do to optimize our code. Consider the following:

.octa {
  --w: 200px;

width: var(--w);\
aspect-ratio: 1;
margin: calc(var(--w) * tan(22.5deg) / 2);
clip-path: polygon(0 50%, 50% 0, 100% 50%, 50% 100%) margin-box;
} 
See the Pen Octagon shape with margin-box by Temani Afif.

The most obvious difference is that the variable containing the math function (--o) is removed, and we have a new one, --w, for setting the shape’s dimensions.

But notice that we’re now setting margin on the shape and declaring a margin-box keyword at the end of the clip-path. It means that the reference for the polygon() is now set to the margin-box instead of the default border-box.

If you look back at Figure 2, notice that the four points used to draw the octagon are outside the shape’s boundaries and have the same distance from those boundaries. Instead of accounting for that distance inside the clip-path, the updated code declares it on the margin property, which makes the values of the coordinates easier to define.

This is the CSS we started with:

.octagon {
  --o: calc(50% * tan(-22.5deg));

clip-path: polygon(var(--o) 50%, 50% var(--o), calc(100% - var(--o)) 50%, 50% calc(100% - var(--o)));
} 

The optimization simplifies the clip-path even if we have an extra property:

.octagon {
  --w: 200px;

margin: calc(var(--w) * tan(22.5deg) / 2);
clip-path: polygon(0 50%, 50% 0, 100% 50%, 50% 100%) margin-box;
} 

All the --o variables are removed from the clip-path, and the margin property gets that same value. I had to introduce a new variable, --w, to set the element’s size dimensions because I couldn’t rely on a percentage value. In this particular case, you will end with some margin around the element, but this trick can really help simplify calculations.

If you don’t want the extra margin, you can add padding instead and apply the same amount of padding as a negative margin. That’s another trick to keep polygons simple in a way that works well with images. Here is a demo showing different shapes created with the same clip-path value.

See the Pen Different shapes using the same polygon by Temani Afif.

Stars #

Creating a star shape is always a bit tricky, even if you are comfortable using clip-path with the polygon() function. Clipping requires very precise values, so we either find a ready-to-use snippet of CSS or fuss with it ourselves until we get it right.

And if I were to ask you how many points we need to cut the shape, you might reasonably respond that 10 points are needed. And you are technically correct. But we can do better using only five points!

Diagram of a star with 10 clip points next to a star with five clip points.
Figure 3: Drawing a star shape with five points instead of 10 points. (Large preview)

It may sound impossible to make a star out of only five points, but it’s perfectly possible, and the trick is how the points inside polygon() are ordered. If we were to draw a star with pencil on paper in a single continuous line, we would follow the following order:

Gold star with five points that are labeled one through five, starting at the top point.
Figure 4: Drawing a star with a single line illustrates the order of the points we need to create the shape. (Large preview)

It’s the same way we used to draw stars as kids — and it fits perfectly in CSS with polygon()! This is another hidden trick about clip-path with polygon(), and it leads to another key lesson for drawing CSS shapes: the lines we establish can intersect. Again, we’re sort of turning a concept on its head, even if it’s a pattern we all grew up making by hand.

Here’s how those five points translate to CSS:

.star {
  width: 200px;  
  aspect-ratio: 1;
  clip-path: polygon(50% 0, /* (1) */
    calc(50%*(1 + sin(.4turn))) calc(50%*(1 - cos(.4turn))), /* (2) */
    calc(50%*(1 - sin(.2turn))) calc(50%*(1 - cos(.2turn))), /* (3) */
    calc(50%*(1 + sin(.2turn))) calc(50%*(1 - cos(.2turn))), /* (4) */
    calc(50%*(1 - sin(.4turn))) calc(50%*(1 - cos(.4turn)))  /* (5) */
   ); 
}
See the Pen Star shape using clip-path by Temani Afif.

I am using trigonometric functions again for accuracy without resorting to magic numbers, but even if we calculate the values, the code is still better than the traditional 10-point approach:

.star {
  width: 200px;  
  aspect-ratio: 1;
  clip-path: polygon(50% 0, 79% 90%, 2% 35%, 98% 35%, 21% 90%); 
}

Since we have a symmetrical shape, note that the second and fifth points on the star share the same Y coordinates. The same is true for the third and fourth points. And notice, too, that the X values have the same distance to the center (79% - 50% = 50% - 21%). If we add those up, we see that the sum is equal to 100% (79% + 21% = 100%).

That leads us to yet another major lesson on drawing CSS shapes: Consider the shape’s symmetry because that’s a big hint that there may be duplicated values. This will reduce your effort in calculating/finding the different values.

We already cut the number of points once from 10 to five. Now, there are only three points to remember — the remaining two can be figured out from there, thanks to symmetry.

50% 0   /* (1) */
79% 90% /* (2)  --> (100% - 79%) = 21% 90% /* (5) */
 2% 35% /* (3)  --> (100% -  2%) = 98% 35% /* (4) */

Go back to the hexagon and octagon shapes and look for symmetry. You will notice repeated values as well, and the clip-path will suddenly look easier to remember!

Polygons & Starbursts #

We’ve already covered stars, hexagons, and octagons, but what if you are working with an unknown number of points or sides? You may want a solution that is capable of adjusting the number for whatever situation it is used for. For this, we can consider more generic shapes like polygons and starbursts.

A row of three multi-point star shapes above a row of three geometric shapes with different numbers of sides.
Geometric shapes come with a number of points and sides. (Large preview)

The funny thing is that starbursts are basically the exact same thing as polygons, just with half the points that we can move inward.

Animated illustration of a star morphing into a polygon.
Figure 6.

I often advise people to use my online generators for shapes like these because the clip-path coordinates can get tricky to write and calculate by hand.

That said, I really believe it’s still a very good idea to understand how the coordinates are calculated and how they affect the overall shape. I have an entire article on the topic for you to learn the nuances of calculating coordinates.

Parallelograms & Trapezoids #

Another common shape we always build is a rectangle shape where we have one or two slanted sides. They have a lot of names depending on the final result (e.g., parallelogram, trapezoid, skewed rectangle, and so on), but all of them are built using the same CSS technique.

Showing two parallelograms and a trapezoid.
Figure 7: Parallelograms and a trapezoid. (Large preview)

First, we start by creating a basic rectangle by linking the four corner points together:

clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%)

This code produces nothing because our element is already a rectangle. Also, note that 0 and 100% are the only values we’re using.

Next, offset some values to get the shape you want. Let’s say our offset needs to be equal to 10px. If the value is 0, we update it with 10px, and if it’s 100% we update it with calc(100% - 10px). As simple as that!

But which value do I need to update and when?

Try and see! Open your browser’s developer tools and update the values in real-time to see how the shape changes, and you will understand what points you need to update. I would lie if I told you that I write all the shapes from memory without making any mistakes. In most cases, I start with the basic rectangle, and I add or update points until I get the shape I want. Try this as a small homework exercise and create the shapes in Figure 11 by yourself. You can still find all the correct code in my online collection for reference.

If you want more CSS tricks around the clip-path property, check my article “CSS Tricks To Master The clip-path Property” which is a good follow-up to this section.

Masking Shapes In CSS #

We just worked with a number of shapes that required us to figure out a number of points and clip-path by plotting their coordinates in a polygon(). In this section, we will cover circular and curvy shapes while introducing the other property you will use the most when creating CSS shapes: the mask property.

Like the previous section, we will create some shapes while highlighting the main tricks you need to know. Don’t forget that the goal is not to learn how to create specific shapes but to learn the tricks that allow you to create any kind of shape.

Circles & Holes #

When talking about the mask property, gradients are certain to come up. We can, for example, “cut” (but really “mask”) a circular hole out of an element with a radial-gradient:

mask: radial-gradient(50px, #0000 98%, #000);

Why aren’t we using a simple background instead? The mask property allows us more flexibility, like using any color we want and applying the effect on a variety of other elements, such as <img>. If the color and flexible utility aren’t a big deal, then you can certainly reach for the background property instead of cutting a hole.

Here’s the mask working on both a <div> and <img>:

See the Pen Hole shape by Temani Afif.

It’s here that I’d like to call out yet another lesson for creating shapes in CSS: The colors we use in gradients are completely unimportant when working with mask.

All we care about is the color value’s alpha channel because transparency is what is mask-ed out of the element, establishing the circular hole in the center. The gradient’s opaque colors preserve the visibility of the rest of the element. That’s why you will often see me using a black color value (e.g., #000) for the visible part and a transparent color (e.g., #0000) for the invisible part.

Notice the hard color stops in the gradient. A smooth transition between colors would lead to blurry lines. If we remove that transition and sharply change from one color to another, we get smooth, sharp edges. But not totally! I prefer to keep a very small transition (98% instead of 100%) to avoid jagged edges.

And with a simple radial-gradient, we can achieve a lot of shapes, like cutting a circle from the top or bottom of an element.

See the Pen Circular cut from the top & bottom by Temani Afif.

Let’s change it up and make the cut from the top and the bottom edges at the same time:

See the Pen Circular Cut at top and bottom by Temani Afif.

If we give the gradient an explicit size, then it will repeat, resulting in yet another fancy shape, a scooped border:

See the Pen Scooped edges from top and bottom by Temani Afif.

Rather than dissecting the code for that last example, I want you to peek at the CSS and see for yourself how the radial-gradient is configured. You will notice that we went from a simple hole to a fancy border decoration by making only a few changes.

Border Edges #

The previous demo is one example of many fancy borders we can create. We can go wavy, spiked, scalloped, and more!

Scalloped, zig-zag, and wavy borders
Figure 8. (Large preview)

Once again, it’s all about CSS masks and gradients. In the following articles, I provide you with examples and recipes for many different possibilities:

Be sure to make it to the end of the second article to see how this technique can be used as decorative background patterns.

See the Pen CSS only pattern by Temani Afif.

Rounded Arcs #

This is another instance where CSS gradients are the perfect fit for mask-ing shapes. You’ve probably seen this type of shape a gazillion times because it’s a common pattern for animated loading indications.

Circular progress element with rounded edges and gradient coloration
Figure 9: Circular progress element with rounded edges and gradient coloration. (Large preview)

This time, we are going to introduce another technique which is “composition”. It’s an operation we perform between two gradient layers. We either use mask-composite to define it, or we declare the values on the mask property.

The figure below illustrates the gradient configuration and the composition between each layer.

Showing the steps that go from a full circle to an unclosed circle with rounded edges.
Figure 10: Combining radial and conical gradients to establish the final shape. (Large preview)

We start with a radial-gradient to create a full circle shape. Then we use a conic-gradient to create the shape below it. Between the two gradients, we perform an “intersect” composition to get the unclosed circle. Then we tack on two more radial gradients to the mask to get those nice rounded endpoints on the unclosed circle. This time we consider the default composition, “add”.

Gradients aren’t something new as we use them a lot with the background property but “composition” is the new concept I want you to keep in mind. It’s a very handy one that unlocks a lot of possibilities.

Ready for the CSS?

.arc {
  --b: 40px; /* border thickness */
  --a: 240deg; /* progression */  

--_g:/var(--b) var(--b) radial-gradient(50% 50%,#000 98%,#0000) no-repeat;
mask:
top var(--_g),
calc(50% + 50% * sin(var(--a)))
calc(50% - 50% * cos(var(--a))) var(--_g),
conic-gradient(#000 var(--a), #0000 0) intersect,
radial-gradient(50% 50%, #0000 calc(100% - var(--b)), #000 0 98%, #0000)
} 
See the Pen Progress circle using mask by Temani Afif.

Even if the code looks a bit complex at first glance, the use of CSS variables makes things easier to adjust. That’s an important CSS technique I am using in most of the shapes I have created. Many of them require complex formulas and a lot of gradients, but in the end, all you have to do is adjust a few variables to control the shape. So, let’s not spend too much time explaining the math expressions. I want to focus on the tricks and techniques because the CSS concepts are what is important; remember, you can always grab the math. How CSS uses it is key.

Notice that we can achieve the same result using different gradient combinations. It’s weird because the syntax looks completely different. This snippet accomplishes the same visual result.

.arc {
  --b: 40px; /* border thickness */
  --a: 250deg; /* progression */

padding: var(--b);
border-radius: 50%;

--_g: /var(--b) var(--b) radial-gradient(50% 50%, #000 97%, #0000 99%) no-repeat;
mask:
top var(--_g),
calc(50% + 50% * sin(var(--a)))
calc(50% - 50% * cos(var(--a))) var(--_g),
linear-gradient(#0000 0 0) content-box intersect,
conic-gradient(#000 var(--a), #0000 0);
} 

I added border-radius in there to round the element and added padding equal to the border’s thickness. Then, if you check the gradient used in the mask, you will see that I have changed the radial-gradient with a linear-gradient containing a single transparent color that covers the element’s content-box.

Sure, there are two more variables using this approach, but I did simplify the overall gradient at the same time. Yet another valid approach for the same effect.

See the Pen Untitled by Temani Afif.

Dashed Circles #

We can produce additional circular shapes with dashed edges using the same code we just wrote:

See the Pen Dashed border by Temani Afif.

This time we’re combining two gradients in our mask. One is a black-to-transparent repeating-conic-gradient and the other is a transparent linear-gradient configured to cover the element up to its content-box and the mask-composite property is set to intersect.

mask:
 linear-gradient(#0000 0 0) content-box intersect,
 repeating-conic-gradient( /* ... */ );

If you want to dig deeper into mask-composite, I advise you to read “Mask Compositing: The Crash Course” by Ana Tudor.

Rounded Tabs #

Tabs are a super common design pattern. Each tab is connected to a panel of content where clicking a tab reveals that panel of content. Tabs can be rectangular, but we often think of them as rounded, the way they are on actual paper file folders.

An empty light brown manilla folder.
Figure 11. (Large preview)

We could get clever and use a pseudo-element for the shape that’s positioned behind the set of panels, but that introduces more complexity and fixed values than we ought to have. Instead, we can continue using CSS masks to get the perfect shape with a minimal amount of reusable code.

Tab shape with rounded top edges and a gradient background with muted red colors.
Figure 12. (Large preview)

It’s not really the rounded top edges that are difficult to pull off, but the bottom portion that curves inwards instead of rounding in like the top. And even then, we already know the secret sauce: using CSS masks by combining gradients that reveal just the parts we want.

Illustrating the four steps to mask the shape.
Figure 13. (Large preview)

We start by adding a border around the element — excluding the bottom edge — and applying a border-radius on the top-left and top-right corners.

.tab {
  --r: 40px; /* radius size */

border: var(--r) solid #0000; /* transparent black */
border-bottom: 0;
border-radius: calc(2 * var(--r)) calc(2 * var(--r)) 0 0;
} 

Next, we add the first mask layer. We only want to show the padding area (i.e., the red area highlighted in Figure 10).

mask: linear-gradient(#000 0 0) padding-box;

Let’s add two more gradients, both radial, to show those bottom curves.

mask: 
  radial-gradient(100% 100% at 0 0, #0000 98%, #000) 0 100% / var(--r) var(--r), 
  radial-gradient(100% 100% at 100% 0, #0000 98%, #000) 100% 100% / var(--r) var(--r), 
  linear-gradient(#000 0 0) padding-box;
Showing the radial gradients used to create the shape’s inner curve.
Figure 14. (Large preview)

Here is how the full code comes together:

.tab {
  --r: 40px; /* control the radius */

border: var(--r) solid #0000;
border-bottom: 0;
border-radius: calc(2 * var(--r)) calc(2 * var(--r)) 0 0;
mask:
radial-gradient(100% 100% at 0 0, #0000 98%, #000) 0 100% / var(--r) var(--r),
radial-gradient(100% 100% at 100% 0, #0000 98%, #000) 100% 100% / var(--r) var(--r),
linear-gradient(#000 0 0) padding-box;
mask-repeat: no-repeat;
background: linear-gradient(60deg, #BD5532, #601848) border-box;
} 

As usual, all it takes is one variable to control the shape. Let’s zero-in on the border-radius declaration for a moment:

border-radius: calc(2 * var(--r)) calc(2 * var(--r)) 0 0;

Notice that the shape’s rounded top edges are equal to two times the radius (--r) value. If you’re wondering why we need a calculation here at all, it’s because we have a transparent border hanging out there, and we need to double the radius to account for it. The radius of the blue areas highlighted in Figure 13 is equal to 2 * R while the red area highlighted in the same figure is equal to 2 * R - R, or simply R.

We can actually optimize the code so that we only need two gradients — one linear and one radial — instead of three. I’ll drop that into the following demo for you to pick apart. Can you figure out how we were able to eliminate one of the gradients?

I’ll throw in two additional variations for you to investigate:

See the Pen Rounded tab using CSS mask by Temani Afif.

I’m often asked how I know when my code can be optimized more than it is. That’s truly the most difficult part of everything we’ve covered so far. I do not have any hard rules for how and when to optimize, and it’s not necessary to find the optimal solution, especially if you are a beginner. My advice is to first find the trivial and easy solution, even if it requires a lot of gradients. Then, with a lot of practice, you will be able to find better solutions.

Talking about practice, here’s your next bit of homework: try creating the shapes illustrated in Figure 15:

Six variations of tabs and tooltips with rounded corners and edges.
Figure 15. (Large preview)

These aren’t tabs at all but tooltips! We can absolutely use the exact same masking technique we used to create the tabs for these shapes. Notice how the curves that go inward are consistent in each shape, no matter if they are positioned on the left, right, or both.

You can always find the code over at my online collection if you want to reference it.

More CSS Shapes #

At this point, we’ve seen the main tricks to create CSS shapes. You will rely on mask and gradients if you have curves and rounded parts or clip-path when there are no curves. It sounds simple but there’s still more to learn, so I am going to provide a few more common shapes for you to explore.

Instead of going into a detailed explanation of the shapes in this section, I’m going to give you the recipes for how to make them and all of the ingredients you need to make it happen. In fact, I have written other articles that are directly related to everything we are about to cover and will link them up so that you have guides you can reference in your work.

Triangles #

A triangle is likely the first shape that you will ever need. They’re used in lots of places, from play buttons for videos, to decorative icons in links, to active state indicators, to open/close toggles in accordions, to… the list goes on.

Six triangle shape variations.
Figure 16. (Large preview)

Creating a triangle shape is as simple as using a 3-point polygon in addition to defining the size:

.triangle {
  width: 200px;
  aspect-ratio: 1;
  clip-path: polygon(50% 0, 100% 100%, 0 100%);
}

But we can get even further by adding more points to have border-only variations:

See the Pen border-only triangle shapes by Temani Afif.

Or combine clip-path and mask to get rounded corner variations:

See the Pen Rounded triangles (the modern way) by Temani Afif.

Please check out my article “CSS Shapes: The Triangle” on the Verpex blog for a full explanation of techniques with many examples and variations.

Hearts #

Hearts are another classic shape that’s been tackled with older CSS techniques but have a better modern equivalent. We can pull this off more simply by combining border-image and clip-path:

.heart {
  --c: red;

width: 200px;
aspect-ratio: 1;
border-image: radial-gradient(var(--c) 69%,#0000 70%) 84.5%/50%;
clip-path: polygon(-42% 0,50% 91%, 142% 0);
} 
See the Pen Heart shape using border-image by Temani Afif.

Or use mask-border instead of border-image to transform images into hearts:

See the Pen CSS only heart images by Temani Afif.

The full explanation with additional examples is available in my article “CSS Shapes: The Heart” over at the Verpex blog.

Ribbons #

Ribbons were all the rage back when skeuomorphism was the design fad du jour. They’re still awesome today, and I’ve created a big ol’ collection of them with more than 100 shapes.

There are many different types of ribbons, as you might imagine. So, rather than detail one I will provide you with four articles I have written detailing the general technique (more clips!) and a variety of fun variations for you to consider.

Tooltips & Speech Bubbles #

Like ribbons, there are so many ways we can design a tooltip or a speech bubble; so many that I have another collection showcasing more than 100 of them. The following two-part series provides all of the nitty-gritty details:

By the end, you can literally create as many variations as you can imagine.

Cutting Corners #

Insert your obligatory joke about how we’re supposed to cut corners in life. However, when we cut corners out of squares and rectangles, the result is a nice decorative shape that also works as a frame for images.

Squares with cut corners
Figure 17. (Large preview)

We can cut all the corners or just specific ones. We can make circular cuts or sharp ones. We can even create an outline of the overall shape. Take a look at my online generator to play with the code, and check out my full article on the topic where I am detailing all the different cases.

Section Dividers #

Speaking of visual transitions between sections, what if both sections have decorative borders that fit together like a puzzle?

Three examples of section dividers, one a narrow-angle, one a circular, and one a wide angle.
Figure 18. (Large preview)

I hope you see the pattern now: sometimes, we’re clipping an element or masking portions of it. The fact that we can sort of “carve” into things this way using polygon() coordinates and gradients opens up so many possibilities that would have required clever workarounds and super-specific code in years past.

See my article “How to Create a Section Divider Using CSS” on the freeCodeCamp blog for a deep dive into the concepts, which we’ve also covered here quite extensively already in earlier sections.

Floral Shapes #

We’ve created circles. We’ve made wave shapes. Let’s combine those two ideas together to create floral shapes.

Different flower-like shapes
Figure 19. (Large preview)

These shapes are pretty cool on their own. But like a few of the other shapes we’ve covered, this one works extremely well with images. If you need something fancier than the typical box, then masking the edges can come off like a custom-framed photo.

Here is a demo where I am using such shapes to create a fancy hover effect:

See the Pen Fancy Pop Out hover effect! by Temani Afif.

There’s a lot of math involved with this, specifically trigonometric functions. I have a two-part series that gets into the weeds if you’re interested in that side of things:

As always, remember that my online collection is your Number One resource for all things related to CSS shapes. The math has already been worked out for your convenience, but you also have the references you need to understand how it works under the hood.

Conclusion #

I hope you see CSS Shapes differently now as a result of reading this comprehensive guide. We covered a few shapes, but really, it’s hundreds upon hundreds of shapes because you see how flexible they are to configure into a slew of variations.

At the end of the day, all of the shapes use some combination of different CSS concepts such as clipping, masking, composition, gradients, CSS variables, and so on. Not to mention a few hidden tricks like the one related to the polygon() function:

  • It accepts points outside the [0% 100%] range.
  • Switching axes is a solid approach for creating shape variations.
  • The lines we establish can intersect.

It’s not that many things, right? We looked at each of these in great detail and then whipped through the shapes to demonstrate how the concepts come together. It’s not so much about memorizing snippets than it is thoroughly understanding how CSS works and leveraging its features to produce any number of things, like shapes.

Don’t forget to bookmark my CSS Shape website and use it as a reference as well as a quick stop to get a specific shape you need for a project. I avoid re-inventing the wheel in my work, and the online collection is your wheel for snagging shapes made with pure CSS.

Please also use it as inspiration for your own shape-shifting experiments. And post a comment if you think of a shape that would be a nice addition to the collection.

References #

The Forensics Of React Server Components (RSCs)

 We love client-side rendering for the way it relieves the server of taxing operations, but serving an empty HTML page often leads to taxing user experiences during the initial page load. We love server-side rendering because it allows us to serve static assets on speedy CDNs, but they’re unfit for large-scale projects with dynamic content. React Server Components (RSCs) combine the best of both worlds, and author Lazar Nikolov thoroughly examines how we got here with a deep look at the impact that RSCs have on the page load timeline.

In this article, we’re going to look deeply at React Server Components (RSCs). They are the latest innovation in React’s ecosystem, leveraging both server-side and client-side rendering as well as streaming HTML to deliver content as fast as possible.

We will get really nerdy to get a full understanding of how RFCs fit into the React picture, the level of control they offer over the rendering lifecycle of components, and what page loads look like with RFCs in place.

But before we dive into all of that, I think it’s worth looking back at how React has rendered websites up until this point to set the context for why we need RFCs in the first place.

The Early Days: React Client-Side Rendering #

The first React apps were rendered on the client side, i.e., in the browser. As developers, we wrote apps with JavaScript classes as components and packaged everything up using bundlers, like Webpack, in a nicely compiled and tree-shaken heap of code ready to ship in a production environment.

The HTML that returned from the server contained a few things, including:

  • An HTML document with metadata in the <head> and a blank <div> in the <body> used as a hook to inject the app into the DOM;
  • JavaScript resources containing React’s core code and the actual code for the web app, which would generate the user interface and populate the app inside of the empty <div>.
Diagram of the client-side rendering process of a React app, starting with a blank loading page in the browser followed by a series of processes connected to CDNs and APIs to produce content on the loading page.
Figure 1. (Large preview)

A web app under this process is only fully interactive once JavaScript has fully completed its operations. You can probably already see the tension here that comes with an improved developer experience (DX) that negatively impacts the user experience (UX).

The truth is that there were (and are) pros and cons to CSR in React. Looking at the positives, web applications delivered smooth, quick transitions that reduced the overall time it took to load a page, thanks to reactive components that update with user interactions without triggering page refreshes. CSR lightens the server load and allows us to serve assets from speedy content delivery networks (CDNs) capable of delivering content to users from a server location geographically closer to the user for even more optimized page loads.

There are also not-so-great consequences that come with CSR, most notably perhaps that components could fetch data independently, leading to waterfall network requests that dramatically slow things down. This may sound like a minor nuisance on the UX side of things, but the damage can actually be quite large on a human level. Eric Bailey’s “Modern Health, frameworks, performance, and harm” should be a cautionary tale for all CSR work.

Other negative CSR consequences are not quite as severe but still lead to damage. For example, it used to be that an HTML document containing nothing but metadata and an empty <div> was illegible to search engine crawlers that never get the fully-rendered experience. While that’s solved today, the SEO hit at the time was an anchor on company sites that rely on search engine traffic to generate revenue.

The Shift: Server-Side Rendering (SSR)

Something needed to change. CSR presented developers with a powerful new approach for constructing speedy, interactive interfaces, but users everywhere were inundated with blank screens and loading indicators to get there. The solution was to move the rendering experience from the client to the server. I know it sounds funny that we needed to improve something by going back to the way it was before.

So, yes, React gained server-side rendering (SSR) capabilities. At one point, SSR was such a topic in the React community that it had a moment in the spotlight. The move to SSR brought significant changes to app development, specifically in how it influenced React behavior and how content could be delivered by way of servers instead of browsers.

Diagram of the server-side rendering process of a React app, starting with a blank loading page in the browser followed by a screen of un-interactive content, then a fully interactive page of content.
Figure 2. (Large preview)

Addressing CSR Limitations

Instead of sending a blank HTML document with SSR, we rendered the initial HTML on the server and sent it to the browser. The browser was able to immediately start displaying the content without needing to show a loading indicator. This significantly improves the First Contentful Paint (FCP) performance metric in Web Vitals.

Server-side rendering also fixed the SEO issues that came with CSR. Since the crawlers received the content of our websites directly, they were then able to index it right away. The data fetching that happens initially also takes place on the server, which is a plus because it’s closer to the data source and can eliminate fetch waterfalls if done properly.

Hydration

SSR has its own complexities. For React to make the static HTML received from the server interactive, it needs to hydrate it. Hydration is the process that happens when React reconstructs its Virtual Document Object Model (DOM) on the client side based on what was in the DOM of the initial HTML.

Note: React maintains its own Virtual DOM because it’s faster to figure out updates on it instead of the actual DOM. It synchronizes the actual DOM with the Virtual DOM when it needs to update the UI but performs the diffing algorithm on the Virtual DOM.

We now have two flavors of Reacts:

  1. A server-side flavor that knows how to render static HTML from our component tree,
  2. A client-side flavor that knows how to make the page interactive.

We’re still shipping React and code for the app to the browser because — in order to hydrate the initial HTML — React needs the same components on the client side that were used on the server. During hydration, React performs a process called reconciliation in which it compares the server-rendered DOM with the client-rendered DOM and tries to identify differences between the two. If there are differences between the two DOMs, React attempts to fix them by rehydrating the component tree and updating the component hierarchy to match the server-rendered structure. And if there are still inconsistencies that cannot be resolved, React will throw errors to indicate the problem. This problem is commonly known as a hydration error.

SSR Drawbacks

SSR is not a silver bullet solution that addresses CSR limitations. SSR comes with its own drawbacks. Since we moved the initial HTML rendering and data fetching to the server, those servers are now experiencing a much greater load than when we loaded everything on the client.

Remember when I mentioned that SSR generally improves the FCP performance metric? That may be true, but the Time to First Byte (TTFB) performance metric took a negative hit with SSR. The browser literally has to wait for the server to fetch the data it needs, generate the initial HTML, and send the first byte. And while TTFB is not a Core Web Vital metric in itself, it influences the metrics. A negative TTFB leads to negative Core Web Vitals metrics.

Another drawback of SSR is that the entire page is unresponsive until client-side React has finished hydrating it. Interactive elements cannot listen and “react” to user interactions before React hydrates them, i.e., React attaches the intended event listeners to them. The hydration process is typically fast, but the internet connection and hardware capabilities of the device in use can slow down rendering by a noticeable amount.

The Present: A Hybrid Approach

So far, we have covered two different flavors of React rendering: CSR and SSR. While the two were attempts to improve one another, we now get the best of both worlds, so to speak, as SSR has branched into three additional React flavors that offer a hybrid approach in hopes of reducing the limitations that come with CSR and SSR.

We’ll look at the first two — static site generation and incremental static regeneration — before jumping into an entire discussion on React Server Components, the third flavor.

Static Site Generation (SSG) #

Instead of regenerating the same HTML code on every request, we came up with SSG. This React flavor compiles and builds the entire app at build time, generating static (as in vanilla HTML and CSS) files that are, in turn, hosted on a speedy CDN.

As you might suspect, this hybrid approach to rendering is a nice fit for smaller projects where the content doesn’t change much, like a marketing site or a personal blog, as opposed to larger projects where content may change with user interactions, like an e-commerce site.

SSG reduces the burden on the server while improving performance metrics related to TTFB because the server no longer has to perform heavy, expensive tasks for re-rendering the page.

Incremental Static Regeneration (ISR)

One SSG drawback is having to rebuild all of the app’s code when a content change is needed. The content is set in stone — being static and all — and there’s no way to change just one part of it without rebuilding the whole thing.

The Next.js team created the second hybrid flavor of React that addresses the drawback of complete SSG rebuilds: incremental static regeneration (ISR). The name says a lot about the approach in that ISR only rebuilds what’s needed instead of the entire thing. We generate the “initial version” of the page statically during build time but are also able to rebuild any page containing stale data after a user lands on it (i.e., the server request triggers the data check).

From that point on, the server will serve new versions of that page statically in increments when needed. That makes ISR a hybrid approach that is neatly positioned between SSG and traditional SSR.

At the same time, ISR does not address the “stale content” symptom, where users may visit a page before it has finished being generated. Unlike SSG, ISR needs an actual server to regenerate individual pages in response to a user’s browser making a server request. That means we lose the valuable ability to deploy ISR-based apps on a CDN for optimized asset delivery.

The Future: React Server Components

Up until this point, we’ve juggled between CSR, SSR, SSG, and ISR approaches, where all make some sort of trade-off, negatively affecting performance, development complexity, and user experience. Newly introduced React Server Components (RSC) aim to address most of these drawbacks by allowing us — the developer — to choose the right rendering strategy for each individual React component.

RSCs can significantly reduce the amount of JavaScript shipped to the client since we can selectively decide which ones to serve statically on the server and which render on the client side. There’s a lot more control and flexibility for striking the right balance for your particular project.

Note: It’s important to keep in mind that as we adopt more advanced architectures, like RSCs, monitoring solutions become invaluable. Sentry offers robust performance monitoring and error-tracking capabilities that help you keep an eye on the real-world performance of your RSC-powered application. Sentry also helps you gain insights into how your releases are performing and how stable they are, which is yet another crucial feature to have while migrating your existing applications to RSCs. Implementing Sentry in an RSC-enabled framework like Next.js is as easy as running a single terminal command.

But what exactly is an RSC? Let’s pick one apart to see how it works under the hood.

The Anatomy of React Server Components

This new approach introduces two types of rendering components: Server Components and Client Components. The differences between these two are not how they function but where they execute and the environments they’re designed for. At the time of this writing, the only way to use RSCs is through React frameworks. And at the moment, there are only three frameworks that support them: Next.js, Gatsby, and RedwoodJS.

Wire diagram showing connected server components and client components represented as gray and blue dots, respectively.
Figure 3: Example of an architecture consisting of Server Components and Client Components. (Large preview)

Server Components #

Server Components are designed to be executed on the server, and their code is never shipped to the browser. The HTML output and any props they might be accepting are the only pieces that are served. This approach has multiple performance benefits and user experience enhancements:

  • Server Components allow for large dependencies to remain on the server side.
    Imagine using a large library for a component. If you’re executing the component on the client side, it means that you’re also shipping the full library to the browser. With Server Components, you’re only taking the static HTML output and avoiding having to ship any JavaScript to the browser. Server Components are truly static, and they remove the whole hydration step.
  • Server Components are located much closer to the data sources — e.g., databases or file systems — they need to generate code.
    They also leverage the server’s computational power to speed up compute-intensive rendering tasks and send only the generated results back to the client. They are also generated in a single pass, which avoids request waterfalls and HTTP round trips.
  • Server Components safely keep sensitive data and logic away from the browser.
    That’s thanks to the fact that personal tokens and API keys are executed on a secure server rather than the client.
  • The rendering results can be cached and reused between subsequent requests and even across different sessions.
    This significantly reduces rendering time, as well as the overall amount of data that is fetched for each request.

This architecture also makes use of HTML streaming, which means the server defers generating HTML for specific components and instead renders a fallback element in their place while it works on sending back the generated HTML. Streaming Server Components wrap components in <Suspense> tags that provide a fallback value. The implementing framework uses the fallback initially but streams the newly generated content when it‘s ready. We’ll talk more about streaming, but let’s first look at Client Components and compare them to Server Components.

Client Components

Client Components are the components we already know and love. They’re executed on the client side. Because of this, Client Components are capable of handling user interactions and have access to the browser APIs like localStorage and geolocation.

The term “Client Component” doesn’t describe anything new; they merely are given the label to help distinguish the “old” CSR components from Server Components. Client Components are defined by a "use client" directive at the top of their files.

"use client"
export default function LikeButton() {
  const likePost = () => {
    // ...
  }
  return (
    <button onClick={likePost}>Like</button>
  )
}

In Next.js, all components are Server Components by default. That’s why we need to explicitly define our Client Components with "use client". There’s also a "use server" directive, but it’s used for Server Actions (which are RPC-like actions that invoked from the client, but executed on the server). You don’t use it to define your Server Components.

You might (rightfully) assume that Client Components are only rendered on the client, but Next.js renders Client Components on the server to generate the initial HTML. As a result, browsers can immediately start rendering them and then perform hydration later.

The Relationship Between Server Components and Client Components

Client Components can only explicitly import other Client Components. In other words, we’re unable to import a Server Component into a Client Component because of re-rendering issues. But we can have Server Components in a Client Component’s subtree — only passed through the children prop. Since Client Components live in the browser and they handle user interactions or define their own state, they get to re-render often. When a Client Component re-renders, so will its subtree. But if its subtree contains Server Components, how would they re-render? They don’t live on the client side. That’s why the React team put that limitation in place.

But hold on! We actually can import Server Components into Client Components. It’s just not a direct one-to-one relationship because the Server Component will be converted into a Client Component. If you’re using server APIs that you can’t use in the browser, you’ll get an error; if not — you’ll have a Server Component whose code gets “leaked” to the browser.

This is an incredibly important nuance to keep in mind as you work with RSCs.

The Rendering Lifecycle

Here’s the order of operations that Next.js takes to stream contents:

  1. The app router matches the page’s URL to a Server Component, builds the component tree, and instructs the server-side React to render that Server Component and all of its children components.
  2. During render, React generates an “RSC Payload”. The RSC Payload informs Next.js about the page and what to expect in return, as well as what to fall back to during a <Suspense>.
  3. If React encounters a suspended component, it pauses rendering that subtree and uses the suspended component’s fallback value.
  4. When React loops through the last static component, Next.js prepares the generated HTML and the RSC Payload before streaming it back to the client through one or multiple chunks.
  5. The client-side React then uses the instructions it has for the RSC Payload and client-side components to render the UI. It also hydrates each Client Component as they load.
  6. The server streams in the suspended Server Components as they become available as an RSC Payload. Children of Client Components are also hydrated at this time if the suspended component contains any.

We will look at the RSC rendering lifecycle from the browser’s perspective momentarily. For now, the following figure illustrates the outlined steps we covered.

Wire diagram of the RSC rendering lifecycle going from a blank page to a page shell to a complete page.
Figure 4: Diagram of the RSC Rendering Lifecycle. (Large preview)

We’ll see this operation flow from the browser’s perspective in just a bit.

RSC Payload #

The RSC payload is a special data format that the server generates as it renders the component tree, and it includes the following:

  • The rendered HTML,
  • Placeholders where the Client Components should be rendered,
  • References to the Client Components’ JavaScript files,
  • Instructions on which JavaScript files it should invoke,
  • Any props passed from a Server Component to a Client Component.

There’s no reason to worry much about the RSC payload, but it’s worth understanding what exactly the RSC payload contains. Let’s examine an example (truncated for brevity) from a demo app I created:

1:HL["/_next/static/media/c9a5bc6a7c948fb0-s.p.woff2","font",{"crossOrigin":"","type":"font/woff2"}]
2:HL["/_next/static/css/app/layout.css?v=1711137019097","style"]
0:"$L3"
4:HL["/_next/static/css/app/page.css?v=1711137019097","style"]
5:I["(app-pages-browser)/./node_modules/next/dist/client/components/app-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
8:"$Sreact.suspense"
a:I["(app-pages-browser)/./node_modules/next/dist/client/components/layout-router.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
b:I["(app-pages-browser)/./node_modules/next/dist/client/components/render-from-template-context.js",["app-pages-internals","static/chunks/app-pages-internals.js"],""]
d:I["(app-pages-browser)/./src/app/global-error.jsx",["app/global-error","static/chunks/app/global-error.js"],""]
f:I["(app-pages-browser)/./src/components/clearCart.js",["app/page","static/chunks/app/page.js"],"ClearCart"]
7:["$","main",null,{"className":"page_main__GlU4n","children":[["$","$Lf",null,{}],["$","$8",null,{"fallback":["$","p",null,{"children":"🌀 loading products..."}],"children":"$L10"}]]}]
c:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}]...
9:["$","p",null,{"children":["🛍️ ",3]}]
11:I["(app-pages-browser)/./src/components/addToCart.js",["app/page","static/chunks/app/page.js"],"AddToCart"]
10:["$","ul",null,{"children":[["$","li","1",{"children":["Gloves"," - $",20,["$...

To find this code in the demo app, open your browser’s developer tools at the Elements tab and look at the <script> tags at the bottom of the page. They’ll contain lines like:

self.__next_f.push([1,"PAYLOAD_STRING_HERE"]).

Every line from the snippet above is an individual RSC payload. You can see that each line starts with a number or a letter, followed by a colon, and then an array that’s sometimes prefixed with letters. We won’t get into too deep in detail as to what they mean, but in general:

  • HL payloads are called “hints” and link to specific resources like CSS and fonts.
  • I payloads are called “modules,” and they invoke specific scripts. This is how Client Components are being loaded as well. If the Client Component is part of the main bundle, it’ll execute. If it’s not (meaning it’s lazy-loaded), a fetcher script is added to the main bundle that fetches the component’s CSS and JavaScript files when it needs to be rendered. There’s going to be an I payload sent from the server that invokes the fetcher script when needed.
  • "$" payloads are DOM definitions generated for a certain Server Component. They are usually accompanied by actual static HTML streamed from the server. That’s what happens when a suspended component becomes ready to be rendered: the server generates its static HTML and RSC Payload and then streams both to the browser.

Streaming #

Streaming allows us to progressively render the UI from the server. With RSCs, each component is capable of fetching its own data. Some components are fully static and ready to be sent immediately to the client, while others require more work before loading. Based on this, Next.js splits that work into multiple chunks and streams them to the browser as they become ready. So, when a user visits a page, the server invokes all Server Components, generates the initial HTML for the page (i.e., the page shell), replaces the “suspended” components’ contents with their fallbacks, and streams all of that through one or multiple chunks back to the client.

The server returns a Transfer-Encoding: chunked header that lets the browser know to expect streaming HTML. This prepares the browser for receiving multiple chunks of the document, rendering them as it receives them. We can actually see the header when opening Developer Tools at the Network tab. Trigger a refresh and click on the document request.

Response header output highlighting the line containing the chunked transfer endcoding
Figure 5: Providing a hint to the browser to expect HTML streaming. (Large preview)

We can also debug the way Next.js sends the chunks in a terminal with the curl command:

curl -D - --raw localhost:3000 > chunked-response.txt
Headers and chunked HTML payloads.
Figure 6. (Large preview)

You probably see the pattern. For each chunk, the server responds with the chunk’s size before sending the chunk’s contents. Looking at the output, we can see that the server streamed the entire page in 16 different chunks. At the end, the server sends back a zero-sized chunk, indicating the end of the stream.

The first chunk starts with the <!DOCTYPE html> declaration. The second-to-last chunk, meanwhile, contains the closing </body> and </html> tags. So, we can see that the server streams the entire document from top to bottom, then pauses to wait for the suspended components, and finally, at the end, closes the body and HTML before it stops streaming.

Even though the server hasn’t completely finished streaming the document, the browser’s fault tolerance features allow it to draw and invoke whatever it has at the moment without waiting for the closing </body> and </html> tags.

Suspending Components #

We learned from the render lifecycle that when a page is visited, Next.js matches the RSC component for that page and asks React to render its subtree in HTML. When React stumbles upon a suspended component (i.e., async function component), it grabs its fallback value from the <Suspense> component (or the loading.js file if it’s a Next.js route), renders that instead, then continues loading the other components. Meanwhile, the RSC invokes the async component in the background, which is streamed later as it finishes loading.

At this point, Next.js has returned a full page of static HTML that includes either the components themselves (rendered in static HTML) or their fallback values (if they’re suspended). It takes the static HTML and RSC payload and streams them back to the browser through one or multiple chunks.

Showing suspended component fallbacks
Figure 7. (Large preview)

As the suspended components finish loading, React generates HTML recursively while looking for other nested <Suspense> boundaries, generates their RSC payloads and then lets Next.js stream the HTML and RSC Payload back to the browser as new chunks. When the browser receives the new chunks, it has the HTML and RSC payload it needs and is ready to replace the fallback element from the DOM with the newly-streamed HTML. And so on.

Static HTML and RSC Payload replacing suspended fallback values.
Figure 8. (Large preview)

In Figures 7 and 8, notice how the fallback elements have a unique ID in the form of B:0, B:1, and so on, while the actual components have a similar ID in a similar form: S:0 and S:1, and so on.

Along with the first chunk that contains a suspended component’s HTML, the server also ships an $RC function (i.e., completeBoundary from React’s source code) that knows how to find the B:0 fallback element in the DOM and replace it with the S:0 template it received from the server. That’s the “replacer” function that lets us see the component contents when they arrive in the browser.

The entire page eventually finishes loading, chunk by chunk.

Lazy-Loading Components #

If a suspended Server Component contains a lazy-loaded Client Component, Next.js will also send an RSC payload chunk containing instructions on how to fetch and load the lazy-loaded component’s code. This represents a significant performance improvement because the page load isn’t dragged out by JavaScript, which might not even be loaded during that session.

Fetching additional JavaScript and CSS files for a lazy-loaded Client Component, as shown in developer tools.
Figure 9. (Large preview)

At the time I’m writing this, the dynamic method to lazy-load a Client Component in a Server Component in Next.js does not work as you might expect. To effectively lazy-load a Client Component, put it in a “wrapper” Client Component that uses the dynamic method itself to lazy-load the actual Client Component. The wrapper will be turned into a script that fetches and loads the Client Component’s JavaScript and CSS files at the time they’re needed.

TL;DR #

I know that’s a lot of plates spinning and pieces moving around at various times. What it boils down to, however, is that a page visit triggers Next.js to render as much HTML as it can, using the fallback values for any suspended components, and then sends that to the browser. Meanwhile, Next.js triggers the suspended async components and gets them formatted in HTML and contained in RSC Payloads that are streamed to the browser, one by one, along with an $RC script that knows how to swap things out.

The Page Load Timeline

By now, we should have a solid understanding of how RSCs work, how Next.js handles their rendering, and how all the pieces fit together. In this section, we’ll zoom in on what exactly happens when we visit an RSC page in the browser.

The Initial Load #

As we mentioned in the TL;DR section above, when visiting a page, Next.js will render the initial HTML minus the suspended component and stream it to the browser as part of the first streaming chunks.

To see everything that happens during the page load, we’ll visit the “Performance” tab in Chrome DevTools and click on the “reload” button to reload the page and capture a profile. Here’s what that looks like:

Showing the first chunks of HTML streamed at the beginning of the timeline in DevTools.
Figure 10. (Large preview)

When we zoom in at the very beginning, we can see the first “Parse HTML” span. That’s the server streaming the first chunks of the document to the browser. The browser has just received the initial HTML, which contains the page shell and a few links to resources like fonts, CSS files, and JavaScript. The browser starts to invoke the scripts.

The first frames appear, and parts of the page are rendered
Figure 11. (Large preview)

After some time, we start to see the page’s first frames appear, along with the initial JavaScript scripts being loaded and hydration taking place. If you look at the frame closely, you’ll see that the whole page shell is rendered, and “loading” components are used in the place where there are suspended Server Components. You might notice that this takes place around 800ms, while the browser started to get the first HTML at 100ms. During those 700ms, the browser is continuously receiving chunks from the server.

Bear in mind that this is a Next.js demo app running locally in development mode, so it’s going to be slower than when it’s running in production mode.

The Suspended Component #

Fast forward few seconds and we see another “Parse HTML” span in the page load timeline, but this one it indicates that a suspended Server Component finished loading and is being streamed to the browser.

The suspended component’s HTML and RSC Payload are streamed to the browser, as shown in the developer tools Network tab.
Figure 12. (Large preview)

We can also see that a lazy-loaded Client Component is discovered at the same time, and it contains CSS and JavaScript files that need to be fetched. These files weren’t part of the initial bundle because the component isn’t needed until later on; the code is split into their own files.

This way of code-splitting certainly improves the performance of the initial page load. It also makes sure that the Client Component’s code is shipped only if it’s needed. If the Server Component (which acts as the Client Component’s parent component) throws an error, then the Client Component does not load. It doesn’t make sense to load all of its code before we know whether it will load or not.

Figure 12 shows the DOMContentLoaded event is reported at the end of the page load timeline. And, just before that, we can see that the localhost HTTP request comes to an end. That means the server has likely sent the last zero-sized chunk, indicating to the client that the data is fully transferred and that the streaming communication can be closed.

The End Result

The main localhost HTTP request took around five seconds, but thanks to streaming, we began seeing page contents load much earlier than that. If this was a traditional SSR setup, we would likely be staring at a blank screen for those five seconds before anything arrives. On the other hand, if this was a traditional CSR setup, we would likely have shipped a lot more of JavaScript and put a heavy burden on both the browser and network.

This way, however, the app was fully interactive in those five seconds. We were able to navigate between pages and interact with Client Components that have loaded as part of the initial main bundle. This is a pure win from a user experience standpoint.

Conclusion #

RSCs mark a significant evolution in the React ecosystem. They leverage the strengths of server-side and client-side rendering while embracing HTML streaming to speed up content delivery. This approach not only addresses the SEO and loading time issues we experience with CSR but also improves SSR by reducing server load, thus enhancing performance.

I’ve refactored the same RSC app I shared earlier so that it uses the Next.js Page router with SSR. The improvements in RSCs are significant:

Comparing Next.js Page Router and App Router, side-by-side.
Figure 13. (Large preview)

Looking at these two reports I pulled from Sentry, we can see that streaming allows the page to start loading its resources before the actual request finishes. This significantly improves the Web Vitals metrics, which we see when comparing the two reports.

The conclusion: Users enjoy faster, more reactive interfaces with an architecture that relies on RSCs.

The RSC architecture introduces two new component types: Server Components and Client Components. This division helps React and the frameworks that rely on it — like Next.js — streamline content delivery while maintaining interactivity.

However, this setup also introduces new challenges in areas like state management, authentication, and component architecture. Exploring those challenges is a great topic for another blog post!

Despite these challenges, the benefits of RSCs present a compelling case for their adoption. We definitely will see guides published on how to address RSC’s challenges as they mature, but, in my opinion, they already look like the future of rendering practices in modern web development.