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 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
SVG to shape() https://frontendmasters.com/blog/svg-to-shape/ https://frontendmasters.com/blog/svg-to-shape/#respond Wed, 04 Jun 2025 11:23:09 +0000 https://frontendmasters.com/blog/?p=6042 in SVG ported to CSS so it can use actual units. It’s probably how path() should have ported to begin with, but c’est la vie. I’ll make the point in this demo. Resize those […]]]> We’ve been trying to make the point around here that the new shape() in CSS is awesome. It’s the powerful <path> in SVG ported to CSS so it can use actual units. It’s probably how path() should have ported to begin with, but c’est la vie.

I’ll make the point in this demo. Resize those containers and see how the clip-path responds (path() cannot be fluid, shape() can).

Are SVG <path>s 1-to-1 convertible to shape(), though? Apparently they are! (!!). Two people have already built converters and from what I’ve tested they work great.

]]>
https://frontendmasters.com/blog/svg-to-shape/feed/ 0 6042
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
Creating an Angled Slider https://frontendmasters.com/blog/creating-an-angled-slider/ https://frontendmasters.com/blog/creating-an-angled-slider/#comments Wed, 22 Jan 2025 16:28:12 +0000 https://frontendmasters.com/blog/?p=5007 Let’s walk through how this slider with angled content and hover effect works:

We’re going to pull this off in just HTML and CSS (with a little help from Sass to make certain things easier). We’ll be using grid, clip-path:has(), nesting, and other fun CSS stuff to help it all come together.

Real-World Inspiration

One day I saw this ad about a Netflix series:

Four things stuck out to me:

  1. The neat textured font
  2. The angled divisions
  3. The irregular grid layout
  4. The glow effect between grid items

While I did experiment with recreating the glowing neon lines, I ended up making a slider inspired by the layout of this poster. Let’s dive into how it is made!

Creating a Basic Angled Slider

The core of this slider effect is layered content with an angled clip-path to reveal targeted content. “Targeted” isn’t exactly a scientific web dev term, but I’ll be using it to mean “hovered, focused, or aria-selected”.

For static content, creating a layout like this is straightforward. Just layer the contents on top of each other (using position: absolute or display: grid) then apply the clip-path to the top element(s):

Note: When you’re using a clip-path: polygon() you provide the x y points that outline the space that you want visible. There’s no standard for the ordering of these points, but in this article I will always order the clip path points in the order bottom left, top left, top right, then bottom right.

But adding the target effect is more complex, especially as you increase the number of sections. With only two sections, we just have to affect the clip-path of one element in two different ways, based on which element is hovered.

If the first element is hovered, shift the clip-path of the second over to the right. If the second element is hovered, shift its clip-path to the left, like this:

To make this more accessible, you can replace :hover with a :where() that has :hover along with :focus:focus-within, and/or [aria-selected="true"]. Exactly which ones you need depends on your implementation.

To allow users to tab into each section, you could add some content that is focusable like a link or you could explicitly set it by using a tabindex on the foreground element.

Now you can use the keyboard to trigger the transitions!

This code isn’t too bad to write by hand. However, add a third element, and it quickly becomes significantly more complicated and longer. Once you have 3 elements we start needing to select a previous sibling. For a long time that was impossible with CSS alone but thankfully we can now achieve that via :has()!

Taking the same approach but adding a third element, we could hard-code the changes that we need:

Notice that we only have to affect the left clip-path points of elements. Since the right points are covered up by other elements or is the rightmost element, we set the x value of the clip path points for the right side to 100%, which is the rightmost part of the element.

Even though this works, it’s already around 200 lines of code. And with every additional child element that we want to support the amount of code will bloat a good bit. This process is doable but somewhat of a pain, especially if we want to tweak how the effect works.

Creating an angled slider that works with any number of elements

Part of the beauty of programming is that we can instead create an algorithm and let it generate the code we need based on the number of elements in the container. To do so, using a CSS pre-processor like Sass, at least while developing the component, is pretty helpful.

