Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Sat, 30 Aug 2025 05:49:58 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 The `-path` of Least Resistance (Part 2) https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/ https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/#comments Sat, 30 Aug 2025 05:47:10 +0000 https://frontendmasters.com/blog/?p=6976 In the previous chapter, we explored clip-path and its power to reshape elements, cutting through the rectangular constraints of traditional elements to create circles, polygons, and complex curved shapes. We learned how to think beyond the box (literally), but everything we covered was about static shapes. About defining boundaries and staying within them.

Now it’s time to break free from containment entirely. In this second part, we’re shifting from shapes that hold things in place to paths that guide movement. We’re moving from clip-path to offset-path, where your elements don’t get clipped into new shapes, they travel along custom routes.

Article Series

We talk about reduced motion for accessibility later in this post, but not all the demos in this post implement that media query as they are specifically demonstrating a concept. It’s up to you to figure out how best implement a reduced motion version of movement for your circumstances.

This isn’t about changing what your elements look like. It’s about changing how they move through space, creating motion that feels natural, intentional, and surprisingly expressive. Like these rounded squares moving along a heart-shaped path:

The above demo uses the shape() syntax which has less browser support than other features talked about in this series, like offset-path and clip-path.

Before the Motion

Let’s break down offset-path too. We already explored the concept of path extensively in the previous article, but what exactly does “offset” mean in this context?

Here’s a crucial difference from what we’ve learned previously. While clip-path works relative to the element’s own border-boxoffset-path works relative to the containing block that establishes the context for this element. The “offset” refers to the element’s position and orientation relative to that containing block, not its own dimensions.

This difference becomes clear when you see multiple elements following the same path. In this demo, three shapes travel along the exact same route. They all share the same offset-path: inset(10px), which creates a rectangular path 10 pixels inward from each edge of the containing block. Note how each shape follows this identical route, even though they have completely different dimensions:

Values and Coordinates

Just like with clip-path, you can define your offset paths using absolute units like pixels for precision, or relative units like percentages for responsive design, giving you granular control over how your paths relate to different parts of the containing block.

You can also use CSS variables to make your paths dynamic, allowing you to change the route based on user interactions or other conditions. You can plug a variable in as an entire path definition, a path function attribute, or a single numeric / coordinate inside a path function.

/* entire path definition */
offset-path: var(--route, none);

/* function attribute */
offset-path: circle(var(--radius, 50%));
offset-path: inset(10px var(--inline-inset, 20px) 20px);

/* single coordinate */
offset-path: polygon(0% 0%, var(--x2, 100%) 0%, 100% 100%, 0% 100%);
offset-path: shape(from 0% calc(var(--x1, 0px) + 10%), line to 100% 100%);

This makes motion paths highly parameterized and easy to orchestrate, and this flexibility is what makes offset-path so powerful for creating engaging, interactive experiences.

You can also use CSS variables on any of the companion properties (offset-distanceoffset-rotateoffset-anchoroffset-position) that will talk about next.

Traveling the Distance

In the previous examples, we’ve seen shapes moving along heart-shaped curves, simple rectangles, and basic circles. But what exactly is moving there? You might be surprised to learn that all of them use exactly the same keyframes:

@keyframes offset {
  0% { offset-distance: 0%; }
  100% { offset-distance: 100%; }
}

The offset-path is actually static, it defines the path itself. The offset-distance property determines where the shape sits along that path. The position can be set as an absolute value or as a percentage, where 0% is the starting point of the path and 100% is the end. It’s the animation between these values that creates the motion along the path.

Beyond Linear Motion

Of course, the animation doesn’t have to be linear from 0 to 100. You can move the shape along the path however you want by setting the position at the appropriate keyframe. Here’s an example where I move two stars on a star-shaped path. Both share the same offset-path, the red star’s animation is linear, and the cyan star uses additional keyframes that move it back and forth along the path.

/* Red star */
@keyframes offset1 {
  0% { offset-distance: 0%; }
  100% { offset-distance: 100%; }
}

/* Cyan star */
@keyframes offset2 {
  0% { offset-distance: 0%; }
  10% { offset-distance: 10%; }
  5%, 20% { offset-distance: 20%; }
  15%, 30% { offset-distance: 30%; }
  25%, 40% { offset-distance: 40%; }
  35%, 50% { offset-distance: 50%; }
  45%, 60% { offset-distance: 60%; }
  55%, 70% { offset-distance: 70%; }
  65%, 80% { offset-distance: 80%; }
  75%, 90% { offset-distance: 90%; }
  85%, 100% { offset-distance: 100%; }
  95% { offset-distance: 110%; }
}

Note that this animation uses a keyframe with offset-distance: 110%, and we’ll talk about negative and overflow distances later in this article.

Interactive Movement

But you’re not limited to keyframe animations. You can also use transition to smoothly animate the offset-distance property in response to different states and user interactions like hover, click, or focus. Like in this example where I set the offset-distance based on which element is being hovered.

Finding Your Anchor

Here’s something that trips up a lot of people when they first start working with offset-path: which part of your element actually travels along the path? By default, it’s the center of the element, but that’s not always what you want.

The offset-anchor property lets you specify which point on your element gets aligned with the path. You can anchor from any corner, edge, or specific coordinate within the element. It works just like transform-origin, accepting keywords like centertop left, or specific values like 75% 25%. This seemingly small detail can completely transform how your animations act and feel.

Setting the Starting Point

Another piece of the puzzle that’s often overlooked is deciding where the path begins. While offset-anchor controls which part of your element follows the path, offset-position determines where that path starts within the containing block.

This is particularly important when you’re using path functions that don’t specify their own starting position. The default value is normal, which places the starting point at the center of the containing block (50% 50%). But you can position it anywhere you want.

With offset-position: auto, the path uses the element’s own box position as the path’s origin. With something like offset-position: 60px 90px, the path starts from that specific position within the containing block, regardless of where the element itself is positioned.

This gives you incredible flexibility in designing motion paths. You can have multiple elements starting from different points but following similar route shapes, or create complex choreographed movements where the starting positions are as carefully controlled as the paths themselves.

Following the Flow

But there’s another crucial piece to making path animations feel natural: rotation. The offset-rotate property controls exactly this. It can automatically rotate your element to match the direction of the path at any given point, or you can set a fixed rotation, or combine both for more complex effects.

The magic keyword here is auto. When set, your element will always face the direction it’s traveling. As it moves around curves and corners, it rotates to stay aligned with the path’s tangent. You can set a fixed angle to override this automatic rotation, or combine a fixed angle with auto direction, like this: offset-rotate: auto 45deg. It means “face the direction of travel, but add an extra 45-degree twist.”

Here’s a perfect example to illustrate the different rotation behaviors. Four arrows travel along the same curved path, but each one demonstrates a different approach to rotation:

The red arrow uses the standard auto behavior, always pointing in the direction of travel. The green arrow ignores the path direction entirely with a fixed 60deg rotation. The cyan arrow combines both approaches with auto 30deg, following the path but with an additional 30-degree offset. And the purple arrow uses reverse, pointing backward along the path as if it’s being pulled rather than leading.

Working With Transforms

Here’s where things get really interesting from a technical perspective. When you use offset-path, you’re not positioning the elements, you’re actually transforming them into their place and angle, very much like using translate() and rotate(). This special type of CSS transform is called an “offset transform”, it’s a distinct layer in the transform stack, and it sits in a very specific position.

The transform order looks like this:

  1. Individual transform properties (translate, rotate, scale)
  2. Offset transform (our offset-path magic happens here)
  3. The transform property

This layering is crucial because it means offset-path transforms are applied after individual transform properties but before the transform property. This can significantly change the final visual result.

The first three use the individual transform properties: one translate, one rotate, one scale. The other three use the transform property: one translate(), one rotate(), one scale(). Because the individual transform properties run before the offset transform and the transform property runs after it, you get six different visual results from the same path.

Performance Considerations

The good thing about offset-path being a part of the transform stack is that it leverages the same hardware acceleration as other CSS transforms. The browser calculates the path geometry once, then efficiently interpolates positions and rotations as needed. No repaints or reflows.

But there are a few performance gotchas to watch out for. Avoid changing the offset-path itself during animations, as it forces expensive recalculations. Instead, animate offset-distance and use CSS variables or classes to switch between different paths.

Also, be mindful of path complexity. Don’t use shape() for a simple straight line, and remember that a circle() performs much better than a path() with hundreds of curve segments. If you’re seeing performance issues, consider simplifying your paths.

Closed vs Open Paths

There’s an important distinction in how different path functions behave when it comes to their start and end points. Some paths are inherently closed and cyclical, while others can be left open with distinct endpoints.

Path functions like circle()inset(), and polygon() always create closed paths. These are cyclical by nature, meaning the 100% position (the end) connects seamlessly back to the 0% position (the start). When an element travels along these paths, it forms a continuous loop without any jarring jumps or discontinuities.

In contrast, functions like path() and shape() give you explicit control over whether the path is closed or open. With these functions, you can choose to close the path (creating that seamless loop) or leave it open. When a path is left open, there’s a distinct gap between the endpoint and the starting point. If an element travels from 100% back to 0%, it will visually “jump” from the final position directly to the starting position.

In this example, all three shapes follow a similar path with just two lines, forming an inverted V shape. You can see that both the polygon() and the closed path() treat the gap between the last and first points as part of the path, even though it’s not explicitly defined that way. The middle path() remains open, so when it reaches the endpoint, it jumps directly back to the start.

Negative and Overflow Distances

This distinction between closed and open paths becomes particularly important when you start using offset-distance values outside the typical 0% to 100% range.

