Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Mon, 04 Aug 2025 18:30:29 +0000 en-US hourly 1 https://wordpress.org/?v=6.8.3 225069128 Infinite Marquee Animation using Modern CSS https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/ https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/#comments Mon, 04 Aug 2025 18:30:28 +0000 https://frontendmasters.com/blog/?p=6673 A set of logos with an infinite repeating slide animation is a classic component in web development. We can find countless examples and implementations starting from the old (and now deprecated) <marquee> element. I’ve written an article about it myself a few years ago.

“Why another article?” you ask. CSS keeps evolving with new and powerful features, so I always try to find room for improvement and optimization. We’ll do that now with some new CSS features.

At the time of writing, only Chrome-based browsers have the full support of the features we will be using, which include features like shape(), sibling-index(), and sibling-count().

In the demo above, we have an infinite marquee animation that works with any number of images. Simply add as many elements as you want in the HTML. There is no need to touch the CSS. You can easily control the number of visible images by adjusting one variable, and it’s responsive. Resize the screen and see how things adjust smoothly.

You might think the code is lengthy and full of complex calculations, but it’s less than 10 lines of CSS with no JavaScript.

.container {
  --s: 150px; /* size of the images */
  --d: 8s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%,hline by calc(sibling-count()*max(100%/var(--n),var(--s))));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to { offset-distance: 100%; }
}

Perhaps this looks complex at first glance, especially that strange offset property! Don’t stare too much at it; we will dissect it together, and by the end of the article, it will look quite easy.

The Idea

The tricky part when creating a marquee is to have that cyclic animation where each element needs to “jump” to the beginning to slide again. Earlier implementations will duplicate the elements to simulate the infinite animation, but that’s not a good approach as it requires you to manipulate the HTML, and you may have accessibility/performance issues.

Some modern implementations rely on a complex translate animation to create the “jump” of the element outside the visible area (the user doesn’t see it) while having a continuous movement inside the visible area. This approach is perfect but requires some complex calculation and may depend on the number of elements you have in your HTML.

It would be perfect if we could have a native way to create a continuous animation with the “jump” and, at the same time, make it work with any number of elements. The first part is doable and we don’t need modern CSS for it. We can use offset combined with path() where the path will be a straight line.

Inside path, I am using the SVG syntax to define a line, and I simply move the image along that line by animating offset-distance between 0% and 100%. This looks perfect at first glance since we have the animation we want but it’s not a flexible approach because path() accepts only hard-coded pixel values.

To overcome the limitation of path(), we are going to use the new shape() function! Here is a quote from the specification:

The shape() function uses a set of commands roughly equivalent to the ones used by path(), but does so with more standard CSS syntax, and allows the full range of CSS functionality, such as additional units and math functions … In that sense, shape() is a superset of path().

Instead of drawing a line using path(), we are going to use shape() to have the ability to rely on CSS and control the line based on the number of elements.

Here is the previous demo using shape():

If you are unfamiliar with shape(), don’t worry. Our use case is pretty basic as we are going to simply draw a horizontal line using the following syntax:

offset: shape(from X Y, hline by length);

The goal is to find the X Y values (the coordinates of the starting point) and the length value (the length of the line).

The Implementation

Let’s start with the HTML structure, which is a set of images inside a container:

<div class="container">
  <img src="">
  <img src="">
  <!-- as many images as you want -->
</div>

We make the container flexbox to remove the default space between the image and make sure they don’t wrap even if the container is smaller (remember that flex-wrap is by default nowrap).

Now, let’s suppose we want to see only N images at a time. For this, we need to define the width of the container to be equal to N x size_of_image.

.container {
  --s: 100px; /* size of the image */
  --n: 4; /* number of visible images */

  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
}

Nothing complex so far. We introduced some variables to control the size and the number of visible images. Now let’s move to the animation.

To have a continuous animation, the length of the line needs to be equal to the total number of images multiplied by the size of one image. In other words, we should have a line that can contain all the images side by side. The offset property is defined on the image elements, and thanks to modern CSS, we can rely on the new sibling-count() to get the total number of images.

offset: shape(from X Y, hline by calc(sibling-count() * var(--s)));

What about the X Y values? Let’s try 0 0 and see what happens:

Hmm, not quite good. All the images are above each other, and their position is a bit off. The first issue is logical since they share the same animation. We will fix it later by introducing a delay.

The trickiest part when working with offset is defining the position. The property is applied on the child elements (the images in our case), but the reference is the parent container. By specifying 0 0, we are considering the top-left corner of the parent as the starting point of the line.

What about the images? How are they placed? If you remove the animation and keep the offset-distance equal to 0% (the default value), you will see the following.

An animated marquee with text that moves horizontally across a container, showcasing a modern CSS implementation for infinite scrolling images or text.

The center of the images is placed at the 0 0, and starting from there, they move horizontally until the end of the line. Let’s update the X Y values to rectify the position of the line and bring the images inside the container. For this, the line needs to be in the middle 0 50%.

