Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Wed, 26 Nov 2025 21:30:51 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 225069128 How to Add and Remove Items From a Native CSS Carousel (…with CSS) https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/ https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/#respond Wed, 26 Nov 2025 21:30:50 +0000 https://frontendmasters.com/blog/?p=7830 The CSS Overflow Module Level 5 defines specs for scrolling controls that enable users to navigate overflow content without manually scrolling (like click-and-dragging the scrollbar, the trackpad, a scrollwheel, or the like). This includes scroll buttons, which enable users to scroll 85% of the scrollport, unless scroll snapping is enabled, as well as scroll markers, which enable users to skip to specific scroll targets (direct children of the scroll container).

These buttons and markers make themselves present via CSS pseudo-elements. At the time of this writing, these pseudo-elements are only supported in Chrome 142+:

  • ::scroll-marker: a generated element that links to a scroll target in a scroll container (behaves like an <a>)
  • ::scroll-button(<direction>): a generated element that scrolls 85% of the scrollport, where <direction> can be up , down, left, right, or all (behaves like a <button>)

There are many ways that we can leverage these CSS features. I’ll share some of them throughout this article, but focus on one in particular: a standard CSS carousel. We’ll use all the bells and whistles mentioned above and one extra twist, the ability to add and remove items from it.

This functionality would be ideal for showing product photos according to user-defined variables such as color or size, or showing items handpicked by users, just to give to two examples.

Ready to dig in?

Step 1: Setting up the Scroll Container

In this first step, I’m just going to walk you through the HTML, the carousel’s dimensions, and how we determine which carousel items have been added to the carousel.

The HTML

The carousel itself is an unordered list (<ul>) with list items (<li>) inside (in terms of accessibility, I think this is the best markup). Prior to that we have some checkboxes, which users can toggle to add and remove the carousel items, and for the purpose of this walkthrough I’ve pre-selected a few of them using the checked attribute. Finally, we wrap all of that, establishing our overall component. This part is important because we’ll be seeing which checkboxes within it aren’t checked, and then hiding the corresponding carousel items — also within it — based on that:

<div class="component">

  <input type="checkbox" id="i1"><label for="i1">Toggle slide 1</label>
  <input type="checkbox" id="i2"><label for="i2">Toggle slide 2</label>
  <input type="checkbox" id="i3" checked><label for="i3">Toggle slide 3</label>
  <input type="checkbox" id="i4" checked><label for="i4">Toggle slide 4</label>
  <input type="checkbox" id="i5" checked><label for="i5">Toggle slide 5</label>

  <ul class="carousel">
    <li>1</li>
    <li>2</li>
    <li>3</li>
    <li>4</li>
    <li>5</li>
  </ul>

</div>

The CSS

First we rein in the carousel’s width using max-width, then we make the height half of whatever the computed width is using aspect-ratio. Just a little responsive design.

After that, we see which checkboxes aren’t checked, and then hide the corresponding scroll targets/carousel items. For example, this is what we do for the first checkbox and carousel item:

  • .component:has(input:nth-of-type(1):not(:checked)) {}: select the component, which contains an input, the first of which isn’t checked
  • li:nth-of-type(1) {}: within that, select the first carousel item
  • display: hidden: and hide it

That covers the adding-and-removing logic. What’s even better is that those checkboxes can be used to submit data, so if you were to mark up the component as a <form>, you do form-like things with it, like serialize the data and save it.

In the next section, we declare placeholder styles for when no checkboxes are checked (.component:not(:has(input:checked))). This conditional :has() block (and certain others) isn’t required, but it’s a great way of clarifying what, when, and why, for other developers and for yourself if you come back to the code later.

If at least one checkbox is checked (.component:has(input:checked)), the carousel receives display: flex, which makes the carousel items flow horizontally, while the carousel items within receive min-width: 100%, which ensures that only one carousel item is displayed at a time.

The final block runs if multiple checkboxes are checked (.component:has(input:checked ~ input:checked), which translates to “if a checked checkbox is immediately or non-immediately followed by another checked checkbox”). This is where the code for the scroll markers, scroll buttons, and scroll behaviors will go.

.component {
  ul.carousel {
    max-width: 738px;
    aspect-ratio: 2 / 1;
  }

  /* If the first checkbox isn’t checked */
  &:has(input:nth-of-type(1):not(:checked)) {
    /* Hide the first list item */
    li:nth-of-type(1) {
      display: none;
    }
  }

  /* And so on, incrementing the nth-of-type */
  &:has(input:nth-of-type(2):not(:checked)) {
    li:nth-of-type(2) {
      display: none;
    }
  }

  /* If no checkboxes are checked */
  &:not(:has(input:checked)) {
    /* Placeholder content for the carousel */
    ul.carousel {
      background: #eee;
    }
  }

  /* If any checkbox is checked */
  &:has(input:checked) {
    ul.carousel {
      /* Implies flex-direction:row */
      display: flex;

      li {
        /* Show one list item only */
        min-width: 100%;
      }
    }
  }

  /* If multiple are checked */
  &:has(input:checked ~ input:checked) {
    /* Step 2 and step 3 code here */
  }
}