For closed paths, the cyclical nature means you can use any distance value, even negative numbers or values over 100%. Since the path loops back on itself, these values get normalized to their equivalent position within the 0-100% range. An offset-distance of 120% on a closed path is equivalent to 20%, and -15% becomes 85%. The element simply continues around the loop, making multiple revolutions if needed.

Open paths behave very differently. Here, distance values get clamped to the 0-100% range. Any value greater than 100% will position the element at the endpoint of the path, and any value less than 0% will keep it at the starting point. There’s no wrapping or continuation because there’s nowhere for the path to continue beyond its defined endpoints.

In this demo, you can play with the distance slider, which gives you a range from -50% to 150%, and see how the different paths respond.

This difference opens up interesting animation possibilities. With closed paths, you can create smooth multi-revolution animations by animating from 0% to values like 200% or 300%. With open paths, you might use values beyond the normal range to create pause effects at the endpoints, or to ensure the element stays put even if the animation overshoots.

Split Paths

We’ve seen the jump between the endpoint and starting point in open paths, and while that’s not always what we want, sometimes it’s exactly what we need.

Sometimes we need to interrupt the animation at one location and restart it at another, which wasn’t always straightforward until now. Using shape(), we can cut the motion of an animation in the middle of the path and restart it with a move command.

Here’s an example of a shape I created that’s cut in the middle.

offset-path: shape(
  from 80% 30%,
  curve to 100% 50% with 90% 20% / 105% 40%,
  curve to 100% 70% with 95% 60%,
  curve to 80% 90% with 105% 80% / 90% 100%,
  curve to 60% 90% with 70% 80%,
  move to 20% 70%, /* here's the cut */
  curve to 0% 50% with 10% 80% / -5% 60%,
  curve to 0% 30% with 5% 40%,
  curve to 20% 10% with -5% 20% / 10% 0%,
  curve to 40% 10% with 30% 20%
);

And here’s how it looks if we visualize the path itself:

Illustration of a motion path with labeled starting point, end point, and a movement command to a specific coordinate.

If we take this exact path, use it to move some circles, add some styling and perspective to make them look like a colorful twisting snake of balls, and add ‘gates’ as portals between the transition points, we get something like this:

Infinite Directions

As we’re getting closer to the end of our deep dive, let’s talk about something special that exists only in offset-path: the ray() function.

While most path functions define specific routes with clear start and end points, ray() takes a completely different approach. It creates an infinite straight line extending from a specified starting point in a given direction. Think of it as a laser beam that goes on forever.

offset-path: ray(var(--angle)); 

As you can see, the syntax is refreshingly simple. You put an <angle> inside the function, and that angle determines where the ray will point (0deg points right, 90deg points down).

So this covers the direction the ray is pointing, but if it’s an infinite line, what does 100% actually mean?

100% out of infinite

The default 100% is closest-side, which means the distance from the ray’s starting point to the closest side is 100%. We can define this distance using an optional keyword that controls how far the ray extends before the element reaches 100% distance.

There are five keywords in total: closest-sideclosest-cornerfarthest-sidefarthest-corner, and sides. To understand the difference between them, here’s an example where if you hover over the element, the mouse cursor position represents the ray’s starting position, and you can see what each keyword means relative to that position.

Notice that sides always stays constant, because it represents the distance to the element’s edge, regardless of the ray’s angle.

Here’s an example that uses the sides keyword. Here too, the mouse cursor position represents the ray’s center, and from there each star animates to the closest side and back. Hover over it to see how it reacts.

What makes ray() particularly interesting is that it’s always an open path, but unlike other open paths, there’s no defined endpoint. When you animate beyond 100%, the element just keeps traveling in that direction indefinitely. This makes it perfect for creating elements that fly off screen, laser effects, or directional animations that need to feel endless.

Reduced Motion

Just like any other animation, offset-path animations should respect user preferences and accessibility guidelines. When users have enabled reduced motion in their system settings, it’s important to either reduce or completely disable path animations accordingly. This ensures your interactive experiences remain accessible and comfortable for all users, including those who may experience motion sensitivity or vestibular disorders.

/*  animate only if the user has not expressed a preference for reduced motion */
@media (prefers-reduced-motion: no-preference) {
  .moving-shape {
    animation: offset 5s linear infinite;
  }
}

Wrapping Up

And just like that, we’ve completed our journey through the -path universe. What started in Part 1 as static shapes carved from rectangular constraints has evolved into something far more dynamic and expressive. We learned to think beyond the traditional box with clip-path, mastering the art of containment. Now with offset-path, we’ve transcended those boundaries entirely. Your elements no longer just exist in custom shapes, they dance through space along routes you design.

Together, these properties form a complete vocabulary for spatial expression in CSS. clip-path gives you control over form, while offset-path gives you control over motion. One carves space, the other travels through it. The combination unlocks interface animations that feel both natural and magical.

The path of least resistance isn’t always the straight line between two points. Sometimes it’s the beautiful curve that makes the journey more meaningful than the destination. And now you have the tools to create those curves, whether they contain your elements or carry them forward into new possibilities.

Article Series

]]>
https://frontendmasters.com/blog/the-path-of-least-resistance-part-2/feed/ 2 6976
The `-path` of Least Resistance (Part 1) https://frontendmasters.com/blog/the-path-of-least-resistance-part-1/ https://frontendmasters.com/blog/the-path-of-least-resistance-part-1/#respond Wed, 27 Aug 2025 18:58:53 +0000 https://frontendmasters.com/blog/?p=6758 There’s a whole layer of CSS that lives just below the surface of most interfaces. It’s not about layout, spacing, or typography. It’s about shape. About cutting through the default boxes and letting your UI move in new directions. This series is all about one such family of features, the kind that doesn’t just style your layout but gives you entirely new ways to shape, animate, and express your interface.

In this first part, we’ll explore clip-path. We’ll start simple, move through the functions and syntax, and work our way up to powerful shape logic that goes way beyond the basic polygons you might be used to. And just when you think things can’t get any more dynamic, part two will kick in with offset-path, where things really start to move.

Article Series

What is clip-path in CSS?

At its core, clip-path lets us control which parts of an element are visible. It’s like a stencil or cookie cutter for HTML elements. Instead of displaying a rectangular box, you can show just a circle, a triangle, a star, or any complex shape you define. And you can do it with a single line of CSS.

This opens the door to more expressive designs without relying on images, SVG wrappers, or external tools. Want to crop a profile picture into a fancy blob shape? Easy. Want to reveal content through a custom cutout as a hover effect? Done. That’s exactly where clip-path shines. But to use it effectively, we need to understand what it’s made of.

Before the syntax

To really get clip-path, let’s break it into two basic concepts: clip and path. No joke, each one of those carries an important lesson of its own.

This is not the “clip” you know

We’ve all seen clipping in CSS before, usually through the overflow property, set to hidden or clip. By doing so, anything that spills out of the element’s box just vanishes.

But here’s the key difference. While the overflow property clips the content of the element (on the padding box for hidden, and on the overflow clip edge for clip), the clip-path property clips the element itself.

This means that even the simplest clip-path, which visually mimics overflow clipping, will still hide parts of the element itself. That includes things like a box-shadow you were expecting to see, or an outline on a button that suddenly disappears and breaks accessibility.

Also worth noting: just like overflow, clip-path lives entirely in two dimensions. No depth, no perspective. It flattens everything. That means transform-style: preserve-3d is ignored, and any 3D motion will stay locked to the element’s plane.

The “path” to success

This one trips people up. Especially when you’re working with functions like polygon(), it’s tempting to think of the shape as just a bunch of points. But it’s not just the points that matter, it’s the order they come in. You’re not dumping coordinates into a bucket, you’re connecting them, one by one, like a game of “connect the dots.”

A connect-the-dots illustration of a dinosaur character, featuring numbered dots in a sequence, set against a black background.

The path is the journey from one point to the next. The way you sequence them defines the outline, the curves, and eventually the clipped shape. If the points are out of order, your shape won’t behave the way you expect.

Values and Coordinates

You can set the coordinates for your shapes in absolute units like pixels, which stay fixed regardless of the element’s size, or in relative units like percentages, which adapt based on the element’s dimensions. Absolute values give you precision, while relative values make your shapes more responsive. In practice, you’ll often mix the two to balance consistency and flexibility.

By default, every shape you define with clip-path is calculated relative to the element’s border-box. This means the point 0 0 sits at the top-left corner of that box, and all coordinates extend from there. Positive X values move to the right, and positive Y values move down.

Note that you’re not limited to the border-box; the clip-path property also accepts an optional <geometry-box> value, which lets you choose the reference box for your shape, giving you more control over how the clip is applied.

Basic Shapes

Let’s begin with the simplest shape of all. The circle() function creates a circular clipping path that allows you to cut content into a perfect circle shape. This function accepts two main parameters: the radius of the circle and its center position.

The basic syntax follows this pattern:

clip-path: circle(radius at position);

The radius can be specified in various units, like pixels (px), percentages (%), or viewport units (vw, vh). The position defines where the center of the circle should be placed, using coordinates relative to the element’s dimensions.

This demo shows a live preview of the circle() function in action. You can drag the control nodes to adjust both the center position and radius of the circular clip path. As you manipulate these controls, you’ll see the clipped area update in real time, and the corresponding CSS values will be displayed below the preview.

Use the checkbox to toggle between pixel and percentage values to see how the result can be expressed in different units. This is particularly useful when you need responsive clipping that adapts to different screen sizes.

Using Keywords

Beyond specific coordinate values, CSS also supports several convenient keywords for positioning the circle’s center. You can use keywords like center, top, bottom, left, and right, or combine them for more precise placement, such as top left or bottom right. These keywords provide a quick way to achieve common positioning without calculating exact pixel or percentage values.

