The demo:

See the Pen Swiper-Slider: Scroll, Drag, and Click to Navigate by AT Studio (@AtStudio) on CodePen.

CSS

Naming conventions

Clear, descriptive naming is key to maintainable CSS.

:root {
  /* instead of --heading-lg */
  --heading-32-24: clamp(1.5rem, 1.2727rem + 0.8081cqi, 2rem);

  /* 
   * instead of 
   * --pagination-padding-inline: 0.25rem;
   * --pagination-margin-inline: 0.25rem;
   * --image-container-padding-inline: 0.25rem;
   * --image-container-margin-inline: 0.25rem;
   */
  --spacing-4: 0.25rem;
}

Making CSS variables more intuitive

A lot of CSS variables have names that don’t tell you much. Take --heading-lg as an example — how big is “large”? Is it 20px? 40px? You’d have to open DevTools and peek at the value to figure it out. A name like --heading-32-24 instantly shows that the font size clamps between 32px and 24px. With this approach, I can see the upper and lower bounds right in the name — no need to dig into the code. This kind of naming doesn’t just help developers; it also makes the design system clearer for designers. Check out the typography of our design system.

Flowbit's Typography Design System doucmenting every typography details

Simplifying spacing with generic names

For spacing, reusing the same value (e.g., 0.25rem) across margins and paddings is common. Rather than duplicating it with context-specific names like --pagination-padding-inline, a generic --spacing-4 signals that this variable handles spacing (whether it’s margin or padding), and the “4” ties it to 0.25rem (16px base). Then, you can apply that value directly to padding-inline or margin-inline wherever it’s needed. It’s clear what the variable does and what value it holds.

Container queries for responsive design

Here, swiper-slider acts as a container, this means the layout of its children will adjust based on the container’s inline-size. According to MDN container-type: inline-size means:

The query will be based on the inline dimensions of the container. Applies layout, style, and inline-size containment to the element.

What are "inline dimensions"?

The term "inline dimensions" refers to the direction text flows in your writing mode. In languages written left-to-right (like English) or right-to-left (like Arabic), this is the width. In vertical writing modes (like some East Asian scripts), it’s the height. In our example, inline-size here means width.

What does "inline-size containment" mean?

Normally, an element’s size — like a <div> — can stretch or shrink to fit its content. For example, a <div> with no specific width might expand to hold a long string of text. Applying inline-size containment changes that. It tells the browser to calculate the element’s width based solely on its own CSS properties (like width or min-width), ignoring the content inside. In our case, the width of swiper-slider is locked in — it won’t stretch or shrink based on what’s inside it.

Why choose container queries over media queries?

Media queries rely on the viewport size to trigger layout changes, but that can lead to problems. Imagine swiper-slider takes up only 50% of the screen, with another component using the other 50%. If the viewport is still wide, a media query might not trigger, leaving swiper-slider stuck with a layout meant for a larger space — everything gets squished.

Container queries solve this by focusing on the container’s width instead. No matter where you place swiper-slider or how much space it occupies, its layout adapts to its own size. Although you need an extra wrapper (the <swiper-slider> element) to make it work, the trade-off is worth it: your component stays responsive and looks right in any context.

Grid layout with named areas

article {
  display: grid;
  grid-template-columns: 32.9% 1fr;
  grid-template-areas:
    "img_container pagination"
    "img_container slider";
  /* ... */
}

The grid-template-areas property simplifies layout design by naming grid regions. It’s more intuitive than juggling line numbers and avoids using a bunch of generic containers. It is ideal for non-overlapping layouts, making the structure instantly readable.

Animating with @starting-style

img {
  /* ... */
  opacity: 1;
  transform: scale(1.01);
  transition: opacity 0.4s ease-in-out, transform 0.6s linear;

  @starting-style {
    opacity: 0.3;
    transform: scale(1);
  }
}

Juan Diego Rodríguez's article on @starting-style is worth checking out.

What CSS animations can do

CSS has long been able to animate properties that can be interpolated — meaning the browser can calculate smooth steps between a starting value (the before-change style) and an ending value (the after-change style)

  • opacity: 0 to 1

  • transform: scale(0) to scale(1)

These properties use numeric values where the browser can calculate the in-between states.

The problem with discrete properties

Some properties, however, are discrete - they switch between distinct states with no middle ground. For example:

  • display: can be none or block, but not half none, half block, it must jump from one state to another

Since an element is either in the DOM or removed entirely, and there is no "50% displayed" state, the browser can't interpolate between the "not in DOM" and "in DOM" states.

How @starting-style fixes this

The W3C spec explained that @starting-style defines a "starting style" for elements that don't have a before-change style. At the moment the element first appears in the DOM, the browser inserts the starting style as if it were the before-change style.