Continuing from where we left off, let’s revisit the carousel. Keep in mind that we’re working within the context of multiple checkboxes being checked (.component:has(input:checked ~ input:checked), which means that the carousel items that are visible will horizontally overflow the carousel unless we declare overflow: hidden (or overflow: scroll if you want to allow manual scrolling).

Next, by default, scroll buttons enable users to scroll 85% of the scrollport, but we’re looking for a slideshow-type behavior where one complete slide is shown at a time, so we’ll need to set up scroll snapping for the additional 15%. scroll-snap-type: x will do exactly that for the x-axis. We’ll figure out the exact alignment in a moment.

Complimenting that, scroll-behavior: smooth will ensure that users snap to the carousel items smoothly when using the scroll buttons (and scroll markers).

anchor-name: --carousel turns the carousel into an anchor, naming it --carousel. This will enable us to align the scroll buttons (and scroll markers) relative to the carousel, but again this is something that we’ll do in a moment.

The scroll-marker-group property relates to the ::scroll-marker-group pseudo-element that’s generated whenever a scroll marker is generated. It’s basically the container for the scroll markers, where the value of scroll-marker-group determines whether ::scroll-marker-group is inserted before or after the carousel’s content (similarly to ::before/::after), affecting tab order. You must set scroll-marker-group to either before or after.

Treat ::scroll-marker-group like any other container. For example, display: flex; gap: 1rem; will make the scroll markers flow horizontally with 1rem of spacing between them. After that, we combine position: fixed and position-anchor: --carousel (--carousel refers to the anchor that we named earlier) to align the container relative to the carousel, then justify-self: anchor-center to align it horizontally and bottom: calc(anchor(bottom) + 1rem) to align it 1rem from the bottom.

The scroll markers are pseudo-elements of the scroll targets (so li::scroll-marker), which makes sense, right? One marker for every scroll target. But as mentioned before, they’re inserted into ::scroll-marker-group, not the scroll targets, so where you select them isn’t where they’re inserted. After your brain has reconciled that (it took me a minute), you’ll need to set their content property. We’re using stylized markers here, so we’ve set them to an empty string (content: ""), but you can insert whatever content you want inside of them, and even number them using CSS counters.

After that you’re free to style them, and if you want to take that a step further, ::scroll-marker has three pseudo-classes:

  • :target-current: the active scroll marker
  • :target-before: all scroll markers before the active one
  • :target-after: all scroll markers after the active one

Note: the pseudo-class must be prefixed by the pseudo-element:

/* Won’t work */
:target-current {
  /* ... */
}

/* Won’t work (even though it should?) */
::scroll-marker {
  &:target-current {
    /* ... */
  }
}

/* Only this will work */
::scroll-marker:target-current {
  /* ... */
}

This is the full (step 2) CSS code:

/* Step 1 code here */

ul.carousel {
  /* Hide overflow/disable scrolling */
  overflow: hidden;

  /* Enable x-axis scroll snapping */
  scroll-snap-type: x;

  /* Enable smooth scrolling */
  scroll-behavior: smooth;

  /* Turn the carousel into an anchor */
  anchor-name: --carousel;

  /* Insert the SMG after the content */
  scroll-marker-group: after;

  /* SMG (holds the scroll markers) */
  &::scroll-marker-group {
    /* Scroll marker layout */
    display: flex;
    gap: 1rem;

    /* Anchor the SMG to the carousel */
    position: fixed;
    position-anchor: --carousel;

    /* Anchor it horizontally */
    justify-self: anchor-center;

    /* Anchor it near the bottom */
    bottom: calc(anchor(bottom) + 1rem);
  }

  li::scroll-marker {
    /* Generate empty markers */
    content: "";

    /* Style the markers */
    width: 1rem;
    aspect-ratio: 1 / 1;
  }

  /* Active marker */
  li::scroll-marker:target-current {
    background: white;
  }

  /* All markers before the active one */
  li::scroll-marker:target-before {
    background: hsl(from white h s l / 50%);
  }

  /* All markers after the active one */
  li::scroll-marker:target-after {
    background: red;
  }

  /* Step 3 code here */
}

Step 3: adding the scroll buttons

In this final step we’ll add the scroll buttons, which are pseudo-elements of the scroll container (the carousel in this case). ::scroll-button() accepts five physical values for its only parameter:

  • ::scroll-button(*)
  • ::scroll-button(left)
  • ::scroll-button(right)
  • ::scroll-button(up)
  • ::scroll-button(down)

As well as four logical values:

  • ::scroll-button(block-start)
  • ::scroll-button(block-end)
  • ::scroll-button(inline-start)
  • ::scroll-button(inline-end)

They too must have valid content properties like the scroll markers, otherwise they won’t show up. In today’s example we’re only using ::scroll-button(left) and ::scroll-button(right), inserting directional arrows into them, but only when enabled (e.g., ::scroll-button(left):enabled). When they’re :disabled (which means that it’s impossible to scroll any further in that direction), no content property is set (which, again, means that they won’t show up).

We also use anchor positioning again, to align the scroll buttons relative to the carousel. ::scroll-button(*) selects all scroll buttons, which is where most of this logic is declared, then of course ::scroll-button(left) and ::scroll-button(right) to align the individual buttons to their respective edges.

And finally, we also declare scroll-snap-align: center on the carousel items (<li>), complimenting the scroll-snap-type: x that we declared on the carousel earlier, which ensures that when users click on these scroll buttons, they don’t scroll 85% of the scrollport. Instead, they snap to the scroll target fully.

/* Step 1 code here */

ul.carousel {
  /* Step 2 code here */

  /* All scroll buttons */
  &::scroll-button(*) {
    /* Anchor them to the carousel */
    position: fixed;
    position-anchor: --carousel;

    /* Anchor them vertically */
    align-self: anchor-center;
  }

  /* Left scroll button (if enabled) */
  &::scroll-button(left):enabled {
    /* Generate the button with content */
    content: "⬅︎";

    /* Anchor it near the left */
    left: calc(anchor(left) + 1rem);
  }

  /* Right scroll button (if enabled) */
  &::scroll-button(right):enabled {
    /* Generate the button with content */
    content: "⮕";

    /* Anchor it near the right */
    right: calc(anchor(right) + 1rem);
  }

  li {
    /*
      Snap to the center of scroll targets
      instead of scrolling 85% of the scrollport
    */
    scroll-snap-align: center;
  }
}

And here’s the same thing but vertical:

Wrapping up

These features are really cool. They can be used in so many different ways, from tabs to pagination. To throw in a more a real-world example of what we explored here today, a carousel showing product photos:

Just choose the color that you want and that’s what the carousel will show!

]]>
https://frontendmasters.com/blog/how-to-add-and-remove-items-from-a-native-css-carousel-with-css/feed/ 0 7830
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
Quantity Query Carousel https://frontendmasters.com/blog/quantity-query-carousel/ https://frontendmasters.com/blog/quantity-query-carousel/#comments Wed, 25 Jun 2025 23:04:06 +0000 https://frontendmasters.com/blog/?p=6323 The concept of a quantity query is really neat. Coined by Heydon back in 2015, the idea is that you apply different styles depending on how many siblings there are. They was a way to do it back then, but it’s gotten much easier thanks to :has(), which not only makes the detection easier but gives us access to the parent element where we likely want it.