You can also use special keywords for the radius: closest-side and farthest-side. The closest-side keyword sets the radius to the distance from the center to the closest edge of the element, while farthest-side extends the radius to the farthest edge.

For example:

clip-path: circle(50px at left);
clip-path: circle(30% at top right);
clip-path: circle(closest-side at top 25%);
clip-path: circle(farthest-side at center);

Slightly stretched: ellipse()

Now let’s take that circle and give it two radii instead of one. The ellipse() function works similarly to circle(), but instead of creating a perfect circle, it produces an oval shape by accepting two separate radius values. This gives you independent control over both the horizontal and vertical dimensions of the clipping shape.

The syntax extends the circle pattern with an additional radius parameter:

clip-path: ellipse(radiusX radiusY at position);

This demo shows the ellipse() function with three control nodes, that allow you to independently adjust the horizontal and vertical radii. Notice how you can create anything from a wide, flat oval to a tall, narrow shape by manipulating these controls separately.

Rectangular Shapes

While circle() and ellipse() create curved clipping paths, CSS also provides several functions for creating rectangular clips. These functions offer different approaches to defining the same basic shape: a rectangle with straight edges.

inset(), rect(), and xywh()

These three are all about boxes, but each one approaches it differently.

  • inset() defines distances to clip inward from each edge. Its like padding in reverse, instead of adding space inside the box, you remove it.
  • rect() uses absolute coordinates from the top-left corner to define the rectangle’s edges. A legacy function from the old clip property, but still valid and supported in CSS.
  • xywh() defines a rectangle by position and size. The first two values set the X and Y coordinates for the top-left corner, and the next two define the width and height. Clean and straightforward.

This demo lets you compare all three rectangular functions using the same visual controls. Drag the red control lines to adjust the clipping boundaries, and use the dropdown to switch between the different function syntaxes. Notice how the same visual result produces different coordinate values depending on which function you choose.

The inset() function is generally the most intuitive since it works similarly to CSS padding, while rect() follows the traditional clipping rectangle approach. The newer xywh() function uses a more familiar x, y, width, height pattern commonly found in graphics programming.

Now for the fun part: polygon()

Here’s where things get interesting. While circles, ellipses, and rectangles are useful, they’re also predictable. The polygon() function is where you start building custom shapes, point by point, corner by corner.

At its heart, polygon() is wonderfully straightforward. You define a series of coordinate pairs, and CSS connects them in order to create your shape:

clip-path: polygon(x1 y1, x2 y2, x3 y3, ...);

Remember when we talked about the “path” concept earlier? This is where it really shows. Each coordinate pair is a waypoint, and CSS draws straight lines between them in the exact sequence you provide. Here’s a perfect example of why order matters. Take these five points:

/* Pentagon-like shape */
clip-path: polygon(50% 0%, 98% 35%, 79% 91%, 21% 91%, 2% 35%);

/* Same points, different order - creates a star */
clip-path: polygon(50% 0%, 79% 91%, 2% 35%, 98% 35%, 21% 91%);

Same coordinates, completely different shapes. The first creates a neat pentagon-like outline, while the second forms a classic five-pointed star. It’s that simple connection from point to point that builds your final shape.

Polygon Builder

Here’s a demo that lets you create and modify polygons in real time. You can drag the red control nodes to reshape your polygon, add or remove points, and see the resulting CSS code update instantly. Toggle the checkbox to switch between pixel and percentage values for responsive design.

Use the “Add Node” button to introduce new points along your polygon’s edges, or “Remove Node” to simplify the shape. Notice how each modification creates a completely new path—and how the order of your points defines the final appearance.

When Straight Lines Aren’t Enough

Polygons are powerful, but they have one fundamental limitation: they’re made entirely of straight lines. Sometimes your design calls for curves, smooth transitions, or complex shapes that can’t be achieved by connecting points with straight edges. That’s where path() and shape() step in.

path(): Raw Power, Borrowed from SVG

The path() function brings the full power of SVG path syntax directly into CSS. If you’ve ever worked with vector graphics, this will feel familiar. The syntax is identical to SVG’s <path> element:

clip-path: path("M 10,10 L 50,10 L 50,50 Z");

You can use any SVG path command: M for move, L for line, C for cubic curves, Q for quadratic curves, and so on. This gives you incredible precision and the ability to create complex shapes with smooth curves and sharp angles exactly where you want them.

If you’re not comfortable writing path commands by hand, there are plenty of free online SVG path editors like SVG Path Editor or Boxy SVG that can generate the path string for you.

Here’s a simple heart shape as an example:

clip-path: path("M100,178 L87.9,167 C45,128 16.7,102 16.7,71 C16.7,45 37,25 62.5,25 C77,25 90.9,32 100,42 C109.1,32 123,25 137.5,25 C163,25 183.3,45 183.3,71 C183.3,102 155,128 112.1,167 Z");

But here’s the catch: because path() comes from the SVG world, it only works with absolute values. There are no percentages, no responsive units. If your element changes size, your clipping path stays exactly the same. For truly flexible, responsive shapes, we need something more modern.

shape(): The Modern Approach

Enter shape() – CSS’s answer to the limitations of path(). It provides the same curve capabilities as path() but with a more CSS-friendly syntax and support for relative units like percentages.

Here’s the same heart shape, but using shape() with relative coordinates:

clip-path: shape(
  from 50% 89%,
  line to 43.95% 83.5%,
  curve to 8.35% 35.5% with 22.5% 64% / 8.35% 51%,
  curve to 31.25% 12.5% with 8.35% 22.5% / 18.5% 12.5%,
  curve to 50% 21% with 38.5% 12.5% / 45.45% 16%,
  curve to 68.75% 12.5% with 54.55% 16% / 61.5% 12.5%,
  curve to 91.65% 35.5% with 81.5% 12.5% / 91.65% 22.5%,
  curve to 56.05% 83.5% with 91.65% 51% / 77.5% 64%,      
  close);

This demo shows the same heart shape created with both methods. The key difference becomes apparent when you resize the containers. Grab the bottom-right corner of each shape and drag to change its size.

Notice how the path() version maintains its fixed pixel dimensions regardless of the container size, while the shape() version scales proportionally thanks to its percentage-based coordinates. This responsiveness is what makes shape() particularly powerful for modern web design and represents the future of CSS clipping paths.

Syntax Table

If you’re coming from an SVG background, you’ll find the transition to shape() remarkably intuitive. The syntax translates beautifully from SVG path commands, maintaining the same logic while embracing CSS’s flexible unit system.

Just as SVG paths distinguish between absolute (uppercase) and relative (lowercase) commands, shape() uses the keywords to and by. Commands with to are positioned relative to the element’s origin, while commands with by are positioned relative to the previous point in the path.

SVG PathShape EquivalentDescription
M/mfromSet first point
M 10 20
m 10 20
move to 10px 20px
move by 10px 20px
Move point
L 30 40
l 30 40
line to 30px 40px
line by 30px 40px
Draw line
H 50
h 50
hline to 50px
hline by 50px
Horizontal line
V 60
v 60
vline to 60px
vline by 60px
Vertical line
C x1 y1 x2 y2 x y
c x1 y1 x2 y2 x y
curve to x y with x1 y1 / x2 y2
curve by x y with x1 y1 / x2 y2
Cubic curve with two control points
S x1 y1 x y
s x1 y1 x y
curve to x y with x1 y1
curve by x y with x1 y1
Cubic curve with one control point
Q x1 y1 x y
q x1 y1 x y
smooth to x y with x1 y1
smooth by x y with x1 y1
smooth curve with one control point
T x y
t x y
smooth to x y
smooth by x y
smooth curve with no control point
A rx ry angle la sw x y
a rx ry angle la sw x y
arc to x y of rx ry sw la angle
arc by x y of rx ry sw la angle
Arc with radii, rotation, and flags
Z/zcloseClose the path

Self-Intersecting Polygons and Fill Rules

Here’s where things get mathematically interesting. When you create shapes where lines cross over each other, CSS has to decide which areas should be visible and which should remain transparent. This is controlled by fill rules, and understanding them unlocks some powerful creative possibilities.

CSS supports two fill rules: evenodd and nonzero. The difference becomes clear when you see them in action. Here’s a simple rounded star with both fill rules:

  • Even-odd rule: (on the left) Think of it as a simple counting game. Draw an imaginary line from any point to the edge of your element. Every time that line crosses a path edge, count it. If you end up with an odd number, that area gets filled. Even number? It stays transparent. This is why star centers appear hollow, the crossing lines create even-numbered intersections there.
  • Nonzero rule: (default value, on the right) This one’s about direction and flow. As your path travels around the shape, it creates a “winding” effect. Areas that get wound in one direction stay filled, while areas where clockwise and counter-clockwise paths cancel each other out become transparent. In most simple shapes like our star, everything winds the same way, so everything stays filled.

This gives you precise control over complex self-intersecting shapes, letting you create intricate patterns with internal cutouts or solid fills, all depending on which fill rule you choose.

Wrapping up

We’ve covered a lot of ground here. From simple circles to complex self-intersecting stars, clip-path gives you an entirely new vocabulary for shaping your interface. We started with basic geometry, built up to custom polygons, and finally broke free from straight lines with curves and precision.

But here’s the thing: everything we’ve explored so far has been about containment. About cutting away, hiding, cropping. We’ve been thinking inside the box, even when we’re changing its shape.

What if I told you there’s another way to think about paths in CSS? What if, instead of using them to constrain and contain, you could use them to guide and direct? What if your elements could follow curves, travel along custom routes, and move through space in ways that feel natural and intentional?

That’s exactly where we’re heading in part two. We’re going to shift from static shapes to dynamic motion, from clip-path to offset-path. Your elements won’t just be differently shaped—they’ll be dancing along curves you design, following trajectories that bring your interface to life.