Take a look at the hard-coded CSS code from the section above and see if you can see any patterns. If you want to challenge yourself, stop reading this article and try to make the algorithm for supporting any number of elements yourself!

Setting up the initial styles

Below is a starting point which loads SCSS and positions the child content over each other. I also added some video content in the background to make it more visually appealing.

When I start thinking about how to make the hard coded CSS more programmatic, I see the need for some for loops:

  1. We need one loop in order to setup each different child count in the range that we provide. So, for example, if we want to support between 2 and 5 children, it will loop 4 times (with the index starting at 2 and going through 5).
  2. We will need another loop, within the first, to set up the initial clip-paths. It will iterate the number of times of the index of the outer loop minus one (because the first child doesn’t make use of a clip-path). So if the outer loop is currently at index 3, the inner loop will need to run 2 times to set up the target effect for each child element after the first.
  3. We will need another loop, within the first but as a sibling of the second, to set up the target effect. It will iterate the number of times of the index of the outer loop. Technically we could combine this loop and the previous loop but I like keeping them separate for the sake of clarity. Since we can just copy the compiled CSS to put in our final component, it doesn’t matter about the run time (not to mention we have a very small number of elements).
  4. We will need a fourth loop, within the third, for the actual target effect. This loop is for going through all of the sibling elements of the targeted element to shift them. It will iterate the number of times of the index of the outer loop.

That might seem like a lot, but I don’t think it’s as bad when we look at the code.

Let’s setup some SCSS variables for the min and max number of sections so we have numbers to loop through. Let’s also add one for the angle distance:

$min_sections: 2; // The minimum number of content sections you're going to have; 2 is min
$max_sections: 5; // The maximum number of content sections you're going to have
$split_width: 30px; // Sizes the angle and min width of each content section

The reason we have to have these min and max numbers is that this affect requires a different chunk of CSS per child count. For example, when we only have 2 child elements there’s only 1 element to affect when targeted. When there’s 3 children, there’s 2 child elements to affect when targeted. And so on. So we could use a number like 100 as our max and assume that there will never be more than 100 children, but in most use cases of a component like this that’d be way more CSS than you actually need.

Now we can setup our outer loop (following SCSS’ syntax):