offset: shape(from 0 50%, hline by calc(sibling-count() * var(--s)));

It’s better, and we can already see the continuous animation. It’s still not perfect because we can see the “jump” of the image on the left. We need to update the position of the line so it starts outside the container and we don’t see the “jump” of the images. The X value should be equal to -S/2 instead of 0.

offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));

No more visible jump, the animation is perfect!

To fix the overlap between the images, we need to consider a different delay for each image. We can use nth-child() to select each image individually and define the delay following the logic below:

img:nth-child(1) {animation-delay: -1 *  duration/total_image }
img:nth-child(2) {animation-delay: -2 *  duration/total_image }
/* and so on */

Tedious work, right? And we need as many selectors as the number of images in the HTML code, which is not good. What we want is a generic CSS code that doesn’t depend on the HTML structure (the number of images).

Similar to the sibling-count()that gives us the total number of images, we also have sibling-index() that gives us the index of each image within the container. All we have to do is to update the animation property and include the delay using the index value that will be different for each image, hence a different delay for each image!

animation: 
  x var(--d) linear infinite 
  calc(-1*sibling-index()*var(--d)/sibling-count());

Everything is perfect! The final code is as follows:

.container {
  --s: 100px; /* size of the image */
  --d: 4s; /* animation duration */
  --n: 4; /* number of visible images */
  
  display: flex;
  width: calc(var(--n) * var(--s));
  overflow: hidden;
}
img {
  width: var(--s);
  offset: shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * var(--s)));
  animation: x var(--d) linear infinite calc(-1*sibling-index()*var(--d)/sibling-count());
}
@keyframes x { 
  to {offset-distance: 100%}
}

We barely have 10 lines of CSS with no hardcoded values or magic numbers!

Let’s Make it Responsive

In the previous example, we fixed the width of the container to accommodate the number of images we want to show but what about a responsive behavior where the container width is unknown? We want to show only N images at a time within a container that doesn’t have a fixed width.

The observation we can make is that if the container width is bigger than NxS, we will have space between images, which means that the line defined by shape() needs to be longer as it should contain the extra space. The goal is to find the new length of the line.

Having N images visible at a time means that we can express the width of the container as follows:

width = N x (image_size + space_around_image)

We know the size of the image and N (Defined by --s and --n), so the space will depend on the container width. The bigger the container is, the more space we have. That space needs to be included in the length of the line.

Instead of:

hline by calc(sibling-count() * var(--s))

We need to use:

hline by calc(sibling-count() * (var(--s) + space_around_image))

We use the formula of the container width and replace (var(--s) + space_around_image) with width / var(--n) and get the following:

hline by calc(sibling-count() * width / var(--n) )

Hmm, what about the width value? It’s unknown, so how do we find it?

The width is nothing but 100%! Remember that offset considers the parent container as the reference for its calculation so 100% is relative to the parent dimension. We are drawing a horizontal line thus 100% will resolve to the container width.

The new offset value will be equal to:

shape(from calc(var(--s)/-2) 50%, hline by calc(sibling-count() * 100% / var(--n)));

And our animation is now responsive.

Resize the container (or the screen) in the below demo and see the magic in play:

We have the responsive part but it’s still not perfect because if the container is too small, the images will overlap each other.

We can fix this by combining the new code with the previous one. The idea is to make sure the length of the line is at least equal to the total number of images multiplied by the size of one image. Remember, it’s the length that allows all the images to be contained within the line without overlap.

So we update the following part:

calc(sibling-count() * 100%/var(--n))

With:

max(sibling-count() * 100%/var(--n), sibling-count() * var(--s))

The first argument of max() is the responsive length, and the second one is the fixed length. If the first value is smaller than the second, we will use the latter and the images will not overlap.

We can still optimize the code a little as follows:

calc(sibling-count() * max(100%/var(--n),var(--s)))

We can also add a small amount to the fixed length that will play the role of the minimum gap between images and prevent them from touching each other:

calc(sibling-count() * max(100%/var(--n),var(--s) + 10px))

We are done! A fully responsive marquee animation using modern CSS.

Here is again the demo I shared at the beginning of the article with all the adjustments made:

Do you still see the code as a complex one? I hope not!

The use of min() or max() is not always intuitive, but I have a small tutorial that can help you identify which one to use.

More Examples

I used images to explain the technique, but we can easily extend it to any kind of content. The only requirement/limitation is to have equal-width items.

We can have some text animations:

Or more complex elements with image + text:

In both examples, I am using flex-shrink: 0 to avoid the default shrinking effect of the flex items when the container gets smaller. We didn’t have this issue with images as they won’t shrink past their defined size.

Conclusion

Some of you will probably never need a marquee animation, but it was a good opportunity to explore modern features that can be useful such as the shape() and the sibling-*() functions. Not to mention the use of CSS variables, calc(), max(), etc., which I still consider part of modern CSS even if they are more common.

]]>
https://frontendmasters.com/blog/infinite-marquee-animation-using-modern-css/feed/ 6 6673
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