The path of least resistance is about to get a whole lot more interesting.

Article Series

]]>
https://frontendmasters.com/blog/the-path-of-least-resistance-part-1/feed/ 0 6758
Understanding CSS corner-shape and the Power of the Superellipse https://frontendmasters.com/blog/understanding-css-corner-shape-and-the-power-of-the-superellipse/ https://frontendmasters.com/blog/understanding-css-corner-shape-and-the-power-of-the-superellipse/#comments Mon, 23 Jun 2025 14:29:16 +0000 https://frontendmasters.com/blog/?p=6270 /* For MathJax */ .article-content p img { display: inline; @media (prefers-color-scheme: dark) { filter: invert(1); } }

The CSS corner-shape property represents one of the most exciting additions to web design’s geometric toolkit in recent years. Extending our ability to control the appearance of corners beyond the simple rounded edges we’ve become accustomed to with border-radius, this seemingly small addition unlocks a world of new possibilities that previously required complex SVG implementations or image-based solutions.

Demo

As of this writing (June 2025), corner-shape is a very new feature with limited browser support, currently only available in Chrome (version M139 and above). The specification may still undergo changes. Try Chrome Canary right now to view these demos.

Before we dive into the advanced capabilities of this property, let’s first understand the foundation it builds upon: the familiar border-radius property that has shaped our corners for over a decade.

The Foundation

The border-radius property gave us the ability to easily create rounded corners on elements. At the max value, using absolute values (like pixels) creates pill shapes, while percentage values create consistent rounded corners. However, any non-zero radius would always create an elliptical curve.

While it is a powerful and useful tool, sometimes we need something… different. This is where the new corner-shape property comes in, expanding our geometric vocabulary beyond just rounded corners.

Introducing corner-shape

The corner-shape property works as a companion to border-radius, where border-radius determines the ‘size’ of the curve, and corner-shape defines how that curve looks.

CSS provides several predefined keywords for corner shapes:

  • round (default): Creates the traditional circular or elliptical corners
  • squircle: A smooth blend between a square and circle
  • scoop: Concave quarters of an ellipse
  • bevel: Straight lines connecting the corner points
  • notch: Creating an inward corner
  • square: Maintains right angles regardless of border-radius (why is this a thing?!)

These keywords enable us to create diverse and visually interesting borders, without resorting to complex implementations, and allow us to easily produce simple geometric shapes like rhombuses, octagons, or plus signs (+).

.rhombus {
  aspect-ratio: 2 / 3;
  border-radius: 50%;
  corner-shape: bevel;
}

.octagon {
  aspect-ratio: 1;
  border-radius: calc(100% / (2 + sqrt(2))); /* ~29% */
  corner-shape: bevel;
}

.plus {
  aspect-ratio: 1;
  border-radius: calc(100% / 3);
  corner-shape: notch;
}

.plus-alt {
  /* rotate to get a X sign */
  rotate: 45deg;
}

In the not-so-distant future, we will also be able to use the corners shorthand to write things more conveniently. Like this:

.rhombus { 
  corners: 50% bevel;
}

We can create even more shapes with squircle and scoop, but we’ll explore those in depth when we discuss Superellipses. For now, let’s talk about using multiple values…

Working with Multiple Values

So far, we’ve used a single value for border-radius, but we can get much more creative. As you may know, border-radius can accept up to eight different values (horizontal and vertical radii for each of the four corners).

This becomes particularly interesting when combined with corner-shape. The interaction between different radius values and corner shapes creates a rich playground for design experimentation.

By using multiple values, we can create additional designs and shapes, like my all time favorite shape – hexagons. Now we can generate perfect hexagons with just a few simple lines of CSS. Using a bevel shape for the corners, and giving different values to the horizontal and vertical radii (50% and 25%).

.hexagon {
  aspect-ratio: cos(30deg);
  border-radius: 50% / 25%;
  corner-shape: bevel;
}

.hexagon-alt {
  aspect-ratio: 1 / cos(30deg);
  border-radius: 25% / 50%;
}

But border-radius is not the only one that can accept multiple values, corner-shape is a shorthand too, and can take up to 4 values, one for each corner. In this way, we can create even more unique shapes.

.shapeA {
  aspect-ratio: 1 / 2;
  border-radius: 40% / 50%;
  corner-shape: bevel squircle squircle bevel;
}
.shapeB {
  aspect-ratio: 1;
  border-radius: 10% 50% 50%;
  corner-shape: round scoop bevel;
  rotate: 45deg;
}
.shapeC {
  aspect-ratio: 1;
  border-radius: 10% 10% 50% 50%;
  corner-shape: round round scoop scoop ;
}

One of these shapes made me think of speech bubbles. We always needed a little help from pseudo-elements (and some hacking) to create an ‘arrow’ at the bottom of the element. Now, we can do it with a simple border-radius.

We can also declare just one specific corner, which can be particularly useful when we want to style just one corner differently, for example, to create space for a counter or a button at the corner of an element.

.container {
  border-radius: 40px;
  corner-top-right-shape: scoop;
}

Animation and Transition of corner-shape

If you clicked the red button in the previous example (good!), you might have noticed that the closing and opening of the element occurs through a transition of the corner-shape. I take advantage of the fact that a notch with a radius of 50% essentially clip the element completely.

Just like the border-radius, you can also animate the corner-shape property or smoothly transition from one shape to another.

The square value, which might seem useless at first glance, is actually very useful in animations and transitions when we want to animate the border to a square shape.

The Power of Superellipse

So far, we’ve explored the predefined keywords of corner-shape, which are great, but there’s an even more powerful option we haven’t discussed yet: the Superellipse! This is arguably more impressive than all the previous options combined, as those keywords are essentially just specific points along the superellipse spectrum. So let’s understand what are superellipses, what is the superellipse function, and how we can use it to fine-tune our borders.

A colorful geometric design featuring lines and curves arranged in a symmetrical pattern, resembling a superellipse with a vibrant gradient from black to neon colors.

As a mathematical concept, the superellipse has existed for about a hundred years, and I love it because someone took something that had existed for centuries (the ellipse formula) and said, “let’s do this differently”. Gabriel Lamé took the formula for an ellipse [x^2 + y^2 = 1] and asked, what happens if we replace the ^2 with something else?! The result: hundred years goes by, his formula is embedded in browsers, making thousands of web developers happier.

Math dudes will comment the actual formula for an ellipse is [x^2/a^2 + y^2/b^2 = 1] but since the [a] and [b] are the axis set by the element’s width and height, we can set [a = b = 1] and get the simplified formula [x^2 + y^2 = 1]

When we replace the ^2 with a variable (n), this variable would now represent the “squareness” of the shape. If ^2 represents a regular circle, then with values greater than ^2, the curve approaches a rectangle, and with values less than ^2, the line starts to straighten. When [n = 1], it means [x^1 + y^1 = 1], which is exactly the same as [x + y = 1], which is actually the formula of a straight line, resulting in a diamond shape. And any number between zero and one gives us a concave shape.

The CSS Superellipse function

But if all this sounds a bit complicated, don’t worry, the CSS folks took care of us, making this function much simpler and way more intuitive. Unlike Lamé’s formula, the CSS superellipse() function takes an argument (let’s call it k) that can be any value, positive or negative. And the way the function works is like this:

  • Zero is the ‘mid-point’, so if we pass 0 to the function, i.e. superellipse(0), we get a straight line, just like the bevel we saw earlier.
  • Any positive number will give us an outward-curved arc, where superellipse(1) is the regular circle, superellipse(2) is our squircle, and as the number increases, the shape will look more like a square, with infinity giving us the square shape.
  • And the same happens with negative numbers. superellipse(-1) gives us a star-like shape, which is the scoop we saw earlier, and the lower the number, the more deeply concave the corner becomes.

Simple, right? But how?!

The CSS function superellipse(k) uses the exponent 2^k to map the value to n. So [n = 2^k], and the complete formula looks like this:

x^(2^k) + y^(2^k)

This calculation format is much more logical and makes the feature more intuitive.

When [k = 0] it means that [n = 2^0] or [n = 1], hence the straight line. If [k = 1] then [n = 2], which is the regular round curve. And if [k > 1] we get [n > 2], resulting in more ‘squircle’ shape. On the other side, when we give the function a negative number, [k < 0], n will always be a number between zero and one, resulting in a concave curve towards the center of the element.

Different Exponents

One limitation of the current superellipse() function is that we can’t pass two variables to it, like superellipse(k, l), to use different exponents for the x and y axes. That would give us the formula [x^(2^k) + y^(2^l) = 1].

If this were possible, we could create some really interesting shapes like this:

A mathematical illustration of a superellipse, depicted with a pink outline on a black background.

Beyond Two Dimensions

While it’s completely outside the scope of this article, if you’ve made it this far, you might be interested to know that the superellipse concept extends to higher dimensions as well. There’s the Superellipsoid that exists in three dimensions, and you can represent a hyperellipsoid in d dimensions using the following formula:

x_1^n + x_2^n + ... + x_d^n = 1

Wrapping up

This article walked us through the evolution from a simple rounded edges to a full geometric vocabulary with the new CSS corner‑shape property. We explored the predefined shapes, and discover how they map directly to points on the superellipse spectrum, and dive into the true power of the superellipse function. We saw how we can mix multiple values to craft wild shapes, how to animate and transition the corners, and how to exploit even the weird edge case to our advantage.

Now it’s your turn: bring these ideas into your own work. Open a new Pen, add a corner‑shape to a simple box or button, and watch how a tiny tweak changes the whole feel of your design. Don’t forget to share your experiments with the community, inspire others with unexpected curves and help drive this bold new CSS feature into everyday use.