In our example, the img element’s parent is an li with display: none. Since the img isn’t in the layout yet, it doesn’t have a before-change style. Here’s where @starting-style comes in:

  • Starting style (via @starting-style): opacity: 0.3 and transform: scale(1)

  • After-change style (defined on img): opacity: 1 and transform: scale(1.01)

When the li’s display property changes from none to block, the img suddenly appears in the DOM. At that moment, the browser applies the @starting-style as if it were the before-change style.

Why this matters

Without @starting-style, the img would just pop into view with its final styles (opacity: 1 and transform: scale(1.01)) the instant display becomes block. By defining a starting point, you get a polished animation — making the element’s appearance feel intentional and smooth rather than abrupt.

Conditional styling with :has()

&:has(.pagination li:nth-of-type(1) button[active]) .img_container li:nth-of-type(1),
&:has(.pagination li:nth-of-type(2) button[active]) .img_container li:nth-of-type(2),
&:has(.pagination li:nth-of-type(3) button[active]) .img_container li:nth-of-type(3) {
  display: block;
}

The & is a CSS nesting selector which refers to the parent selector article, while :has() acts like a conditional (think about if). In our example, if .pagination li:nth-of-type(1) button has an active attribute, .img_container li:nth-of-type(1) becomes display: block. This links image visibility to pagination state, leveraging the article element as the common parent.

Scroll snapping and scroll-state

.slider {
  /* ... */
  scroll-snap-type: x mandatory;

  li {
    container-type: scroll-state;
    scroll-snap-align: center;
    /* ... */

    *{
      transition: opacity 1s ease;

      @container not scroll-state(snapped: x) {
        opacity: 0.3;
      }
    }
  }
}

Understanding scroll-snap-type and scroll-snap-align

First, let’s unpack the two key properties driving the scroll-snapping behavior scroll-snap-type and scroll-snap-align.

  • scroll-snap-type

    It defines which axis the scroll container snaps along and how strictly it enforces snapping. Here, scroll-snap-type: x mandatory means:

    • x: snapping happens along the the x-axis

    • mandatory: the browser must snap to a designated snap position. No halfway stops allowed.

  • scroll-snap-align

    So which property defines the designated snap position? It is defined by the scroll-snap-align property. According to MDN:

    The scroll-snap-align property specifies the box's snap position as an alignment of its snap area (as the alignment subject) within its snap container's snap port (as the alignment container).

Mapping the definition to our example:

  • Snap Container: the ul.slider element(the parent), which has scroll-snap-type.

  • Snap Area: Each li element(the child), which has scroll-snap-align.

  • Snap Port: the viewport of the scroll container, which is the ul.slider element - basically, the part of the scroll container that the user sees.

With scroll-snap-align: center on the li elements, we’re instructing the browser: “When scrolling stops, align the center of each li (the snap area) with the center of the visible area of ul.slider (the snap port).”

Key Takeaway: The parent (ul.slider) controls the snapping direction and strictness with scroll-snap-type, while the child (li) decides which part of the child snap area (start, center, end) should align with the corresponding part of the parent snap port (scroll-snap-align).

Understanding scroll-state

Now let's take a look at the scroll-state. Adam Argyle's article on scroll-state() explains:

the container for a snap query is the element with scroll-snap-align on it

In our case, the container is the li element. By setting container-type: scroll-state on the li, we enable it to act as a container for scroll-state queries. This means we can style the children of each li based on whether the li is snapped into position.

  • The query not scroll-state(snapped: x) targets any li that is not snapped to the center along the x-axis.

  • For those unsnapped li elements, their child elements get opacity: 0.3.

  • The li that is snapped to the center keeps its children at opacity 1.

With the transition property, As you scroll, the children of an li that moves out of the center gradually fade to 30% opacity. The children of the newly centered li fade up to 100% opacity. This creates a visual focus effect, drawing attention to the centered item while subtly dimming the others.

Putting it all together

Let’s summarize the relationships between the elements:

  • Parent (ul.slider): The scroll container. It sets the snapping rules with scroll-snap-type: x mandatory, controlling the direction (horizontal) and strictness (mandatory snapping).

  • Child (li): The snap area. It defines the snap position with scroll-snap-align: center (controls which part of the li should align with corresponding part of the ul.slider snap port, center in our case) and serves as the container for scroll-state queries.

  • Grandchildren (children of li): These adjust their styles (like opacity) based on whether their parent li is snapped to the center.

In action:

  • When you scroll, the browser snaps each li to the center of the slider’s visible area.

  • The scroll-state query checks each li’s snap status, fading out the children of unsnapped items and keeping the centered item’s children fully visible.