For instance:

.grid {
  display: grid;

  &:has(:nth-child(2)) {
    /* Has at least 2 elements */
    grid-template-columns: 1fr 1fr;
  }
 
  /* Use a :not() to do reverse logic */
}

What if we kept going with the idea where we…

  • If there is 1 element, let it be full-width
  • If there are 2 elements, set them side-by-side
  • If there are 3 elements, the first two are side-by-side, then the last is full-width
  • If there are 4 elements, then it’s a 2×2 grid

Then…

  • If there are 5+ elements, woah there, let’s just make it a carousel.

I heard Ahmad Shadeed mention this idea on stage at CSS Day and I had to try it myself. Good news is that it works, particularly if you can stomach the idea of a “carousel” just being “horizontal overflow with some scroll snapping” in Firefox/Safari for now. Of course you’d be free to make your own fallback as needed.

Here’s the whole gang:

Setup & One

The default setup can be something like:

.grid {
  display: grid;
  gap: 1rem;
}

Honestly we don’t even really need to make it a grid for one item, but it doesn’t really hurt and now we’re set up for the rest of them.

Two

Does it have two? Yeah? Let’s do this.

.grid {
  ...

  &:has(:nth-child(2)) {
    grid-template-columns: 1fr 1fr;
  }
}

Note that if our grid has three or more elements, this will also match. So if want to do something different with columns, we’ll need to override this or otherwise change things.

Three

To illustrate the point, let’s match where there are only three items.

.grid {
  ...

  &:has(> :nth-child(3)):not(:has(> :nth-child(4))) {
    > :nth-child(3) {
      grid-column: span 2;
    }
  }
}

So we’re not going to change the 2-column grid, we’ll leave that alone from two. And now we’re not selecting the grid itself, but just grabbing that third item and stretching it across both columns of the grid.

Four

We can… do nothing. It’s already a two-column grid from two. So let’s let it be.

Five+