I’d like to give special thanks to Noam Rosenthal for reviewing the code and examples, helping refine the ideas, and of course, for authoring the spec for corner-shape and implementing it in chromium.

]]>
https://frontendmasters.com/blog/understanding-css-corner-shape-and-the-power-of-the-superellipse/feed/ 3 6270
CSS Spotlight Effect https://frontendmasters.com/blog/css-spotlight-effect/ https://frontendmasters.com/blog/css-spotlight-effect/#comments Mon, 26 May 2025 15:02:35 +0000 https://frontendmasters.com/blog/?p=5939 I recently made an experiment about Proximity Reactions. The idea was to create an interactive effect according to the mouse position relative to elements. Then I made a less JavaScript, more CSS version where the only thing JavaScript does is to pass the mouse position into a couple of CSS custom properties. That’s it. All the heavy lifting happened inside the CSS itself, safely away from the JavaScript thread.

That got me thinking: if we can get the mouse position in CSS so easily, what else can we build with that? I started tinkering, trying out different interaction patterns, and eventually got to this Spotlight Effect that’s easy to create, simple to customize, and looks surprisingly slick, all with just a few lines of CSS.

Let’s take a look at how it works and how you can make it your own, and hopefully you can pick up a few new CSS tricks along the way. 🙂

The Setup

To create a spotlight effect that responds to the mouse position, we need to set up two small things before diving into the CSS.

  1. We need a dedicated spotlight element in the DOM. This is usually placed near the end of the markup so it can sit on top of everything else when needed.
  2. We need just a few lines of JavaScript to pass the mouse coordinates into CSS custom properties.
<div class="spotlight"></div>
document.body.addEventListener('mousemove', (e) => {
  document.body.style.setProperty('--clientX', e.clientX + 'px');
  document.body.style.setProperty('--clientY', e.clientY + 'px');
});

That is all. No fancy libraries, no event throttling, just raw coordinates handed over to CSS, where the real magic happens.

Basic follow

Now that the setup is in place, we can start writing some CSS. We will begin with a very basic version of the spotlight effect: a simple transparent circle that follows the mouse movements. There are many ways to implement this kind of effect. Using transform is a common and often more precise approach in some cases. But for our example, we are going to tap into the power of background-image. This gives us a lot of creative flexibility, especially when we’ll start creating patterns with gradients later on.

Here is the CSS for our initial spotlight:

.spotlight {
  position: fixed;
  inset: 0;
  background-image: radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent 6em, black 8em);
}

Notice that we set position: fixed and inset: 0, this ensures that it fills the entire viewport, anchoring it to the edges of the body, and stays in place when the user scroll down the page. With that in place, we can position the transparent circle (made with a simple radial-gradient) using the CSS custom properties that our JavaScript sets. It really is that simple.

I’m using em units for sizing. This makes everything scale relative to the font size, and it makes it very easy to adjust the size of the entire effect just by changing the font size on this element.

Here is the result:

To make the effect feel a bit lighter, I also added a touch of opacity. I think it creates a more layered and subtle look. More importantly, I set pointer-events: none on the .spotlight element. Since this layer sits above everything else in the DOM, we want to make sure it does not block any user interaction with the elements below it. Without this, buttons, links, and other interactive parts of the page would become unresponsive.

I’m not using cursor: none; here. While it might seem like an good choice for effects like this, hiding the mouse cursor can lead to accessibility issues and negatively impact the user experience. It’s generally best to avoid it.

Making It Interesting

This is where things start to get fun. Instead of a simple circle, we can turn our spotlight into a dynamic, interactive effect that responds to the mouse movement in playful ways. The technique we will use involves layering gradients in the background-image and combining them in a gooey visual style. The result is a smooth, organic animation that feels alive under the cursor.

To achieve the gooey effect, we rely on the filter property, specifically a combination of blur and contrast. The blur softens the edges of the shapes, and the high contrast causes overlapping areas to merge into blobs. However, applying contrast on a transparent background does nothing. To fix that, we give the element a solid white background-color. Then, to make the white areas effectively transparent against the page, we use mix-blend-mode: darken.

1) Start with a basic spotlight

Add a pattern using the background-image

Set the background-color to white

Apply the filter for the gooey effect

Remove the white parts using mix-blend-mode

And here is the code that sets up this visual base:

.spotlight {
  filter: blur(1em) contrast(100);
  mix-blend-mode: darken;
  background-color: white;
}

Now that we have this setup, we can start layering more shapes, play with gradients, and watch the gooey interactions evolve as the mouse moves.

The Blob Light

With the gooey base in place, we can use gradients to build more playful visual behaviors. Since background-image can accept a comma-separated list of layers, we can stack several gradients with varying styles, sizes, and positions. These layers blend together through the blur and contrast filters, resulting in a smooth, organic effect.

To create a blob-style spotlight, I made the main circle a bit larger and softer, and stacked two repeating linear gradients to form a diagonal grid pattern.

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent, black 20em),
    repeating-linear-gradient(45deg, black 0 0.4em, transparent 0 3em),
    repeating-linear-gradient(-45deg, black 0 0.4em, transparent 0 3em);
}

This is how the background-image looks like without the gooey setup:

And the full blob effect:

Fixing the Fuzzy Edges

You may have noticed in the previous example that the edges of the .spotlight element appear fuzzy, subtly revealing the content behind it. This is a side effect of the blur filter. When there’s nothing beyond the blurred edge for the contrast filter to respond to, the gradient just fades out softly. Visually, that results in blurry borders that break the clean feel of the effect.

There are a few ways to deal with this. Like scaling up the element, applying a negative inset, or manually setting a larger width and height. But all of these approaches introduce extra complexity, especially since you’d also have to compensate for the mouse coordinates shifting relative to the larger area.

A simpler and more robust fix is to add an outline. Just make sure it’s larger than the blur radius and matches the background color. That way, the fuzzy edges get hidden cleanly without affecting the positioning logic at all.

.spotlight {
  outline: 2em solid white;
}

We’ll include this outline fix in all the following examples to keep things clean and crisp.

Dotted Reveal

The reason the blob in the previous example appears to morph as the mouse moves is that, while the main circle follows the cursor, the grid pattern remains fixed on the screen. The interaction between these two layers creates the illusion of motion and shifting shapes within the spotlight.

Following the same principle, we can build a dotted effect. This time, instead of diagonal lines, we’ll use two radial gradients, and set a background-size to create a repeating pattern:

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent 6em, black 10em),
    radial-gradient(circle, black 0.2em, transparent 1em),
    radial-gradient(circle, black 0.2em, transparent 1em);
  background-size: 100% 100%, 2em 3em, 2em 3em;
  background-position: 0 0, 0 0, 1em 1.5em;
}

The first layer defines the moving mask (just like before), and the next two layers form the repeating dot pattern. By adjusting background-position, we offset the second dot layer to create the alternating effect. The result is a playful dotted texture that dynamically follows the mouse.

All of the values in the last two examples (color stops, gradient sizes and positions, blur and contrast settings, and more) can be tweaked to create wildly different effects. I spent a lot of time experimenting before landing on these particular numbers, and I encourage you to do the same. Go ahead and fork one of the demos, adjust the gradients, play with the filter values, and see where your creativity takes you. And if you discover something cool, don’t forget to send it my way.

Movement Interaction

In the previous examples, only the main circle responded to the cursor movement, but those same CSS variables can drive other visual elements as well. Here is an example that lays out a grid of squares using a conic-gradient. By offsetting its position by a fraction of the cursor coordinates (a factor of negative 0.25 in this case) we achieve a subtle parallax effect.