@for $num_sections from $min_sections to ($max_sections + 1) {
    $ns1: $num_sections + 1; // Number of sections + 1, used in calculations
    $pps: 100% / $num_sections; // Percent per section

$ns1 and $pps here are some variables which will help us with our calculations later.

Then we need to setup a :has based on the number of children so that the styles from one child count don’t affect a different child count.

&:has(> :last-child:nth-child(#{$num_sections})) {

Now we can setup the initial clip path styles:

@for $i from 2 to $ns1 {
  & > :nth-child(#{$i}) {
    // Initial clip paths
    clip-path: polygon(
      calc(#{$pps * ($i - 1)} - #{$split_width}) 100%,
      calc(#{$pps * ($i - 1)} + #{$split_width}) 0,
      100% 0,
      100% 100%
    );
  }
}

This calculation sets the initial x percent of the first and second points to the “percent per section” that we calculated above plus or minus the split width value to create the angled look.

Adding the target effect

Here’s the loops that setup the target effect:

@for $i from 1 to $ns1 {
  @for $j from 1 to $ns1 {
    &:has(li:nth-child(#{$i}):where(
      :hover,
      :focus,
      :focus-within,
      [aria-selected="true"], // For potential JS-driven effects
    )) > :nth-child(#{$j}) {

Let’s break this down:

  • The outer for loop goes through each child element so that we can apply the effect to each.
  • The inner for loop goes through all of the child elements so that when each element is targeted it can affect all of the children, including but not limited to itself.
  • The :has() and > :nth-child() are doing the actual selecting of each child element but only when one of the child elements is targeted.

Inside of this, we need to count how many elements are to the left and right of the targeted element. The main info that we need for this calculation is the inner loop’s index, which is also the index of the targeted element.

$num_left: $j - 1;
$num_right: $num_sections - $j;

Now we can write some logic based on whether or not it is to the left or the right of the targeted element. The index of the targeted element is all we need for that. For the purposes of this effect, we can lump in the targeted element with the elements to its left.

Our goal here is to have all of the elements including the target element to shift to the left side with the proper spacing between each section.

Below I apply the clip path for the targeted element and the ones to the left. Since we want to make the end (top) part of each angled section line up vertically the start (bottom) of the next angled section the formula is pretty simple: the index of the item ($j - 1, which is $num_left in our case) times 2 (since there are two $split_width per element), times the $split_width (the variable that determines the angle, and thus the distance from center of our clip path).

Here’s the full SCSS code:

// Is or is to the left of the targeted item
@if $j <= $i {
  // Apply to elements to the left except the first
  @if $j != 1 {
    $base_num: $num_left * 2;
    clip-path: polygon(
      #{($base_num + 0) * $split_width} 100%,
      #{($base_num + 2) * $split_width} 0,
      100% 0,
      100% 100%
    );
  }
}

We can apply the same principle for the elements to the right but we want them to go towards the right side, thus 100% minus the calculated amount:

@else if $j > $i { // you could juse use @else here but I like being explicit
  $base_num: $num_right * 2;
  clip-path: polygon(
    calc(100% - #{($base_num + 4) * $split_width}) 100%,
    calc(100% - #{($base_num + 2) * $split_width}) 0,
    100% 0,
    100% 100%
  );
}

And that’s it!

The Benefits of SCSS

Modern CSS is great! I hardly ever need to reach for CSS pre-processors like SCSS since we have CSS variables, nested selectors, and all of the other modern CSS features.

However, hopefully this demo shows how CSS pre-processors can still provide value for specific use cases. It lets us create CSS more algorithmically for situations like this, which can save us time.

Plus there’s no real downside, because we can just copy the exported CSS to our actual codebase! Win-win!

Adding More Effects

There’s more you can do to build on this effect. For example, you might want to have text content that only takes up the visible portion of the clipped element. Or maybe you want to add a reveal animation based on the direction of the hover. I created a demo of these effects here:


What other variations can you come up with?

]]>
https://frontendmasters.com/blog/creating-an-angled-slider/feed/ 3 5007
Split Effects with no Content Duplication https://frontendmasters.com/blog/split-effects-with-no-content-duplication/ https://frontendmasters.com/blog/split-effects-with-no-content-duplication/#respond Wed, 11 Sep 2024 19:18:31 +0000 https://frontendmasters.com/blog/?p=3782 recent post here lead me to another called The Magic of Clip Path by Emil Kowalski, which focuses on the inset() basic shape in particular. While I agree that clip-path is a very useful property and the inset() basic shape is underrated and underused on the web, most of the use case examples in the article are far from ideal as they rely on content duplication, which can come at a maintenance, performance, and accessibility cost, not to mention that some of them break in some scenarios.

In this article, I’ll be showing how to get the same effects with no content duplication.

Comparison Sliders

Emil creates this with two different images (the before and the after) stacked one on top of the other, the top one being clipped, plus a button for the draggable line control.

Using a button for the draggable line somehow doesn’t feel right to me, but I’m no expert when it comes to accessibility, so we’ll be focusing on how to do this with a single image, though this is the one example where I can see some advantages to using two images instead of one.

This kind of comparison slider is something I once explained in detail in another article some years back. The basic idea used there is the following: the original image (the “before”) is a background layer of a slider whose thumb is the draggable split line. The original image background layer is blended with another which only covers the progress area between the lateral edge of the track and the current thumb position. The result of the blending operation is the “after”.

In my old article, the result of the blending operation was the negative of the image:

Here’s a fancier version of it:

But we can also have other effects, for example desaturating an image (black and white photography effect).

This demo needs a single HTML element (input[type=range]), less than 20 CSS declarations (and that’s with having to duplicate a bunch of them for the -webkit- and -moz- cases) and under 100 bytes of JS (without even bothering to minify it).

The trick here is to use a color blend mode, which takes the the hue and the saturation of the top layer (transparent before the thumb and any grey after) and the luminosity (which is not the ‘L’ in HSL, that one stands for lightness, but still close enough in a lot of cases) of the bottom layer (the image).

The saturation of any grey is 0 and zero saturation makes the hue irrelevant (if you think about the HSL bicone, the saturation is the horizontal distance from the vertical axis, so the greys are on the vertical axis, where the rotation around it, which gives us the hue, doesn’t matter anymore).

That is, the color blend mode with any grey top layer zeroes the saturation of the result, giving us a fully desaturated image (as if we applied filter: grayscale(1) on it).

We can also make a fully desaturated image monochrome. This only requires a couple of tiny changes from the previous demo: replacing the grey with a dark blue (or anything with non-zero saturation, really) and using a black and white image.

Or why just monochrome it when we can duotone it?

Here, we just switched to an exclusion blend mode. How this works in the back is something I explained in a lot of detail in the blend modes article.

This technique also has the advantage of only needing a minimal amount of JS. All the JS does here is to update one custom property --val when the slider thumb gets dragged. That’s it!

However, the amount of effects we can achieve by blending is limited and, since I wrote that article, I’ve changed my mind about the image as a background approach and I don’t like it as much anymore nowadays. I’d rather have an actual img element there, which can get a proper right click menu and a proper alt text (even though the sliders in the previous examples have a label for screen readers which explains the changing image effect on dragging the thumb).

So the more flexible and overall better approach I’d go for nowadays involves an img element and an input[type=range] inside a wrapper kind of structure. On the CSS side, I’d use a backdrop-filter (which opens the door to endless possibilities) instead of blending. The JS remains the same, we only need it to update the same custom property as before.

The trick is to make the wrapper a grid container with a single grid cell, stack and stretch inside this cell the img, then a wrapper pseudo-element and finally the input[type=range] on top of both.

Both the pseudo-element and the slider get pointer-events: none, so that right click brings up a menu that allows us to open the image in a new tab, save it, copy it and so on. Note that this needs to get reverted on the slider’s thumb (by setting pointer-events: auto) so we can drag it. The pseudo-element gets clipped to just the area between the slider’s edge and the thumb’s vertical midline. It also has a backdrop-filter. This creates the desired effect on the part of the image underneath the area this pseudo-element is clipped to.

The demo above shows a blur effect, but we have unlimited possibilities here, as we can also chain CSS filters or even use SVG ones. We can make our image grainy, introduce chromatic aberration or swap two channels… the sky’s the limit!

One caveat though: as cool as in the browser image filter effects are, we only have access to the original image via the right click menu. We cannot save or copy the result we get after applying the filter this way. For the situation when we want that, stacking the filtered and clipped version of the image on top of the original is probably the better idea, even if we have to load two images.

Split Text

Emil showcases an example similar to the one used by Vercel here. This duplicates the text and clips the version on top. It also breaks on small viewports.

But we can do something like this with no text duplication whatsoever, which also allows us to avoid such problems, regardless of the viewport size.

This can be seen in the demo below where you can drag the separator line:

The trick here is to put the text fill, the text stroke and the progress area each on one RGB channel. In my demo, the text fill uses the blue channel, the text stroke uses the red channel and the progress area uses the green channel. Note that the progress area is created using a full coverage pseudo on the element containing the text and that this pseudo is blended with its parent.

p {
  position: relative;
  color: #00f;
  -webkit-text-stroke: #f00 4px;
  isolation: isolate;

  &::after {
    position: absolute;
    inset: 0;
    background: linear-gradient(#0f0 var(--prc), #000 0);
    mix-blend-mode: lighten;
    pointer-events: none;
  }
}

The --prc stop position is the progress value of the slider in %. The higher up we pull it, the lower the value and the other way around.

The isolation property ensures the pseudo is only blended with its parent, but not with whatever backdrop may be behind its parent as well. pointer-events: none on the pseudo ensures we can click and select the text underneath.

The result so far looks like this:

Intermediate result.

You can see that here, each of the three RGB channels are either zeroed or maxed out (0 or 1). The values for the red channel (text stroke) and the blue channel (text fill) are mutually exclusive, it’s just the green channel (progress area) that can mix with the other two.

We then apply an SVG filter. Here, we combine two concepts I’ve talked about this year before: using RGB channels as alpha masks and painting the graphic we extract using an RGB value (using one of the two ways I did for these monojicons).

Our filter extracts the intersection between the blue channel (the text fill) and the green channel (the progress area) – that is, the text fill within the limits of the progress area – and paints it white.

The intersection between the text fill and the progress area, painted in white.

This is pretty much like creating an alpha mask that makes opaque the area where both the blue channel and the green channel are maxed out. And at the same time, makes transparent the area where at least one of the two is 0.

The filter also extracts the difference between the red channel (the text stroke) and the green channel (the progress area) – that is, the text stroke outside the progress area, then paints it using a variable (which can be either currentColor or a custom property, var(--c-neon), for example).

The difference between the text stroke and the progress area, painted in neon blue.

Finally, we put these two together and we have the result!

There are some other minor polishing tweaks in the demo, but this is the main idea. It’s very similar to other text split demos with no duplication I’ve made (as seen in this CodePen collection), the only difference being that in this case the split line isn’t fixed, but depends on a value that changes when dragging the slider.

The CodePen collection.

Tabs Transition

This is another effect that Emil achieves by duplicating the whole navigation content.

As you might suspect, we can do without duplication!

What is going on here?

First off, each nav item has an index --i, whereas the nav itself has a current index --k, equal to the index --i of the currently selected item. The only JS necessary here is to update the value of --k on the nav to match the value --i of the item we’ve just clicked/selected (works with Tab + Enter too).

We want the currently selected item to have a highlight — that is, we want to have a highlight over the item where the difference between --i and --k is 0. In order to know from which direction this highlight needs to move when we change the selected item and --k changes value, we need to also get the sign of this difference between --i and --k. Since no matter which item is selected, both --i and --k are set to integer values, we use this formula that I explained in detail in an older article to compute the sign:

--sgn: clamp(-1, var(--i) - var(--k), 1)

Now, you may be wondering why in the world still use this when we now finally have the sign() function supported cross-browser (almost, it’s still behind a flag in Chrome) and the answer is that… well, we’re calling it “sign”, but that’s not exactly what we want. I said above that both --i and --k are set to integer values and, while that is true, --k also takes non-integer values when it smoothly transitions from the integer value it previously had to the one it’s currently set to.

The sign() function jumps from -1 to 0, then to 1 and we don’t want that jump. We don’t want this:

The graph of sign(x) has discontinuity at 0.
sign(x) graph

We want this:

The graph of clamp(-1, x, 1) is continuous.
clamp(-1, x, 1) graph

Next we want to know if the highlight is outside of an item or over that item, meaning that item is selected. The highlight is outside of an item if --sgn is non-zero:

--out: abs(var(--sgn))

That is, if --sgn is either -1 or 1, then --out is 1, the highlight is outside the item, meaning the item is not selected. If --sgn is 0, then --out is 1, the highlight is not outside the item, but instead is over it, the item is selected.

Note that with sign() and abs() being the final two mathematical functions still behind the Experimental Web Platform features flag in Chrome, we need to wrap the above in a @supports and also use a fallback:

--out: max(var(--sgn), -1*var(--sgn));

There’s still one more thing we need to compute here and that’s whether we need to move the highlight towards the positive direction of the x axis if we were to select an item of index --i that’s currently not selected:

--bit: round(.5*(1 + var(--sgn)))

We’d need to move in the positive direction of the x axis if we were to select an item of an index --i bigger than --k, that is, if --sgn is 1. In this case, --bit computes to 1. Othwerwise, if we wouldn’t need to move in the positive direction of the x axis but in the negative one, then that means --i is smaller than --k, which means --sgn is -1, so --bit computes to 0.

We create the highlight with a pseudo-element, which is absolutely positioned to cover its entire parent. Okay, but we want this highlight only for the currently selected item, so we clip it to nothing for all the others, that is, for all those where --out computes to 1.

And we know whether to clip it from the right or from the left based on the --bit value. If --bit is 1, we clip from the right (the positive direction of the x axis). Otherwise, we clip from the left. This is the clip-path and the lateral offset values:

--r: calc(var(--out)*var(--bit)*100%);
--l: calc(var(--out)*(1 - var(--bit))*100%);
clip-path: inset(0 var(--r) 0 var(--l));

The final ingredient is to register (as '<number>') and transition --k.

You can see a basic version of this below:

Now you may see a teeny tiny gap in the highlight during the transition.

Screenshot. Shows a tiny vertical gap during the transition.
The problem.

This is due to pixel rounding and the way to fix it is to ensure the pseudo-element highlight doesn’t have a width that might get rounded down to an integer number of pixels.

inset: 0 -.5px

That’s it!

Okay, but what about the rounded corners and the text color change on intersecting the highlight? For that, we need an SVG filter that achieves two things. One, the blobby look and two, something very similar to the one in the earlier text split example, a different look for the text where it intersects the highlight blob versus where it doesn’t. We also want to have a different look for the :hover:focus state outside the blob.

Just like in the text split case, we use a separate RGB channel for each component. The red channel is used for the highlight, the blue channel for the regular text and the green channel for the text in the :hover/:focus case.

Screenshot. Shows the nav with different parts on different channels. Normal (not focused/ hovered) text is on the blue channel. Hovered/ focused text is on the blue channel. The highlight is on the red channel. Since the layers are blended using the lighten blend mode, the text intersecting the highlight looks fuchsia/ magenta (has both the red and bluue channels maxed out).
Intermediate/pre-filter result.

Then in the SVG filter, we first extract the highlight out of the red channel, paint it blue and turn its shape into a blob. Then we extract the text out of the blue and green channels and paint it either grey or blue depending on what channel it’s on. We place the blob on top of it and on top of that, we put the intersection between the text and the blob, painted in white.

Theme Switch Transition

Emil shared a version that duplicates the entire page, one version being in light mode and the other in the dark mode, with the top one having a clip-path on it.

Back when :has() was still a new feature in late 2022, I started toying with a bunch theme switch effects using it and one of them produced a result very similar to this, but without duplicating any content.

Let’s take a quick look at the idea behind!

It’s not very far from what I’m doing in this bubble theme switch (which I’ve explained in detail in the Pen description), but it allows for more control than simply inverting what’s on the page.

We have a custom property --dark that’s 0 in the light theme case and 1 in the dark theme case.

body {
  --dark: 0;
 
  &:has(#dark:checked) { --dark: 1 }
}

We make the page background a CSS gradient with background-attachment: fixed and depending on the --dark custom property via a percentage --perc, which we register as '<length-percentage>' so it can be transitioned when we switch the theme.

body {
  /* same as before */
  --perc: calc(var(--dark)*100%);
  background: 
    linear-gradient(90deg, #333 var(--perc), #ddd 0) fixed;
  transition: --perc .65s
}

We do something similar for the text itself. In this case, we also need to set the color property to transparent and clip its background to text. This isn’t super ideal as we can end up having to set such a background clipped to text to a lot of elements on the page, but oh, well…

p, label {
  background: 
    linear-gradient(90deg, #ddd var(--perc), #333 0) text fixed
}

Click on either “dark” or “light” below:

This is the basic idea behind. There are two big issues with it.

One, the swipe direction changes (it goes from right to left) when we switch back from the dark theme to the light one. We want the swipe transition to always go from left to right. We can fix this by using an angle --ang that depends on the value of the --dark switch. This isn’t the best solution as it limits us to a linear swipe effect, but we’ll stick to it for now and come back to this problem later.

--sign: sign(var(--dark) - .5);
--ang: calc(var(--sign)*90deg);

To this angle --ang, we may add another one that gives us the direction of the swipe.

Two, it doesn’t give any indication about whether any of these controls is focused or hovered (for example an outline) and there are no special styles for the currently selected one, but we can fix that using the DRY switching technique (with --hov and --sel switches) plus color-mix() to further simplify things.

The same idea applies to all text and links.

There are a couple of issues here.

The first is that we have these ugly edges around the link letters due to the fact that the link background clipped to text is placed on top of the paragraph background clipped to the same text and the latter contrasts with the page background even more.

There are a couple of pretty straightforward fixes here. One by isolating the paragraph and then applying a hard-light blend mode on the link and another by using slightly thicker text for the link. For example, with a font like REM, we can give the normal paragraph text a font-weight of 300 and the links a font-weight of 400.

The second is that when the background gets clipped to text, that doesn’t also include the text-decoration (underline, for example).

An easy solution for this would normally be to add another background of limited vertical size at the bottom, but this doesn’t work here due to the fixed nature of the background.

So we’re forced to use a pseudo and make links inline-block or wrap each link’s text content in a span. Each of these comes with some complications of its own, but oh, well…

In the future, being able to clip the fixed background to text and to a bottom border should do the trick without the need for the extra pseudo-element hack (see this proposal by Lea Verou).

Also, for every element that needs to have both text content and a background (like a button, for example!)… some bad news! Because of a Firefox bug old enough to go to school, we need to either make that element inline-block like we did in the links scenario and use a pseudo or wrap that text content in an inner span. Or, in order to avoid the problems that these two methods come with (and maybe introduce some performance ones instead), we could use an SVG filter. That’s pretty much what we have to do for a lot of input elements (like input[type=button]) anyway.

Okay, but what if we want to have a patterned background? Or what if we want a more interesting link hover effect, for example a XOR one? Or other kinds of XOR effects, for example one on a header? Blending (the difference blend mode in particular) to the rescue!

What about having some gradients on the page? For example, in the case of gradient buttons. That can be done using the same trick of putting the text, the gradient of the button and the gradient determining the progress of the theme swipe transition each on a different RGB channel. Then we use an SVG filter to extract the gradients and text for each of the two themes, paint them as desired and resolve how much of each is shown based on the progress of the theme swipe transition.

You may remember I said something about being tied to linear swipes here. But we can fix that in order to also have radial or conic ones!

First, we need to have two swipe percentage values: one that changes instantly when selecting another theme and another one that transitions smoothly. We only register the second one (--perc-ani).

--dark: 0;
--perc-fix: calc(var(--dark)*100%);
--perc-ani: calc(var(--dark)*100%);

We also compute a direction or sign value, whatever you want to call it. This is 1when we’ve switched from the light theme to the dark one (--dark is 1) and -1 otherwise (--dark got switched to 0).

--sign: sign(var(--dark) - .5)

Then we compute the progress of the swipe. This always smoothly goes from 0% to 100% over the course of the swipe that changes the theme, regardless of whether we’re switching from the light to the dark theme (--sign is 1--perc-fix has switched to 100% and --perc-ani transitions from 0% to 100%) or from the dark to the light theme (--sign is -1--perc-fix has switched to 0% and --perc-ani transitions from 100% to 0%).

--perc: calc(100% - var(--perc-fix) + var(--sign)*var(--perc-ani));

We then use this in the stop list for the gradient (which now can be a radial or a conic one too) transitioned to create the swipe effect:

--list: var(--c1) var(--perc), var(--c0) 0%

The --c0 and --c1 values depend on whether we’re going from the light theme to the dark one (--perc-fix has switched to 100%) or the other way (--perc-fix has switched to 0%):

--c0: color-mix(in srgb, var(--dark) var(--perc-fix), var(--light));
--c1: color-mix(in srgb, var(--dark), var(--light) var(--perc-fix));

And that’s it! This is the technique from my initial demo in this section which allows us to use any kind of gradient for our swipe.

I hope you’ve enjoyed this little ride through the land of fun effects without content duplication!

]]>
https://frontendmasters.com/blog/split-effects-with-no-content-duplication/feed/ 0 3782
Clip Pathing Color Changes https://frontendmasters.com/blog/clip-pathing-color-changes/ https://frontendmasters.com/blog/clip-pathing-color-changes/#respond Tue, 23 Jul 2024 17:29:41 +0000 https://frontendmasters.com/blog/?p=3103 This is a nice post from Emil Kowalski on usage of the clip-path property in CSS. I’ve always liked clip-path. Maybe it’s because it’s such a sharp knife. When you clip an element, it’s clipped, yo. There isn’t a lot of nuance to it, it does what it does. But moreso, I think I like the connection to SVG (oh, hey, by the way, I just made my old book Practical SVG entirely free). The value that you give clip-path is stuff like circle(), polygon(), path(), etc — the primitive shapes of SVG.

In Emil’s post, my favorite example is a navigation bar where a “pill” shape animates from one navigation item to another when they are clicked. The pill is a different background color, and so the text color also changes. (If you’re over 100 years old like me, we used to call this kind of thing “lava lamp” navigation 👴).

I would guess most people would assume what is happening here is an extra element set behind the links that moves position to underneath the newly active links. You could do it that way, but there is a minor aesthetic issue with it. Because the background-color is changing here, the text also needs to change appropriately (from dark to light here). You could change that color instantly, but that will look weird like it’s changing too early. You could set a transition on it, but you’ll never get the fade to look quite right, especially as it has to go through an awkward gray color.

Essentially, you’ll never get a state like this:

This ain’t gonna happen with an underlay element alone.

See how the text is half-light and half-dark mid-animation when the highlight blue pill moves from one to another? That’s a lovely effect that makes this feel very polished and smooth. This idea first came from a tweet by Paco. Like Emil says:

You might say that not everyone is going to notice the difference, but I truly believe that small details like this add up and make the experience feel more polished. Even if they go unnoticed.

Agreed.

In Emil’s post, it’s done with React. That’s totally fine, but I figured I’d make a vanilla one for y’all here:

Here’s how this works:

  1. There is one set of semantic HTML navigation.
  2. If JavaScript executes, it duplicates the nav (we’ll need two) but ensures the duplicate is hidden for screen readers.
  3. The duplicate is placed exactly on top of the original (it’s the “blue” one) and can’t directly be clicked (i.e. pointer-events: none;)
  4. A clip-path is set that highlights one of the navigation items in particular by clipping the entire duplicate except one link.
  5. As links are clicked, the clip-path is changed using positional math, highlighting the new one. Also high-five for the round keyword that can be used with inset() for rounded corners on inset rectangles.
  6. The clip-path animates, thanks to a basic CSS transition.

I think it’s cool as heck that it all comes together that cleanly.

It’s also a nice touch that the new clip-path positions are calculated based on their page position, meaning that there are really no magic numbers here. If we add navigation items or change them, this code will be resilient and it will all still work. And if none of this JavaScript runs at all, no big deal.

]]>
https://frontendmasters.com/blog/clip-pathing-color-changes/feed/ 0 3103