This is the fun part. We already know how to test for X+ children, so we do that:

.grid {
  ...

  &:has(:nth-child(5)) {
    grid-template-columns: unset;
  }
}

But now we’re unseting those columns, as we don’t need them anymore. Instead we’re going with automatic column creation in the column direction. We could use flexbox here too essentially but we’re already in a grid and grid can do it with easy sturdy columns so might as well. Then we’ll slap smooth scrolling and scroll snapping on there, which will essentially be the fallback behavior (only Chrome supports the ::scroll-button stuff that makes it carousel-like for now).

.grid {
  ...

  &:has(:nth-child(5)) {
    grid-template-columns: unset;

    grid-auto-flow: column;
    grid-auto-columns: 200px;

    overflow-x: auto;
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scroll-behavior: smooth;

    > div {
      scroll-snap-align: center;
    }
  }
}

Actually Carouselling

We’re all set up for it, we just need those back/forward buttons to make it really be a carousel. That’s a CSS thing now, at least in Chrome ‘n’ friends, so we can progressively enhance into it:

.grid {
  ...

  &:has(:nth-child(5)) {
    ...

    anchor-name: --⚓️-carousel;

    &::scroll-button(*) {
      position: absolute;
      top: 0;
      left: 0;
      position-anchor: --⚓️-carousel;
      background: none;
      border: 0;
      padding: 0;
      font-size: 32px;
    }

    &::scroll-button(right) {
      position-area: center inline-end;
      translate: -3rem -0.5rem;
      content: "➡️" / "Next";
    }

    &::scroll-button(left) {
      position-area: inline-start center;
      translate: 3rem -0.5rem;
      content: "⬅️" / "Previous";
    }
  }
}

That’ll do it! Here’s the demo and I’ll video it in case you’re not in Chrome.

]]>
https://frontendmasters.com/blog/quantity-query-carousel/feed/ 1 6323
Container Query for “is there enough space outside this element?” https://frontendmasters.com/blog/container-query-for-is-there-enough-space-outside-this-element/ https://frontendmasters.com/blog/container-query-for-is-there-enough-space-outside-this-element/#comments Tue, 13 May 2025 16:26:46 +0000 https://frontendmasters.com/blog/?p=5796 Say you had a UI component that had pagination arrows for whatever reason. If there was enough space on the outside of that component, you wanted to put those arrows outside, like this this:

But if there isn’t enough room for them without shrinking the main content area, then place them inside, like this:

You could do that with plenty of magic numbers, especially in how big the main content area is. But wouldn’t it be cool if you didn’t have to know? Like the main content area could be whatever, fluid/responsive/flexible, and you could still test if there is “room” outside for the arrows or not.

I was playing with this trick because I remember Adam Argyle talking about it one time, but couldn’t find where he used it. So I wrote this article up to re-learn and document it. Then of course I find the original article. So full credit to Adam here. Mine approach here is super similar of course. I think I prefer how his @container query uses cqi units inside of it in case the parent isn’t the viewport. Clever.

The trick is in combining viewport units within a container query. You could probably do it by using container units within a media query too, but we’ll go with the former because I tried it and it worked.

We’re going to need a “wrapper” element because that’s just how @container queries tend to be most useful. You can’t query the same element that is the container, so easier if the container is a wrapper.

<div class="box">
  <div class="box-inner">
    <div class="arrow arrow-left">
       <svg ...></svg>
    </div>
    <div class="arrow arrow-right">
       <svg ...></svg>
  </div>
</div>

The box will be the container:

.box {
  container: box / inline-size;
  inline-size: min(500px, 100vw);
}

I love using that second declaration, which says: “Make the element 500px wide, but if the entire browser window is smaller than that, do that instead.” That element is the container.

Then we can use a @container query on the inside. If we wanted to make a style change exactly when the container is the same size as the browser window, we could do this:

.box-inner {
  background: rebeccapurple;
  ...

  @container box (inline-size >= 100vw) {
    background: red;
  }
}

That will do this!

But we’re actually dealing with the arrows here, so what we want to know is “is there enough space outside for them?” Meaning not the exact size of the element, but that:

Element <= Viewport - Arrows - Gap

Which we can express like this:

.box-inner {
  background: rebeccapurple;
  ...

  @container box (inline-size <= calc(100vw - 80px * 2 - 1rem * 2)) {
    /* move arrows here */
  }
}

I’ll use a bit of translate to move the arrows here:

And here’s a video of the success:

Again what’s kinda cool about this is that we don’t know what the size of the container is. It could be changed anytime and this will continue to work. The only hard coded numbers we used were for the size of the arrow elements and the gap, which you could abstract out to custom properties if you wanted to be more flexible.

]]>
https://frontendmasters.com/blog/container-query-for-is-there-enough-space-outside-this-element/feed/ 4 5796