.spotlight {
  background-image:
    radial-gradient(circle at var(--clientX, 50%) var(--clientY, 50%), transparent, black 14em),
    conic-gradient(from 270deg at 1em 1em, #aaa 90deg, transparent 0);
  background-size: 100% 100%, 3em 3em;
  background-position: 
    0 0, 
    calc(var(--clientX, 50%) * -0.25) calc(var(--clientY, 50%) * -0.25); /* only the conic layer moves */
}

You can comment out the background-position to see its affect, and feel free to tweak the offset factor and see how the motion transforms.

Tip: try adding a transition on the background’s position to add even more motion. .spotlight { transition: background-position 0.5s ease-out; }

Remember these values can be used for anything. They’re just variables, and that means you can plug them into any CSS property that accepts dynamic values. For example, here’s an example where the mouse’s X position controls the amount of blur, and the Y position determines the size of the central circle.

With just two custom properties, you’re suddenly controlling not only movement, but also style and intensity. You could just as easily hook the mouse into opacity, gradient angles, or any part of the effect you want to feel dynamic. What would you change in your effect?

The Full Reveal

Up until now, we’ve been revealing only what’s inside the spotlight, with everything else hidden behind the dark blur. But what if we want to fully reveal the page in certain cases? For example, when hovering over a specific element, we might want to turn the effect off entirely and let the full content show.

Surprisingly, you don’t need any JavaScript to do this. With one clever CSS selector, we can ‘listen’ for a hover on elements with a specific class and adjust the effect accordingly.

.spotlight {
  transition: opacity 1s, background-color 1s;

  body:has(.reveal:hover) & {
    opacity: 0;
    background-color: black;
  }
}

Now, any element with class="reveal" will temporarily disable the spotlight effect when hovered.

In terms of styling, there are a few ways to disable the effect. You could scale the gradient out, reduce the blur, or even hide the entire .spotlight element. In this case, I went with a combination of lowering the opacity and changing the background color. This gave me a subtle fade effect both in and out.

The Light Spotlight

Until now, the hidden part of the page has been covered in black, creating a dark spotlight effect. But what if your design calls for a light version, with white as the cover color?

Turns out it’s pretty straightforward. All we need to do is invert the colors in our .spotlight element’s styles. Anything that was black becomes white, anything that was white becomes black (transparent stays as-is). And just as important, make sure to change the mix-blend-mode from darken to lighten so that the blending works correctly with the inverted color scheme.

Of course, these values don’t have to be hard coded. You can define the colors and blend mode using CSS custom properties, giving you full control over the theme. Better yet, we can respond to user preferences using the light-dark() function and the prefers-color-scheme query to decide whether to use a light or dark spotlight effect.

:root {
  color-scheme: light dark;

  --spotlight-cover: light-dark(white, black);
  --spotlight-reveal: light-dark(black, white);

  @media (prefers-color-scheme: dark) {
    --spotlight-blend-mode: darken;
  }
  
  @media (prefers-color-scheme: light) {
    --spotlight-blend-mode: lighten;
  }
}

This approach not only makes your spotlight more flexible, but also keeps it aligned with accessibility and user experience best practices.

Adding Colors

So what about colors beyond just black or white? Black and white are great for creating strong contrast, but what if you want something a bit more… purple?

Well, at this point, we need to slightly rethink our approach. The gooey technique we’ve used so far works beautifully with monochrome because of the way mix-blend-mode interacts with light and dark. As soon as you start introducing color, things get trickier. The blend mode can dramatically shift the look and feel depending on how your chosen colors interact with the background and with each other.

You can try changing the colors to something like purple or teal, but it will alter the nature of the effect, sometimes in surprising ways, so I encourage you to experiment. And how knows, you might land on exactly the vibe you’re looking for.

Mobile Support

This entire effect relies on mouse movement, so what happens when there’s no mouse? Rather than hiding content on touch devices, we’ll simply disable the effect altogether when we detect a mobile or touch-based screen. That way, users still see everything, just without the fancy spotlight interaction.

We can ensures that a device support hover interactions using the hover media query, which is supported on all major browsers. By wrapping the spotlight styles in a @media (hover: hover) we can apply the effect only on hover supported devices.

@media (hover: hover) {
  .spotlight {
    /* spotlight styles */
  }
}

This media query works well for most cases, but some devices support both touch and mouse input. Think touchscreen laptops or tablets with external mice. In those cases, the effect might kick in when it shouldn’t.

To handle this more gracefully, we can back up our CSS with a small JavaScript snippet. It listens for a touch event and disables the effect as soon as a user interacts via touch. That way, the spotlight effect is removed dynamically if the device leans toward touch input.

const mouseMoveHandler = (e) => {
  document.body.style.setProperty('--clientX', e.clientX + 'px');
  document.body.style.setProperty('--clientY', e.clientY + 'px');
};

document.body.addEventListener('mousemove', mouseMoveHandler);

document.body.addEventListener('touchstart', () => {
  document.body.classList.add('reveal');
  document.body.removeEventListener('mousemove', mouseMoveHandler);
});

And one last thing on this topic: we should also account for basic keyboard navigation. We do not want users tabbing into elements that are visually hidden by the effect, so we will also disable it in that case. This can be detected using body:has(:focus-visible), which tells us when one of our elements is focused. You can combine this selector with your .reveal logic to ensure the effect is turned off when keyboard navigation kicks in.

@media (hover: hover) {
  .spotlight {
    /* spotlight styles */

    body:has(.reveal:hover, :focus-visible) & {
      opacity: 0;
      background-color: black;
    }
  }
}

With this setup, the effect behaves just right: it kicks in only when it makes sense and stays out of the way when it doesn’t. Mobile users still get the full content, and hybrid devices adapt in real time.

The Ultimate Spot

Before we wrap up, here is a quick demo that brings together most of what we explored. A spotlight with a blob gooey effect, crisp edges, theme switching, and full mobile and keyboard navigation support. All within scrollable content, with areas that disable the effect on hover.

Taking It Further

All of the ideas in this article are just starting points. Now it’s your turn to run with them. You can play with gradient backgrounds and tweak their sizes and positions. You can experiment with filter settings or try different blend mode options to see what new moods emerge. You might also pull extra data from JavaScript (like the cursor angle relative to an element or the speed of movement) and feed that into your styles for even richer effects.

In this article, I’ve used a single <div> for the .spotlight element, but feel free to layer in additional elements, icons, text, or graphic shapes within the reveal area. Apply the same technique to multiple elements with their own custom settings. The possibilities are endless, so let your imagination guide you and discover what unique interactions you can build.

]]>
https://frontendmasters.com/blog/css-spotlight-effect/feed/ 8 5939
Am I a Sadistic Developer? Are You? https://frontendmasters.com/blog/am-i-a-sadistic-developer-are-you/ https://frontendmasters.com/blog/am-i-a-sadistic-developer-are-you/#respond Thu, 24 Apr 2025 16:45:59 +0000 https://frontendmasters.com/blog/?p=5619

This code looks… sadistic.

Someone reviewing some code I wrote this week

Sadistic, as in I purposefully wrote the code this way to make things harder for myself. Coding in a way that no typical developer would ever write on purpose.

At first, I laughed it off. I mean, I’m not someone who likes working hard for no reason. People who know me know I’m actually pretty lazy.

But the comment stuck with me, and I started wondering: what does “developer sadism” even mean? What does it look like in real life? Who’s the real sadist here? Me? You?

Let’s find out.

Not All Code Matters Equally

To be fair, the code in question wasn’t part of some production project or even a side project. It was just a little CodePen experiment I was playing around with. I was testing out a new CSS feature, trying weird things on purpose to explore how the browser handled different edge cases. That’s it.

I’ve always believed in pushing tools to their limits as a way to learn them better. Over the years, I’ve done that with pretty much every technology I’ve worked with. The harder I pushed, the more I understood. “Train hard, fight easy.” So yes, maybe the code looked a bit messy or over-engineered in that moment. That’s fine. That’s not sadism, that’s how you grow.

All that said, Developer Sadism isn’t something I just made up (I mean, I did, but bear with me). We’ve all run into tools, APIs, or processes that punish instead of guide, and feel less like helpful utilities and more like a trap. We’ve all stared at a piece of code and thought, what on earth was this person thinking?

But does that count as sadism? We all make mistakes, especially when writing code. Maybe it’s perfectionism, maybe it’s a lack of empathy, or maybe it’s that “tough love” culture we’ve internalized from years of bad code reviews. I want to believe no one actually means to make things difficult and harder, so maybe it’s more of an ‘unintentional sadism’. Still, whether we mean to or not, or even know it or not, we sometimes end up sabotaging ourselves and our code.

The Deadly Sins

One of the (worst?) sins is when there is over-engineering for purity rather than usability, and a perfectly fine solution is sacrificed to chase some ideal architecture. Developers forget that code is meant to be read, understood, and changed by real people. Instead they end up with five layers of abstraction so neat that nobody can figure out how they all connect. This is not clever. This is building walls just to admire them.

Another favorite of mine is when developers chase every shiny new trend without asking if it fits the project. They throw a dozen experimental libraries at a simple page and call it innovation. They never stop to wonder if the tool actually solves their problem or if it just looks cool. The result is a bloated mess that breaks on the slightest breeze. Great for internet points. Horrible for shipping anything.

A nice sub-sin of trend-chasing is when developers fall in love with a specific tool or feature and try to use it for everything (everything!), regardless of whether it’s the right tool for job or not. I’ve seen developers using routing libraries to navigate between elements, just because they love working with React Router. Or developers who slap * { display: flex; } in their CSS, simply because it’s all they know. While these tools and features have their place, overusing them in situations where they’re unnecessary can lead to code that’s hard to maintain and harder to understand. It’s a good rule of thumb to ask: “Does this tool actually solve my problem, or am I just using it because it’s my favorite hammer?”

You can even get overly attached to a development philosophy itself. There’s a great post by Blake Watson that talks about how he took functional programming too far in a real-world project, only to realize later that clarity and pragmatism matter more than theoretical purity. It’s a good reminder that no paradigm, however elegant, should override the basic goal: to write code that works, is readable, and makes life easier for the team.

On the more common side, you’ll find the sin of laziness. You see it every time you encounter a constant named a, a function named doit(), or an error message like ‘Invalid data’ that doesn’t tell you anything. Sometimes, this laziness can also lead us to skip steps, resulting in things like too-deeply nested ternary expressions. It might seem clever or smart in the moment, but try reading this:

const status = isActive
  ? hasAccess
    ? "green"
    : isPending
      ? "orange"
      : "red"
  : "gray";

There are plenty more sins we could call out, but I’ll save those for another day. For now, consider this one last example: I once knew a developer who set up a CI check so strict, he couldn’t even merge his own pull requests. He must have thought watching the team spin in circles was hilarious. Spoiler alert: no one was laughing.

Consequences

Sure, Developer Sadism might start off as a joke, or as a side effect of chasing perfection. But the consequences are very real. Let’s talk about two of the most immediate ones.

Technical Debt in Disguise

A lot of this unintentional sadism hides behind good intentions. It wears the costume of “best practices”, “clean architecture” or “modern tooling.” But when you peel that back, it’s often just technical debt in disguise.

These excuses can, and will, slow you down. They make the code harder to change, harder to fix, and harder to trust. The real kicker is that nobody thinks they’re writing bad code while they’re doing it. That’s the trap.

The Human Cost

The bigger problem? People burn out. Teams waste time. Developers grow cynical.

When every task feels like battling some overengineered monster, momentum dies. You stop improving the product and start just surviving the codebase. That kills creativity, motivation, and morale. And it’s contagious, one person’s impossible setup becomes the whole team’s daily nightmare.

You know that feeling when a new teammate joins and their first reaction to your codebase is “…why?” That’s not a rite of passage, that’s a warning sign.

Doing Better with Code That Cares

It doesn’t have to be this way. Writing code shouldn’t feel like setting traps for yourself or others. It can be better. We can write code that supports us, rather than sabotages us. We can build tools that invite collaboration, not confusion. It starts with being a little more mindful and a lot more human.

Self‑audit your habits

As developers, we often fall into routines without realizing the impact they have on our workflow and the team. It’s easy to get stuck in certain habits, like hoarding knowledge, overcomplicating solutions, or getting fixated on “perfect” code. But all of these tendencies can lead to friction and unnecessary difficulty. Self-auditing means taking a step back and regularly evaluating your own development practices.

Are you the type to hoard useful information, leaving others to struggle or reinvent the wheel? Do you make decisions that you think are clever, but that only you understand? It’s time to ask yourself: “Is this really the best way to do things? Could someone else come in and immediately grasp what’s going on?” This kind of reflection helps you become more aware of your own habits and opens the door to more thoughtful and accessible solutions.

By identifying bad habits, we can focus on being more intentional and sharing knowledge freely. It’s a simple shift, but it can have a huge impact on both your own productivity and the overall team dynamic.

Empathy as a Feature

Great code isn’t just about functionality; it’s about making things easy for the people who will interact with it. Empathy means considering your fellow developers and users when building tools, writing documentation, and designing error messages.

Think of onboarding flows and error messages as part of the “user experience.” Make sure they’re clear, helpful, and easy to navigate. This ensures that everyone, from your teammates to your users, feels supported and empowered to keep moving forward.

Design for Forgiveness

Mistakes are inevitable, and good tooling should help people recover from them, not punish them for it. Designing with forgiveness in mind means acknowledging that no one gets everything right on the first try.

Add helpful hints, dry-run flags, or “undo” options in your tooling. These small touches can save time and frustration, allowing people to recover from errors quickly and easily. It’s about creating a system that lets people try, fail, and learn without making the process feel like a dead-end.

Seek Feedback Constantly

No one’s code is perfect, and it’s important to invite others to offer their perspectives and learn how they interact with your code. Fresh eyes can catch issues you might have missed and provide valuable insights on usability, clarity, and overall functionality.

Feedback isn’t just about finding bugs; it’s about understanding how your design and decisions affect the people using it. By actively seeking feedback, you foster a collaborative environment, ensure your code is accessible, and discover new ways to improve your development practices.

Invest Time in DX

Developer Experience (DX) often gets overlooked, but investing a little extra time in creating clear documentation or providing example scripts can save hours of frustration for the entire team. A small upfront effort to clarify the usage of a tool, library, or API can go a long way in helping others get up to speed quickly and avoid confusion.

A fine-tuned experience and a well-documented system isn’t just nice to have, it’s a game-changer for team productivity and happiness. By thinking ahead about how others will interact with your code, you create an environment where everyone can work more efficiently and with less friction.

Conclusion: Let’s Break Free from Sadism

At the end of the day, no one wants to be the source of frustration in the team. By recognizing and improving our own development practices, we’re not just making life easier for ourselves, but we’re helping create a healthier, more productive team environment. The goal isn’t perfection, it’s improvement, one step at a time.

Have you seen yourself in any of the “sadistic” practices talked about above? See if you can try to eliminate it in some way this week. Whether it’s simplifying your solutions, sharing knowledge more freely, or making your code more approachable, small steps lead to big changes.

And remember, we’ve all been there.

Got a horror story to share? Or maybe you have your own tips for avoiding sadistic habits? Drop a comment and let’s keep the conversation going!

]]>
https://frontendmasters.com/blog/am-i-a-sadistic-developer-are-you/feed/ 0 5619
Reanimating the CSS Day Buttons https://frontendmasters.com/blog/reanimating-the-css-day-buttons/ https://frontendmasters.com/blog/reanimating-the-css-day-buttons/#respond Mon, 31 Mar 2025 16:55:52 +0000 https://frontendmasters.com/blog/?p=5489 Are you as excited about CSS Day as I am? While browsing the conference website, I couldn’t help but notice their big firebrick-red buttons. A website isn’t just about displaying content, it’s about creating excitement. Every interaction should feel polished and engaging, especially buttons, which are the primary way users navigate the site.

A well-animated button can capture attention, reinforce branding, and make the experience more enjoyable. In this article, we’ll take a closer look at the existing buttons and explore ways to enhance them with modern CSS techniques.

First, here is a version of the button that is currently on the website. Hover over the button to see its shape change.

The a element is wrapped with a div that has a solid background color, and the hover effect sets the div‘s background to transparent, revealing the a‘s arrow shape, created using a clip-path. The transition between the states (the ‘fade’ of the background) creates weird looking triangles.

I think that we can improve on that by using some movement and dynamics.

Approach 1: Background Image Animations

One simple-yet-effective way to add flair to buttons is by animating a background-image. Well, we’re not really animating the image, but we can achieve a smooth transition effect that feels dynamic by animating the background-position property.

Here is a button that uses a linear transition on the background-position:

button {
  border: 2px solid firebrick;
  background: linear-gradient(120deg, white 50%, firebrick 0) 0 0/ 250% 100%;
  color: firebrick;
  padding: 0.6em 1em 0.7em;
  cursor: pointer;
  transition: all 0.5s;
  
  &:hover {
    background-position-x: 100%;
    color: white;
  }
}

In this example the background stretches the gradient horizontally to 2.5 times the width of the button, and the background’s position changes from 0% to 100%. to better understand this effect, you can use the ‘Visualize background’ checkbox.

So how do you use background-position to create the arrow-shaped hover effect? We’ll actually need to layer two backgrounds and control both positions. Here’s how:

The pointed shape is created using two simple conic-gradients, and again, the background stretches each gradient to 2.5 times the width of the button, so we can set the background-position to bring the center of these conics in and out of view.

This method can actually be quite powerful and is great for many applications. And while I love using (and animating) background gradients, maybe for this specific use case it’s not the best option. So let’s try something else…

Approach 2: Clip-Path Transition

Another way to animate shapes in CSS is by using clipping, allowing us to create unique shapes and transitions. In our case, the buttons already have a clip-path property, so let’s use it. We’ll set it to the hover state, and ‘reset’ the polygon on idle.

.button {
  display: block;
  border: none;
  background: var(--yearColour);
  color: white;
  padding: 0.6em 1em 0.7em;
  clip-path: polygon(0% 0%, 100% 0%, 100% 50%, 100% 100%, 0% 100%, 0% 50%);
  cursor: pointer;
  transition: clip-path 0.5s;

  &:hover {
    clip-path: polygon(0% 0%, 95% 0%, 100% 50%, 95% 100%, 0% 100%, 5% 50%);
  }
}

Note that in order to transition the movement, the number of nodes in the polygon shapes must be the same.

This approach already looks nice, but I think we can do better.

When working with clip-path (and clipping in general) you need to remember that the clipping is inwards, removing parts of your elements, and you can’t overflow anything outside the clipped area. If we do want to expand outward from our element, we need to first expend the element itself, and then adjust the clipping.

.button-v2 {
  display: block;
  border: none;
  background: var(--yearColour);
  color: white;
  padding: 0.6em 1.3em 0.7em;
  clip-path: polygon(2.5% 0%, 97.5% 0%, 97.5% 50%, 97.5% 100%, 2.5% 100%, 2.5% 50%);
  cursor: pointer;
  transition: clip-path 0.5s;

  &:hover {
    clip-path: polygon(0% 0%, 95% 0%, 100% 50%, 95% 100%, 0% 100%, 5% 50%);
  }
}

In the above example I’ve increased the inline padding, making the element wider, then adjusted the idle state of the polygon to remove the added width. Now the clipping is not just inward, but also expands out, which not only creates a more dynamic effect but also reduces the risk of cutting into the button’s content.

Here is a live demo of the two versions, In my opinion, this second shape looks slightly better overall. Which one do you like?

Solving the Challenge of the Dotted Button

While the previous techniques work well for solid buttons, the CSS Day website also has a dotted-style button. These require a different approach, since background-image and clip-path alone don’t handle dotted outlines effectively.

Approach 3: Pseudo-Elements and Transforms

Somehow, whenever there’s an animation or interaction that feels tricky to implement, it often turns out that pseudo-elements (e.g. ::before and ::after) are the solution. They’re like the hidden superpower of every element, allowing us to do some really cool things.

In this case, we can achieve a clean and elegant solution using pseudo-elements. The idea is pretty straightforward: ensure each pseudo-element spans the full width of the button and half the height. We place one element at the top of the button and the second at the bottom. Then, on hover, we apply a skew transformation to the elements. Simple enough? Here’s a live demo:

Let’s break down what we added:

  • We applied position: relative; to the button as we’re going to position the pseudo-elements using position: absolute.
  • For the pseudo-elements, we started with shared styling: positioning, size, color, z-index, and of course, a transition so everything moves smoothly.
  • The ::before pseudo-element is placed at top: 0; to serve as the background for the top half of the button, and the ::after pseudo-element is positioned at bottom: 0; to cover the bottom half.
  • We added a transform with a simple skew function along the X-axis, and used calc to adjust the direction of the skew in the ::after element so the skew effects are applied in two different directions.
  • The last thing we added is a hover state that defines the desired skew angle, transforming the button into an arrow shape.

So how do pseudo-elements help us solve the dotted button challenge? It’s simple: all we need to do is change the text color of the button and the background color of the pseudo-elements, then apply a dotted border. The key is to ensure that the ::before has a border on the sides and top, while the ::after gets a border on the sides and bottom.

.button.dotted {
  color: firebrick;

  &::before, &::after {
    background-color: white;
    border: dotted firebrick;
  }
  &::before {
    border-width: 2px 2px 0;
  }
  &::after {
    border-width: 0 2px 2px;
  }
}

That’s it. I’ve also added a version that changes the button color after the shape shifts on hover. Here are live examples of both versions:

Wrapping Up

We’ve reanimated the CSS Day 2025 buttons by experimenting with different CSS techniques:

  1. Background-image animations for smooth gradient transitions.
  2. Clip-path effects for unique button shapes.
  3. Pseudo-elements to create a dynamic dotted button effect.

Each approach offers distinct advantages (and some drawbacks), and it’s important to familiarize ourselves with various animation options so that we can choose the most suitable one for each case based on the design needs, the desired effect, and the button’s context

Want to take this further? Try incorporating CSS Variables for more flexibility or mixing in @keyframes for even more animation control. Happy coding!

]]>
https://frontendmasters.com/blog/reanimating-the-css-day-buttons/feed/ 0 5489
Pure CSS Circular Text (without Requiring a Monospace Font) https://frontendmasters.com/blog/pure-css-circular-text-without-requiring-a-monospace-font/ https://frontendmasters.com/blog/pure-css-circular-text-without-requiring-a-monospace-font/#comments Fri, 21 Jun 2024 11:17:46 +0000 https://frontendmasters.com/blog/?p=2774 There is no simple and obvious way to set text on a circle in CSS. Good news though! You can create a beautiful, colorful, and even rotating circular text with pure CSS. It just takes a bit of work and we’ll go over that here.

Circular-set text can be used as a decorative element, a cool headline, wrapping a call-to-action button, a loading animation, and really anything else you can think of.

This whole concept of circular text is not new. I encountered a few articles and videos about it over the years, starting with Chris Coyier’s article from way back in 2012, up until this article by Jhey Tompkins from last year that shows a great use of CSS trig functions. Both of them, and others, use a very similar method: splitting the text into individual characters, then rotating these characters around a common center. Like Chris shows in his article:

Each letter becomes a very tall rectangle rotated around a common point at the far end away from the letter.

While it does work, and looks great, it limits us to using a monospace font. Otherwise, the characters start to overlap each other, it doesn’t look as good, and the text can be unreadable. But I’ve never liked this limitation (or limitations in general to be honest), so we’re going to look at a completely different method that allows us to use any font we want, not necessarily monospace, including weird cursive fonts, and even emojis. And hopefully we can learn a new thing or two along the way, so let’s begin!

In order to get the desired result, we will divide our work into three steps.

Step 1) Splitting

As I said, up until now, people tended to split the text into individual characters (e.g. Splitting.js), but now we’re actually going to split the text into equal width segments (using <span> elements) and each segment will have a visual piece of the final text. In order to do that we will add a number of span elements into our text container, each containing the desired text (please read the accessibility note at the end of this part).

In the example we’re building toward here I’m using 24 <span>s, but the number of segments is up to you. The more segments you use the better it will look, just don’t use too much as it might affect performance, especially if you’re going to animate the text.

<div class="textContainer">
  <span style="--i: 0;">Lorem ipsum dolor sit amet consectetur adipisicing elit</span>
  <span style="--i: 1;">Lorem ipsum dolor sit amet consectetur adipisicing elit</span>
  <span style="--i: 2;">Lorem ipsum dolor sit amet consectetur adipisicing elit</span>
  <!-- add more spans -->
</div>

As you can see, I’ve also added a style attribute to each span and inline a custom property of --i with an ascending value. We will use this value later on to position each segment.

For the CSS, let’s set the font-size to something a bit bigger, say 60px, and we’re going to set the width of each span to 1em. Next, we want to keep the text in one line so we’ll add white-space of nowrap, and set the inner position of each text using text-indent with a simple calc function in it of the --i custom property times -1em (the width). And the last thing, to keep everything inside the span, we will use overflow: hidden.

We’re going to add two more things (just temporarily, so we can see things better): a display of inline-block as the default display of a span is inline, and some of the properties we set don’t work on inline elements. And we will add an outline for each span to see they’re outline.

.textContainer {
  span {
    width: 1em;
    font-size: 60px;
    white-space: nowrap;
    text-indent: calc(var(--i) * -1em);
    overflow: hidden;
    
    /* temporarily */
    display: inline-block;
    outline: 2px solid red;   
  }
}

And this is the result so far:

As you can see, we ended up with a bunch of red rectangles, each containing a continuous segment of the text. I’m also using the well-known Lobster font here, as it is cursive, and I kind of like it, so why not.

Accessibility Note

Please note that this is not accessible! If we leave it as is, screen readers will read the text repeatedly for each span. This is not good, so in order to avoid it, I’ve added the aria-hidden attribute on the text container. If you wanted the text to be read by screen readers (once), you could add aria-hidden to all but the first one.

Step 2) Positioning

Our next step, similarly to monospace font techniques, is to position these segments in a circle. This positioning will be done using transform, but first let’s add a position: absolute to each segment, and a position: relative to the .textContainer as the main context for the positioning.

On the transform property we’re going to need to add a few things. First, we want to center these elements so we’ll add a translate of -50% on each axis. Now we need to rotate each span, and the angle of the rotation depends on the number of elements we are using. Here, because we are using 24 elements, the rotation for each element will be 360° divided by 24, that is 15°.

360° ÷ 24 Segments = 15° per Segment

So we’ll add a rotate with a calc function of 15deg times the --i custom property. Now we can translate each element again, moving it upwards (on the y-axis), and to figure out exactly how much we need to move it. We’re going to use some basic math functions here:

.textContainer {
  position: relative;

  span {
    position: absolute;
    transform:
      translate(-50%, -50%)
      rotate(calc(15deg * var(--i)))
      translateY(calc(-1em / sin(15deg)));
  }
}

Now let’s add this code to what we already have (note that we don’t need the display: inline-block anymore as we are using position: absolute on the span), and what we get is a nice circle of partially overlapping segments.

If we take a closer look at how these segments are touching each other, we can see that the segments are overlapping at the bottom (the inner side of the circle), and are far away from each other on the top (the outer side of the circle).

This is of course not what we want, we want these segments to connect smoothly and seamlessly. This is where the magic happens!

Step 3) Perspective

In order for these rectangles to connect seamlessly they actually shouldn’t be rectangles, but some sort of trapezoids. Wider at the top and thinner at the bottom than the original rectangle. In CSS, when we want to make a trapezoid out of a rectangle, most of the time we will use a clip-path or a mask, but that won’t be ideal in our situation as we might clip out (or mask out) important visual information of the text itself.

What we need to do is to ‘stretch out’ the top part of each segment and ‘squeeze in’ the lower part, so that the segments will connect as they should without us losing any visual information of the text. And we can actually do it using perspective.

The most basic thing about perspective is that things that are closer to us look bigger, and things that are farther away from us look smaller. So we can utilize perspective, rotate the segments in a way that the top part would be closer to us (and look wider), and the bottom part would be farther away from us and would look smaller.

To do that, we will add the perspective property on the text container, and then all we need to do is to rotate the segments on the x-axis 90deg towards us.

.textContainer {
  perspective: 15em;
  
  span {
    transform: rotateX(-90deg);
  }
}

Adding this to our previous code will result in a seamless connection between the segments and a continuous text, regardless of the type of font you’re using. (we’re going to talk about the value of the perspective in the next part)

So what’s really going on?

Although it doesn’t seem like it right now, what we’ve created is actually a ring of flat elements, but because we’re looking at it from a specific perspective and don’t have visual context to compare it with, it looks like the ring is flat.

To help us understand it better I’ve created this animation:

As we can see, we didn’t actually transform the rectangles into trapezoids, they are just arranged one next to the other. But because we are looking at them from a specific perspective, where the top part of the rectangle is closer to us and the bottom part is farther away, it looks like they are trapezoids.

The distance at which we’re looking at the ring (that is, the value of the perspective) determines the shape of the trapezoids, so if we set the perspective to a lower value the trapezoids would look longer, and if we set it to a higher value it would look smaller. So it’s a bit of a game of trial and error to find the right value for the text to look nice.

Can’t SVG do this?

It’s true SVG has a relatively straightforward <textPath> element that was designed for setting text on curved paths. The unfortunate part is that it is still based on splitting the text into individual characters, so while we’re no longer limited to monospace fonts, cursive fonts will still look broken. See:

Final Touches & Animation

Now we can get rid of the red outline to get a continuous text, that was just a fun visual aid. Sadly, this may result in some thin gaps between the segments (hairlines), but there’s a very easy fix for that. All we need to do is to bring those segments a bit closer to each other, and for that we need to adjust the translateY function in the transform. In this case I’ve added 0.1em as it’s more than enough.

translateY(calc(-1em / sin(15deg) + 0.1em))

To add some color to our text, there’s a few methods we can use. We could simply set the color property on the text container to have one solid color, or we can make it more colorful by utilizing the --i custom property to set a different color for each segment. For example:

span {
  color: hsl(calc(var(--i) * 15) 100% 50%);
}

We can also use a background-image on the span elements and use the background-clip: text method if we want to get something more complex.

And as for the animation, here I’m using a relatively simple rotation (I’m adding a bit of a ‘fake skew’), but in fact we are only limited by our own imagination, and we can add whatever animation we want on the text container.

The final result of what we’ve built so far looks like this:

If you liked this concept, I’ve done a live stream a few months back where I go much deeper into the whole topic of forced perspective and explain how to create a circular text that is much more responsive and adjustable (and a bit more complicated).

Hope you found this method helpful and maybe learned something new. If you have any comments or have used this on a project somewhere, I’d love to hear them.

]]>
https://frontendmasters.com/blog/pure-css-circular-text-without-requiring-a-monospace-font/feed/ 2 2774