Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Fri, 09 Jan 2026 13:24:48 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 225069128 Beyond the Mouse: Animating with Mobile Accelerometers https://frontendmasters.com/blog/beyond-the-mouse-animating-with-mobile-accelerometers/ https://frontendmasters.com/blog/beyond-the-mouse-animating-with-mobile-accelerometers/#respond Fri, 09 Jan 2026 13:24:47 +0000 https://frontendmasters.com/blog/?p=8178 Adding user interactions is a powerful way to elevate a design, bringing an interface to life with subtle movements that follow the mouse and creating an effect that seemingly dances with the cursor.

I’ve done dozens of demos and written several articles exploring these exact types of effects, but one thing has always bothered me: the moment a user switches to a mobile device, that magic vanishes, leaving behind a static and uninspiring experience.

See: The Deep Card Conundrum

In a mobile-first world, we shouldn’t have to settle for these ‘frozen’ fallbacks. By leveraging the built-in accelerometers and motion sensors already in our users’ pockets, we can bridge this gap, breathing new life into our animations and creating a dynamic, tactile experience that moves with the user, literally.

A quick note before we jump in: while I usually recommend viewing my examples on a large desktop screen, the effects we are discussing today are purpose-built for mobile. So to see the magic in action, you’ll need to open these examples on a mobile device. A link to a full-page preview is provided below each demo.

Identifying the Environment

Before we dive into the code, let’s take the simple example above of the 3D effect, where the objects tilt and turn based on the cursor’s position. It creates a satisfying effect with a nice sense of depth on a desktop, but on mobile, it’s just a flat, lifeless image.

To bridge this gap, our code first needs to be smart enough to detect the environment, determine which interaction model to use, and switch between the mouse and the accelerometer at a reliable way.

While we could just check if the DeviceOrientationEvent exists, many modern laptops actually include accelerometers, which might lead our code to expect motion on a desktop. A more robust approach is to check for a combination of motion support and touch capabilities. This ensures that we only activate the motion logic on devices where it actually makes sense:

const supportsMotion = typeof window.DeviceMotionEvent !== 'undefined';
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;

if (supportsMotion && isTouchDevice) {
  // Initialize mobile motion sensors
  initMotionExperience();
} else {
  // Fallback to desktop mouse tracking
  initMouseFollow();
}

By making this distinction, we can tailor the experience to the hardware. If we detect a mobile device, we move to our first real hurdle: getting the browser’s permission to actually access those sensors.

You might be tempted to use the User Agent to detect mobile devices, but that is a slippery slope. Modern browsers, especially on tablets, often masquerade as desktop versions. By checking for specific features like touch support and sensor availability instead, we ensure our code works on any device that supports the interaction, regardless of its model or brand.

The Gatekeeper: Handling Permissions

Now that we’ve identified we are on a mobile device, you might expect the sensors to start streaming data immediately. However, to protect user privacy, mobile browsers (led by iOS) now require explicit user consent before granting access to sensor data.

This creates a split in our implementation:

  • The “Strict” Environment (iOS): Access must be requested via a specific method, and this request must be triggered by a “user gesture” (like clicking a button).
  • The “Open” Environment (Android & Others): The data is often available immediately, but for consistency and future-proofing, we should treat the permission flow as a standard part of our logic.

The best way to handle this is to create a “Start” or “Enable Motion” interaction. This ensures that the user isn’t startled by sudden movements and satisfies the browser’s requirement for a gesture. Here is a clean way to handle the permission flow for both scenarios:

// call on user gesture
async function enableMotion() {
  // Check if the browser requires explicit permission (iOS 13+)
  if (typeof DeviceOrientationEvent.requestPermission === "function") {
    try {
      const permissionState = await DeviceOrientationEvent.requestPermission();

      if (permissionState === "granted") {
        window.addEventListener("devicemotion", handleMotion);
      } else {
        console.warn("Permission denied by user");
      }
    } catch (error) {
      console.error("DeviceMotion prompt failed", error);
    }
  } else {
    // Non-iOS devices or older browsers
    window.addEventListener("devicemotion", handleMotion);
  }
}

By wrapping the logic this way, your app stays robust. On Android, the event listener attaches immediately. On iOS, the browser pauses and presents the user with a system prompt. Once they click “Allow,” the magic begins.

Understanding Mobile Motion Sensors

Now that we know we’re on mobile and have the necessary permissions, we start receiving data. This information comes from a set of motion sensors found in almost every smartphone.

In the browser, these sensors are exposed through two main APIs: DeviceOrientation, which provides the absolute physical orientation of the device (its position in space), and DeviceMotion, which provides real-time data about the device’s acceleration and rotation.

For the first step, we want to focus on the movement itself, so we will start with the DeviceMotion API. This API provide us with two distinct types of data:

  • Linear Motion (Acceleration): This measures forces along the three primary axes: X, Y, and Z. It’s what detects when you are shaking the phone, dropping it, or walking. Within this property we can access three values (xy, and z) that describe the change in acceleration along each specific axis.
  • Rotational Motion (Rotation Rate): This measures how fast the device is being tilted, flipped, or turned. This is where the magic happens for most UI effects, as it captures the “intent” of the user’s movement. The rotationRate property provides three values:
    • alpha: Rotation around the X axis, from front to back (tilting the top of the phone away from you).
    • beta: Rotation around the Y axis, from left to right (tilting the phone from side to side).
    • gamma: Rotation around the Z axis, perpendicular to the screen (spinning the phone on a table).

By listening to these rates of change, we can mirror the physical movement of the phone directly onto our digital interface, creating a responsive and tactile experience.

Mapping Motion to CSS Variables

Now that we are receiving a steady stream of data via our handleMotion function, it’s time to put it to work. The goal is to take the movement of the phone and map it to the same visual properties we used for the desktop version.

Inside the function, our first step is to capture the rotation data:

function handleMotion(event) {
  const rotation = event.rotationRate;
}

Now we can map the Alpha, Beta, and Gamma values to CSS variables that will rotate our rings.

In the desktop implementation, the rings responds to the mouse using two CSS variables: --rotateX and --rotateY. To support mobile, we can simply “piggyback” on these existing variables and add --rotateZ to handle the third dimension of movement.

Here is how the logic splits between the two worlds:

// Desktop: Mapping mouse position to rotation
window.addEventListener('mousemove', (event) => {
  rings.style.setProperty('--rotateX', `${event.clientY / window.innerHeight * -60 + 30}deg`);
  rings.style.setProperty('--rotateY', `${event.clientX / window.innerWidth * 60 - 30}deg`);
});

// Mobile: Mapping rotation rate to CSS variables
function handleMotion(event) {
  const rotation = event.rotationRate;
    
  // We multiply by 0.2 to dampen the effect for a smoother feel. 
  // A higher number will make the rotation more intense.
  // Notice that the Y-axis is multiplied by a negative number to align with physical movement.
  rings.style.setProperty('--rotateX', `${rotation.alpha * 0.2}deg`);
  rings.style.setProperty('--rotateY', `${rotation.beta * -0.2}deg`);
  rings.style.setProperty('--rotateZ', `${rotation.gamma * 0.2}deg`);
}

By multiplying the values by 0.2, we “calm down” the sensor’s sensitivity, creating a more professional and controlled animation. Feel free to experiment with this multiplier to find the intensity that fits your design.

The final step is updating the CSS. Since --rotateX and --rotateY are already in use, we just need to add the Z-axis rotation:

.rings {
  position: relative;
  transform: 
    rotateX(var(--rotateX, 0deg)) 
    rotateY(var(--rotateY, 0deg)) 
    rotateZ(var(--rotateZ, 0deg));
  
  /* The transition is key for smoothing out the sensor data */
  transition: transform 0.4s ease-out;
}

Now that all the pieces are in place, we have a unified experience: elegant mouse-tracking on desktop and dynamic, motion-powered interaction on mobile.

(Demo above in a full page preview, for mobile.)

Adding Physical Depth with Acceleration

To take the effect even further, we can go beyond simple rotation. By using the acceleration property from the DeviceMotion event, we can make the object physically move across the screen as we move our hands.

Inside our handleMotion function, we’ll capture the acceleration data along the X, Y, and Z axes:

function handleMotion(event) {
  const rotation = event.rotationRate;
  const acceleration = event.acceleration;

  // Rotation logic (as before)
  rings.style.setProperty('--rotateX', `${rotation.alpha * 0.2}deg`);
  rings.style.setProperty('--rotateY', `${rotation.beta * -0.2}deg`);
  rings.style.setProperty('--rotateZ', `${rotation.gamma * 0.2}deg`);

  // Translation logic: moving the object in space
  rings.style.setProperty('--translateX', `${acceleration.x * -25}px`);
  rings.style.setProperty('--translateY', `${acceleration.y * 25}px`);
  rings.style.setProperty('--translateZ', `${acceleration.z * -25}px`);
}

By multiplying the acceleration by 25, we amplify the small movements of your hand into visible shifts on the screen.

Finally, we update our CSS to include the translate property. Notice that we use a slightly longer transition for the translation (0.7s) than for the rotation (0.4s). This slight mismatch creates a “lag” effect that feels more organic and less mechanical:

.rings {
  position: relative;
  
  /* Applying both motion and rotation */
  translate: 
    var(--translateX, 0px) 
    var(--translateY, 0px) 
    var(--translateZ, 0px);
    
  transform: 
    rotateX(var(--rotateX, 0deg)) 
    rotateY(var(--rotateY, 0deg)) 
    rotateZ(var(--rotateZ, 0deg));
  
  /* Different speeds for different movements create a more fluid feel */
  transition: 
    translate 0.7s ease-out, 
    transform 0.4s ease-out;
}

With these additions, our rings now not only tilt and spin with the phone’s movement but also shift position in 3D space, creating a rich, immersive experience that feels alive and responsive.

(Demo above in a full page preview, for mobile.)

The “Wobble” Factor: Tilt vs. Movement

One key distinction to keep in mind is how the experience differs conceptually between devices. On desktop, we are tracking position. If you move your mouse to the corner and stop, the rings stay tilted. The effect is absolute.

On mobile, by using the DeviceMotion, we are tracking movement. If you tilt your phone and hold it still, the rings will float back to the center, because the speed of rotation is now zero. The rings only react while the device is in motion.

This difference stems naturally from the different ways we interact with a desktop versus a mobile device. Actually, my experience shows that in most cases involving visual interactions, like card angles or parallax control, this “reactionary” behavior actually looks better. Despite the inconsistency with the desktop version, it simply feels more natural in the hand.

However, if your design strictly requires a static behavior where the element locks to the device’s angle (similar to the mouse position), that is not a problem. This is exactly what DeviceOrientation is for.

Using Device Orientation for Absolute Angles

Remember earlier when we mentioned DeviceOrientation provides the absolute physical orientation? This is the place to use it. First, in our setup and permission checks, we would switch from listening to devicemotion to deviceorientation.

window.addEventListener('deviceorientation', handleOrientation);

Then, inside our handler, the mapping changes:

function handleOrientation(event) {
  rings.style.setProperty('--rotateZ', `${event.alpha}deg`);
  rings.style.setProperty('--rotateX', `${event.beta}deg`);
  rings.style.setProperty('--rotateY', `${event.gamma * -1}deg`);
}

Pay close attention here: the mapping of Alpha, Beta, and Gamma to the X, Y, and Z axes is different in DeviceOrientation compared to DeviceMotion (WHY?!).

  • Alpha maps to the Z-axis rotation.
  • Beta maps to the X-axis rotation.
  • Gamma maps to the Y-axis rotation (which we again multiply by -1 to align the movement with the physical world).

Here is a demo using DeviceOrientation where we track the absolute angle of the device, creating a behavior that more closely mimics the desktop mouse experience.

(Demo of the above in a full page preview, for mobile.)

If you want the object to start aligned with the screen regardless of how the user is holding their phone, you can capture a baseOrientation on the first event. This allows you to calculate the rotation relative to that initial position rather than the absolute world coordinates.

let baseOrientation = null;

function handleMotion(event) {

  if (!baseOrientation) {
    baseOrientation = {
      alpha: event.alpha,
      beta: event.beta,
      gamma: event.gamma,
    };    
  }

  rings.style.setProperty('--rotateZ', `${event.alpha - baseOrientation.alpha}deg`);
  rings.style.setProperty('--rotateX', `${event.beta - baseOrientation.beta}deg`);
  rings.style.setProperty('--rotateY', `${(event.gamma - baseOrientation.gamma) * -1}deg`);
}

If you want to let the user re-center the view, you can easily reset the baseOrientation with a simple interaction:

rings.addEventListener('click', () => { baseOrientation = null; });

With this approach, you can create a mobile experience that feels both intuitive and consistent with your desktop design, all while leveraging the powerful capabilities of modern smartphones.

Demo above in a full page preview, for mobile. Please note that using absolute values can sometimes feel a bit jittery, so use it with caution.

Going Further: The Cube Portal

Here is an example borrowed from my last article. In this case, the phone’s angles are not only used to rotate the outer cube, but also to determine the inner perspective and its origin:

.card {
  transform:
    rotateX(var(--rotateX, 0deg))
    rotateY(var(--rotateY, 0deg))
    rotateZ(var(--rotateZ, 0deg));
}

.card-content {
  perspective: calc(cos(var(--rotateX, 0)) * cos(var(--rotateY, 0)) * var(--perspective));
  perspective-origin:
    calc(50% - cos(var(--rotateX, 0)) * sin(var(--rotateY, 0)) * var(--perspective))
    calc(50% + sin(var(--rotateX, 0)) * var(--perspective));
}

(Demo above in a full page preview, for mobile.)

Final Thoughts: Breaking the Fourth Wall

It is easy to treat mobile screens as static canvases, skipping the rich interactivity we build for desktop simply because the mouse is missing. But the devices in our pockets are powerful, sophisticated tools aware of their physical place in the world. And we can use it.

By tapping into these sensors, we do more than just “fix” a missing hover state. We break the fourth wall. We turn a passive viewing experience into a tactile, physical interaction where the user doesn’t just watch the interface, but influences it.

The technology is there. The math is accessible. The only limit is our willingness to experiment. So next time you build a 3D effect or a parallax animation, don’t just disable it for mobile. Ask yourself: “How can I make this move?”

Go ahead, pick up your phone, and start tilting.


Bonus: Bringing the Mobile Feel to Desktop

We spent a lot of time discussing how to adapt mobile behavior to desktop standards, but there are cases where we might want the opposite: to have the desktop experience mimic the dynamic, movement-based nature of the mobile version.

To achieve this, instead of looking at the mouse’s position, we look at its movement.

If you want to implement this, it is quite straightforward. We simply define a lastMousePosition variable and use it to calculate the CSS variables based on the difference between frames:

let lastMousePosition = null;

function initMouseFollow() {
  
  window.addEventListener('mousemove', (e) => {
    rings.style.setProperty('--rotateX', `${(e.clientY - lastMousePosition.y) / window.innerHeight * -720}deg`);
    rings.style.setProperty('--rotateY', `${(e.clientX - lastMousePosition.x) / window.innerWidth * 720}deg`);
    
    lastMousePosition.x = e.clientX;
    lastMousePosition.y = e.clientY;
  });
}

This creates an effect on desktop that responds to the speed and direction of the mouse, rather than its specific location.

(Demo above in a full page preview, for mobile.)

]]>
https://frontendmasters.com/blog/beyond-the-mouse-animating-with-mobile-accelerometers/feed/ 0 8178
Popover Context Menus with Anchor Positioning https://frontendmasters.com/blog/popover-context-menus-with-anchor-positioning/ https://frontendmasters.com/blog/popover-context-menus-with-anchor-positioning/#comments Thu, 08 Jan 2026 17:13:43 +0000 https://frontendmasters.com/blog/?p=8194 Tooltips are the classic use case for anchor positioning in CSS. You click a thing, and right next to it another thing opens up. That other thing could also be a menu. I mean — what’s a context menu aside from a tooltip with links, right?

I’ll illustrate this with some appropriately common and vague components, like a “Kebab” menu button within a “Card” component:

This video comes from the complete demo for this post.

The positioning of that menu happens via the magic of anchor positioning. Lemme show you all the HTML first, as that is interesting in it’s own right.

The HTML for the Menu Button & Menu

We’ll use a <button> to toggle the menu (duh!) and put the attributes on it to set up an invoker. Invokers are super cool, allowing us to open/close the menu without any JavaScript at all. Just these declarative instructions will do. The interestfor attribute is extra-new, allowing popover="hint" to work meaning we can even open the popover on hover/focus (which is kinda amazing that it can be done in HTML alone).

<button
  class="menu-toggle"

  <!-- interest invoker! -->
  command="toggle-popover"
  <!-- connect to id of the popover menu -->
  commandfor="card-menu"
  <!-- for "hint" type (open menu on hover) -->
  interestfor="card-menu"

  style="anchor-name: --card;"
>
  <span class="screenreader-only">Open Menu</span>
  <span class="aria-hidden">•••</span>
</button>

<!-- 
  These two things ⬆️⬇️ could be anywhere 
  in DOM, thanks to anchor positioning,
  but it's still probably smart to keep 
  them nearby for tabbing accessibility.
-->

<menu 
  popover="hint" 
  id="card-menu" 
  style="position-anchor: --card;"
>
  <li><button>This</button></li>
  <li><button>Little</button></li>
  <li><button>Piggy</button></li>
  <li><button>Went</button></li>
  <li><button>to Market</button></li>
</menu>

Did you know the <menu> tag is just an <ul>? I love that.

I put the style tag on both of those elements, naming an anchor and styling against that named anchor, because “in the real world” I think that will be extremely common. The rest of the CSS can happen in a CSS file, but those things will likely be in the HTML because a page is likely to have lots of context menus and they will all need –unique-names.

The typical/ideal situation.

The CSS for the Menu

The CSS for the button isn’t terribly interesting aside from the named anchor we already gave it, so we’ll skip over that (just make sure it has nice hover/focus styles).

The CSS for the menu is much more interesting because…

  • We get to use anchor positioning to put it right where we want, including fallbacks.
  • We can animate the opening and closing.

The menu is going to be properly closed to start, with display: none;. Normally this means we can’t animate it, but with modern CSS these days, we can!

menu {
  /* Already has this from the HTML */
  /* position-anchor: --card; */

  /* Put it in place */
  position-area: block-start span-inline-start;

  transition-property: translate, opacity, display, overlay;
  transition-duration: 0.45s;
  /* Important for swapping timing of when the display properly flips */
  transition-behavior: allow-discrete;

  /* OUTGOING style */
  opacity: 0;
  display: none;
  translate: 10px 0;

  &:popover-open {
    /* MENU OPEN style */
    opacity: 1;
    display: block;
    translate: 0 0;

    /* INCOMING style */
    @starting-style {
      opacity: 0;
      translate: 0 -20px;
    }
  }

That position-area is decently complex all in itself. You might think there are basically eight places to put it (four sides and four corners) but there is really sixteen as the direction it “spans” can be sort of reversed from the natural flow. Ours does!

The Positioning Fallbacks

This is actually why I originally made this demo and why I’m writing this article. I found the anchor positioning fallbacks to be very mind-bending. Now that I have the right solution in place, it seems more obvious, but it’s been a mental journey for me getting here 😵‍💫.

Why fallbacks? If we didn’t have them, we’d risk opening our menus in into areas where they are unusable. Like if the menu button was toward the top of the screen, our initial position-area has the menu opening upwards, and we’d be hosed:

A kebab menu button opened, displaying menu items including 'Went' and 'to Market', set against a colorful, blurred background.
The top edge here is the top of the browser window, and the menu options are cut off. We can’t read them or click them.

In this situation, what we want to happen is for the menu to open downwards instead. That’s pretty straightforward, we add:

menu {
  ...

  position-try: flip-block;
}

This is basically saying: if you need to and there is space, go ahead and flip the position in the block direction. So from top to bottom for us. So if we had that situation where it’s cutting off on top, it’ll flip to the bottom if it can. That works great:

But top and bottom aren’t the only places a menu like this could get cut off. The left and right edges of the browser window are just as plausible. So in my mind, we’d just do this:

position-try: flip-inline flip-block;

My mind: OK you have everything you need now, if you need to flip in the block direction, you’ve been given permission, and if you need to flip in the inline direction, you’ve also been given permission:

Spoiler: This is now how it works.

What I’ve written above actually says: If you’re getting cut off with your initial positioning, try flipping in both the inline and block directions and if and only if that is better, do it.

That’s not really what I want.

What I want is: If the initial positioning is cut off, try flipping in the block direction, if that doesn’t work, try flipping in the inline direction, if that doesn’t work, then try flipping in both directions. What we need to that is this:

position-try: flip-block, flip-inline, flip-block flip-inline;

The Most Useful position-try Incantation

I’m going to say this again, because I’ve always thought this is just how it should work naturally without even having to ask for it (but it doesn’t). If you want positioning fallbacks that attempt to flip all the different directions to best fit, do this:

.good-positioning-fallbacks {
  position-try: flip-block, flip-inline, flip-block flip-inline;
}

Behold, a menu that works no matter where it shows up in a browser window:

We Could Write it Longhand

There is also a special @position-try syntax which we could use to do the exact same thing, like…

position-try-fallbacks: 
  --flip-block, 
  --flip-inline, 
  --flip-both;

@position-try --flip-block {
  position-area: block-end span-inline-start;
}

@position-try --flip-inline {
  position-area: block-start span-inline-end;
}

@position-try --flip-both {
  position-area: block-end span-inline-end;
}

The advantage to doing it this way is those @rule blocks allow us to do more stuff when they “hit”, for example adjust the margin differently or change alignment. That’s certainly nice if you need it!

Demo

Here’s the demo page. Feel free to see a full page preview.

]]>
https://frontendmasters.com/blog/popover-context-menus-with-anchor-positioning/feed/ 1 8194
How to @scope CSS Now That It’s Baseline https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/ https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/#comments Mon, 05 Jan 2026 17:46:37 +0000 https://frontendmasters.com/blog/?p=8146 Firefox 146 now supports @scope in CSS, joining Chrome and Safari, meaning that it’s now supported in all major web browsers, earning it the “Baseline: Newly Available” tag.

This @scope at-rule defines a new scope context in CSS. The :scope pseudo-class represents the root of said context (otherwise known as the ‘scope root’), and all this means is that we have some new and exciting ways of writing and organizing CSS, so today I’ll demonstrate the different ways of using @scope and the benefits of each one.

You can use an @scope block in any CSS, be it within a stylesheet that you <link> up or a <style> block within HTML. In fact, the latter has some interesting properties we’ll get to.

Whichever way you use CSS, the rules are, by default, globally scoped. For example, in the demo below, even though the CSS rules are nested within the <main>, they apply to the whole document:

<header></header>

<main>
  <style>
    /* No scoping applied. Global scope. */
    header, footer {
      background: rgb(from green r g b / 30%);
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

<footer></footer>

Styles in a CSS stylesheet are also globally scoped by default.

@scope in a <style> Block

However, @scope can be used to limit the CSS to the ‘scope root’ (which in this case is <main>, because the <style> is a direct child of <main>). In addition, within the @scope at-rule, :scope selects this scope root:

<header></header>

<main>
  <style>
    @scope {
      /* Scope root */
      :scope { /* Selects the <main> */

        /* <header>/<footer> within scope root */
        header, footer {
          background: rgb(from green r g b / 30%);
        }
      }
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

<footer></footer>

If needed, we can narrow the scope, or actually, stop the scoping at a particular second selector. In the example below I’ve added a ‘scope limit’ — specifically, I’ve defined the scope as from <main> (implicitly) to <section>. The scope is non-inclusive, so :scope > * selects the <header> and <footer> (excluding <section>, since it’s outside of the scope). main and section don’t select anything as, again, they’re out-of-scope, but we can continue to select <main> by using :scope:

<main>
  <style>
    @scope to (section) {
      /* Selects nothing */
      main, section {
        color: red;
      }

      /* However, this selects <main> */
      :scope {
        font-weight: bold;
      }

      /* Selects scoped direct children */
      :scope > * {
        background: rgb(from green r g b / 30%);
      }

      /* This also works */
      & > * {
        background: rgb(from green r g b / 30%);
      }

      /* As does this */
      > * {
        background: rgb(from green r g b / 30%);
      }
    }
  </style>

  <header></header>
  <section></section>
  <footer></footer>
</main>

When you use the to keyword it is known as donut scope. In the image below you can see why (the rings of the donuts include the scope root and scope limit, but it’s what’s in the donut hole that’s actually included in the scope):

Nothing inside that <section> could be selected at all, because the “donut” stops there. That’s the hole in the donut.

The <style> element itself can be selected by the universal selector (*), so if you were to, for example, set display to anything other than none, the CSS would hilariously output as a raw string (but still work, somehow):

To get really weird, style the <style> element like you would a <pre> tag and add the contenteditable attribute!

Notably, you don’t need to use the :scope selector or an equivalent, that’s just helpful for clarity or adding specificity to the selector if needed.

<p>
  <style>
    @scope {
      color: red;
    }
  </style>
  
  I'll be red.
</p>

<p>I'll be whatever color is inherited.</p>

The potential benefits of scoping in a <style> block are:

  • HTML and CSS are kept together
  • No external resource to download (even if you load CSS asynchronously to stop it from render-blocking, which risks Cumulative Layout Shift anyway, external resources must be downloaded in full before they can be rendered, which isn’t ideal)
  • CSS always renders with the HTML, which means no Cumulative Layout Shift, and when not deferring non-critical CSS, is the best way to prioritize resources efficiently

Keep in mind that the CSS will output more times than is necessary if you’re reusing the component, which is very anti-DRY and why you’ll want to combine this type of scoped CSS with other types of CSS where appropriate. With that in mind, let’s talk about using @scope with internal and external CSS.

@scope in a CSS file

When using @scope with a CSS file, we must specify the scope root (and optionally the end scope) within the @scope at-rule manually, like this:

@scope (main) to (section) {
  > * {
    background: rgb(from green r g b / 30%);
  }
}

This is actually true for <style> blocks as well. If you specify the scope with a selector (with or without the to selector), it will behave the same way. The distinction with a <style> block is when you don’t specify a selector, the scope becomes the parent element.

The benefit of this method is that it’s DRY, so you won’t be repeating yourself. However, there are quite a few drawbacks:

  • CSS is a render blocking resource
  • Potential Cumulative Layout Shift (if loading external CSS asynchronously)
  • HTML and CSS aren’t kept together

Other ways to use :scope

What’s interesting is that, if we use :scope outside of @scope, it selects the global scope root, which in HTML is <html>:

/* Global scope root */
html { }

/* Selects the same thing */
:root { }

/* Selects the same thing! */
:scope { }

I don’t know why we’d select :scope instead of :root or html, but it makes sense that we can do so, and explains why :scope was supported before @scope.

:scope can also be used in the querySelector(), querySelectorAll(), matches(), and closest() JavaScript DOM APIs, where :scope refers to the element on which the method is called. Take the following HTML markup, for example:

<section>
  <div>
    Child div
    <div>Grandchild div</div>
  </div>
</section>

While trying to select the direct child <div> only:

  • section.querySelectorAll("div").forEach(e => e.style.marginLeft = "3rem") undesirably but expectedly selects both <div>s
  • section.querySelectorAll("> div").forEach(e => e.style.marginLeft = "3rem") doesn’t work (even though, as demonstrated earlier, > div would work in CSS)
  • Luckily, section.querySelectorAll(":scope > div").forEach(e => e.style.marginLeft = "3rem") targets the child div only, as desired
  • section.querySelectorAll("& > div").forEach(e => e.style.marginLeft = "3rem") also works, as it would in CSS

Fun fact, we can also use & instead of :scope:

& {
  /* Instead of html, :root, or :scope */
}

@scope (main) to (section) {
  & {
    /* Instead of :scope */
  }
}

A Well-Balanced Approach to Serving and Writing Scoped CSS

I was really looking forward to @scope, and it securing full browser support in the last minute of 2025 made it my feature of the year. Regardless of what types of websites you build, you’ll find all ways of implementing @scope quite useful, although I think you’ll often use all implementations together, in harmony.

It will depend on the type of website that you’re building and how much you want to balance CSS organization with web performance.

Personally, I like splitting CSS into reusable modules, including them as internal CSS using templating logic only when needed (e.g., forms.css on /contact), and then using in-HTML scoped <style>s for one-time or once-per-page components. That way we can avoid render-blocking external CSS without causing Cumulative Layout Shift (CLS) and still have fairly organized CSS. One thing to consider though is that CSS isn’t cached with these methods, so you’ll need to determine whether they’re worth that.

If you’re building heavy front-ends, caching external CSS will be better and fewer bytes overall, but you can totally serve CSS using all methods at once (as appropriate) and use @scope with all of them.

In any case though, the ultimate benefit is, of course, that we’re able to write much simpler selectors by defining new scope roots.

All in all, the future of CSS could look like this:

/* global.css */
body {
  color: #111;
}

section {
  background: #eee;

  h2 {
    color: #000;
  }
}


/* home.css */
@scope (section.home-only) {
  :scope {
    background: #111;

    h2 {
      color: #fff;
    }
  }
}
<!DOCTYPE html>
<html>

<head>
  <!-- Site-wide styles -->
  <link rel="stylesheet" href="global.css">

  <style>
    /* Reusable BUT critical/above-the-fold, so not for global.css */
    @scope (header) {
      height: 100vh;
    }

    /* Include home.css conditionally */
    {% if template.name == "index" %}{% render "home.css" %}{% endif %}
  </style>

</head>

<body>
  <header>
    <h1>Critical/above-the-fold content</h1>
  </header>

  <main>
    <section>
      <h2>Default section (styles from external CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section class="home-only">
      <h2>Home-only section (styles from internal CSS)</h2>
    </section>

    <section>
      <style>
        @scope {
          :scope {
            background: #f00;

            h2 {
              color: #fff;
            }
          }
      </style>

      <h2>Unique section (styles from in-HTML CSS)</h2>
    </section>

  </main>
</body>

</html>

This is a very simple example. If we imagine that section.home-only is a much more complex selector, @scope enables us to write it once and then refer to it as :scope thereafter.

]]>
https://frontendmasters.com/blog/how-to-scope-css-now-that-its-baseline/feed/ 2 8146
!important and CSS Custom Properties https://frontendmasters.com/blog/important-and-css-custom-properties/ https://frontendmasters.com/blog/important-and-css-custom-properties/#comments Thu, 01 Jan 2026 16:20:06 +0000 https://frontendmasters.com/blog/?p=8128 This just bit me the other day so I’m going to write it down. Again, as it’s surprised me before. I just think I can maybe explain it even more clearly this time.

CSS custom properties are super permissive in what values are valid. Like this is totally fine, and I sure it can get much weirder:

--whats-up: (👍🏻ᴗ _ᴗ)👍🏻;

So my brain extends that to think that this also is a complete valid value:

--color: orange !important;

Like the value of --color is orange !important;

But it’s not! The value is just orange and the declaration itself is “important”. Hopefully this graphic makes it even more clear:

A graphic explaining CSS custom properties, highlighting the difference between the value and the declaration, with emphasis on the statement '--color: orange !important;' and clarifications in colorful text.

This can come up when there are multiple declarations that apply to the same element. Normally specificity and source order help sort out which declaration wins, but just as !important always does, an !important declaration trumps those other things.

So say you have a:

<div class="greeting">Hello</div>

Then two selector blocks:

div {
  --color: red !important;
}

.greeting {
  --color: blue;
  color: var(--color);
}

Even though --color is set to blue right there next to where it is used with a higher-specificity selector, the greeting will actually be red. If !important became part of the value, blue would have won because the custom property declaration is more specific and would have won. But it’s the custom property declaration itself that is important-ized and thus the red value wins.

]]>
https://frontendmasters.com/blog/important-and-css-custom-properties/feed/ 1 8128
Toggle `position: sticky` to `position: fixed` on Scroll https://frontendmasters.com/blog/toggle-position-sticky-to-position-fixed-on-scroll/ https://frontendmasters.com/blog/toggle-position-sticky-to-position-fixed-on-scroll/#comments Wed, 24 Dec 2025 14:41:32 +0000 https://frontendmasters.com/blog/?p=8090 It’s quite an unusual look when you see an element glide along it’s parent element as position: fixed;, the slide right on out of it, as if the positoning of it somehow magically changes at just the right moment, to position: sticky;. This is exactly what we’re going to pull of here with the help of scroll-driven animation and scroll state queries.

Both sticky and fixed positioning are about locking an element to a point on screen where it stays stuck throughout scrolling. A sticky element is stuck within its scrollable ancestor, and a fixed element sticks to the viewport. Both great for user interfaces that have to be persistent, like alert banners. They also make for nice visual effects.

Switching between these two types of position can give the illusion of an element breaking out of its scrollable container while the user is scrolling the page. Here’s an example:

Let’s see the mechanism behind that change.

The Layout

<div class="scrollPort">

  <div class="visualBlock">
    <div class="stickyElement"></div>
  </div>

  <!-- more blocks -->

</div>
.scrollPort {
  /* etc. */

  overflow-y: auto;

  .visualBlock {
    /* etc. */

    .stickyElement {
      position: sticky;
      top: 40px;
    }
  }
}

The .scrollPort is a scroll container with a set of .visualBlocks that overflow the container. Each .visualBlock has a sticky element inside.

Sizing the Sticky Element

Fixed units for the dimensions of the sticky element won’t be a problem, but if they have to be relative, there are some precautions to take.

.visualBlock {
  /* etc. */
  
  container-type: inline-size;

  .stickyElement {
    /* etc. */
    
    /* Sets the width to 80% of the query container's (.visualBlock) width */
    width: 80cqw;

  }
}

We can’t use a percentage (like 80%) to size the sticky element relative to its parent, because the reference element for a percentage unit is its nearest parent, which changes when the element goes from sticky to fixed*.

*A fixed element’s reference point in a document flow is the viewport.

To use the same reference for relatively sizing the sticky element, even when it becomes fixed, use container query units:

  1. Establish the .visualBlock as an inline-size* query container
  2. Use cqw unit for .stickyElement’s width

*In horizontal writing, the width is along the inline axis.

With sizing done, we move onto the code to change the position value.

Method 1: Using Scroll-Driven Animation

We use CSS view() function to run a keyframe animation that’ll turn .stickyElement from sticky to fixed.

.visualBlock {
  /* etc. */

  --stickyPosition: sticky;

  animation: toFixed;
  animation-timeline: view(block 0% 100%);


  .stickyElement {
    /* etc. */
    
    position: var(--stickyPosition); /* previously, position: sticky; */
  }
}

@keyframes toFixed {
  to { 
    --stickyPosition: fixed; 
  }
}

The parts above:

  • --stickyPosition: sticky; — Set a CSS variable in .visualBlock with an initial value of sticky. This value is used by .stickyElement to set its position.
  • animation: toFixed; — Apply the CSS animation toFixed (explained later) to .visualBlock.
  • animation-timeline: view(block 0% 100%); — The animation’s progress is based on .visualBlock’s visibility within .scrollPort. It starts when .visualBlock scrolls into view (0%) and ends (100% progress) when it scrolls out of view.
  • toFixed — At the end* (to) of the animation progress set --stickyPosition to fixed.

*The position CSS property is discrete. When animated, it changes from its start to end value halfway through the animation.

We’re not done yet, but here’s how it works when toFixed animation is applied through view():

A couple of things to take care of. First, when .stickyElement turns fixed it shifts slightly, since its top is no longer relative to .visualBlock. Needs reassigning the correct top value to prevent the shift.

Second, .stickyElement reverts to sticky when its .visualBlock goes off-screen, which is too soon since we want it to reach the next .stickyElement. Time to expand the area tracked for the view timeline to include the space between .visualBlocks and above .stickyElement.

I’ll keep these values is CSS variables for ease of update.

.scrollPort {
  /* etc. */

  container-type: size;

  .visualBlock {
    /* etc. */

    --visualBlockMargin: 60px;
    --stickyPosition: sticky;
    --stickyMarginTop: 50px;
    --stickyTopTemp: 40px;
    --stickyTop: var(--stickyTopTemp);

    margin: var(--visualBlockMargin) auto; 
    /* the space between .visualBlocks */

    animation: toFixed;
    animation-timeline: view(block calc(-1 * (var(--visualBlockMargin) + var(--stickyMarginTop))) 100%);
    /* includes the space above .visualBlock and .stickyElement */

    .stickyElement {
      /* etc. */

      margin: var(--stickyMarginTop) auto auto; 
     /* the space above .stickyElement */

      position: var(--stickyPosition);
      top: var(--stickyTop);
    }
  }
}

@keyframes toFixed {
  to {
    --stickyPosition: fixed;
    --stickyTop: calc(50vh - 50cqh + var(--stickyTopTemp) - var(--stickyMarginTop));
    /* includes the space above .scrollPort and .stickyElement */
  }
}

Negative inset values in view() expand the element’s visibility range outward from the boundary edges.

Here’s the result:

This is the method used in our first example, shown at the beginning of the article.

Method 2: Using Scroll State Queries

The second method, using scroll state queries, is the most efficient way to achieve what we want. The only downside is that scroll state queries are not widely supported by browsers yet.

We don’t need a keyframe animation for this one. What we need is a sticky scroll state container.

<div class="scrollPort">

  <div class="visualBlock">
    <div class="stickyWrapper">
      <div class="stickyElement"></div>
    </div>
  </div>

  <!-- more visual blocks -->

</div>
.stickyWrapper {
  /* etc. */

  container-type: scroll-state;

  position: sticky;
  --stickyTop: 40px;
  top: var(--stickyTop);

  .stickyElement {
      /* etc. */
   }
}

A scroll state container lets its descendants use scroll state queries to apply styles based on the container’s scrolling state.

That’s why we use a .stickyWrapper to provide the sticky positioning and be used as the scroll state query container.

When .stickyWrapper gets stuck, we’ll turn its child, .stickyElement, to fixed.

@container scroll-state(stuck: top) {
  .stickyElement {
    position: fixed;
    top: calc(50vh - 50cqh + var(--stickyTop));
  }
}

Here’s how it looks:

As you can see, this method requires much less code in CSS. But since view() is widely supported at the moment, compared to scroll state queries, it’s good to have the first method available, too. Choose whichever method or design you want. The key for this to work is to simply maintain the right size and position for the element when it shifts back and forth between its sticky and fixed behavior to look like it’s moving between the visual blocks.

Uses and Variants

If there’s a visual element that’s not to be unnecessarily shown to the user right off the bat, but once shown could be useful to keep it on screen, toggling its position like the examples in this post might do the trick. It could be a call-to-action button, or a banner, or it could be graphics moving between slides in a presentation once a particular slide is shown.

On top of the position change, if other visual changes are layered, that opens up even more variations for how this can play out.

As mentioned before, focus on where and how you want the element to appear when its sticky and when its fixed, for the desired effect to come through as the position changes on scroll.

]]>
https://frontendmasters.com/blog/toggle-position-sticky-to-position-fixed-on-scroll/feed/ 4 8090
Thoughts on Native CSS Mixins https://frontendmasters.com/blog/thoughts-on-native-css-mixins/ https://frontendmasters.com/blog/thoughts-on-native-css-mixins/#respond Thu, 11 Dec 2025 22:29:27 +0000 https://frontendmasters.com/blog/?p=8020 I have some notes from various times I’ve thought about the idea of native CSS mixins so I figured I’d get ’em down on (digital) paper!

For the record, they don’t really exist yet, but Miriam Suzanne says:

The CSS Working Group has agreed to move forward with CSS-native mixins.

And there is a spec, but the spec only deals with @function (which does exist). Functions are a little similar but act only as a single value rather than a block of styles.

The idea comes from Sass @mixin.

We happen to use Sass (SCSS) at CodePen and as I write, we have 328 @mixin definitions in the codebase, so it’s clearly of use.

Here’s a practical-if-basic example:

@mixin cover {
  position: absolute;
  inset: 0;
}

In Sass, that doesn’t compile to anything. You have to use it. Like:

.modal-overlay {
  @include cover;
}

.card.disabled {
  &::before {
    @include cover;
    background: lch(0% 0 0 / 0.8);
  }
}

See how I’ve used it twice above. Compiled Sass will dump in the contents of the mixin in both places:

.modal-overlay {
  position: absolute;
  inset: 0;
}

.card.disabled {
  &::before {
    position: absolute;
    inset: 0;
    background: lch(0% 0 0 / 0.8);
  }
}

Things can get a little fancier in Sass, but it’s all pretty straightforward:

  • Mixins can include nesting and work in nested code. They can even slot in nested content you pass to it.
  • Mixins can use other mixins.
  • Mixins can have parameters (like a function) and use/calculate off those values in the output.

I would assume and hope that all of this is supported in native CSS mixins. The native version, as explained so far on Miriam’s site (which will almost definitley change!), the only difference is the usage syntax:

@mixin --cover {
  position: absolute;
  inset: 0;
}
.modal-overlay {
  @apply --cover;
}

I imagine it’s @apply instead of @include literally because Sass uses @include and Sass would have a hard time “leaving it alone” when processing down to CSS.

Is there enough here for browsers/standards to actually do it?

The W3C CSS Working Group has already OK’d the idea of all this, so I assume it’s already been determined there is value to native CSS having this ability at all. But what are those reasons?

  • Not having to reach for a preprocessor tool like Sass. I don’t think this is enough of a reason all by itself for them, but personally, I do. This is a paved cowpath, as they say.
  • Preprocessor output has potentially a lot of duplicate code. This leads to bigger CSS files. Perhaps not a huge issue with gzip/brotli in play, but still, smaller files is almost always good.
  • Integration with --custom-properties. I would think the parameters could be custom properties and there could be custom properties used generally with the style block. Custom properties can change dynamically, causing re-evaluated styles, so mixins can become more powerful expressions of style based on a comparatively small custom property change.
  • Custom Properties can cascade and be different values at different points in the DOM, so mixins might also do different things at different points in the DOM. All this custom property stuff would be impossible in a preprocessor.
  • It’s a nicer API than faking it with @container style(). You can test a custom property with a style query and dump out styles in certain places now, but it doesn’t feel quite right.

I wonder what else tipped the scales toward the working group doing it.

Parameter handling seems tricky.

You can pass things to a mixin, which I think is pretty crucial to their value.

@mixin --setColors(--color) {
  color: var(--color);
  background-color: oklch(from var(--color) calc(l - 40%) c h / 0.9);
}

But things can get weird with params. Like what happens if you call setColors() with no params? Does it just fail and output nothing?

.card {
  @apply --setColors(); /* ??? */
}

It’s possible --color is set anyway at the cascade level it’s being used at, so maybe it has access to that and outputs anyway? I assume if --color is set at the same cascade level and the param is passed, the param value wins? How does !important factor in?

And what about typed params? And default values? Seems doable but quite verbose feeling, especially for CSS. Is it like…

@mixin --setColors(
  --color type(color): red
) {
  color: var(--color);
  background-color: oklch(from var(--color) calc(l - 40%) c h / 0.9);
}

Maybe like that? I’m not sure what the syntax limitations are. Or maybe we don’t need default values at all because the var() syntax supports fallbacks already?

Feels like it could open up a world of more third-party CSS usage.

Imagine CSS carousels. They are so cool. And they are quite a bit of CSS code. Perhaps their usage could be abstracted into a @mixin.

The jQuery days were something like this pseudo-code:

// <script src="/plugins/owl-carousel.js"></script>
$(".owl-carousel").owlCarousel({
  gap: 10, 
  navArrows: true,
  navDots: true
});

Which morphed into JavaScript components:

@import SlickCarousel from "slickcarousel";

<SlickCarousel
  gap="10"
  navArrows={true}
  navDots={true}
/>

Maybe that becomes:

@import "/node_modules/radcarousel/carousel.css";

.carousel {
  @apply --radCarousel(
    --gap: 10px,
    --navArrows: true,
    --navDots: true
  );
}

The jQuery version was DIY HTML and this would be too. You could call that SSR for free, kids.

What about “private” variables?

I sort of remember Miriam talking about this at CSS Day this past year. I think this was the issue:

@mixin --my-thing {
  --space: 1rem;
  gap: var(--space);
  margin: var(--space);
}

.card {
  @apply --my-thing;
  padding: var(--space); /* defined or not? */
}

The question is, does that --space custom property “leak out” when you apply the mixin and thus can be used there? It either 1) does 2) doesn’t 3) some explicit syntax is needed.

I can imagine it being useful to “leak” (return) them, so say you wanted that behavior by default, but the option to not do that. Maybe it needs to be like…

@mixin --my-thing {
  @private {
    --space: 1rem;
  }
  gap: var(--space);
  margin: var(--space);
}

Don’t hate it. Miriams post also mentions being more explicit about what is returned like using an @output block or privatizing custom properties with a !private flag.

What about source order?

What happens here?

@mixin --set-vars {
  --papaBear: 30px;
  --mamaBear: 20px;
  --babyBear: 10px;
}

.card {
  --papaBear: 50px;
  @apply --set-vars;
  margin: var(--papaBear);
}

What margin would get set here? 50px because it’s set right there? 30px because it’s being overridden by the mixin? What if you reversed the order of the first two lines? Will source order be the determining factor here?

Are Custom Idents required?

All the examples use the --my-mixin style naming, with the double-dashes in front, like custom properties have. This type of using is called a “custom ident” as far as I understand it. It’s what custom functions are required to use, and they share the same spec, so I would think it would be required for mixins too.

/* 🚫 */
@mixin doWork {
}

/* ✅ */
@mixin --doWork {
}

Is this just like the way forward for all custom named things forever in CSS? I think it’s required for anchor names too, but not container names? I wish it was consistent, but I like backwards compatibility better so I can live.

Wouldn’t it be better if it was required for keyframes, for example? Like if you saw this code below, is it obvious what the user-named word is and what other things are language syntax features?

.leaving {
  animation: slide 0.2s forwards;
}

It’s slide here, so you’d have to go find it:

@keyframes slide {
  to { translate: -200px 0; }
}

To me it would be much more clear if it was:

.leaving {
  animation: --slide 0.2s forwards;
}
@keyframes --slide {
  to { translate: -200px 0; }
}

Annnnnnnd there is nothing really stopping us from doing that so maybe we should. Or take it one step further and adopt an emoji naming structure.

Calling Multiple Mixins

Would it be like?

@apply --mixin-one, --mixin-two;

Maybe space-separated?

@apply --mixin-one --mixin-two;

Or that is weird? Maybe you just gotta do it individually?

@apply --mixin-one;
@apply --mixin-two;

Does it matter?

Functions + Mixins

It seems to make sense that a mixin could call a function…

@mixin --box {
  gap: --get-spacing(2);
  margin-trim: block;
  > * {
    padding: --get-spacing(4);
  }
}

But would it be forbidden the other way around, a function calling a mixin?

@function --get-spacing(--size) {
  @apply get-vars(); /* ??? */
  result: 
    if (
      style(--some-other-var: xxx): 3rem;
      style(--size: 2): 1rem;
      style(--size: 4): 2rem;
      else: 0.5rem;
    )
}

Or is that fine?

Infinite Loops

Is it possible this opens up infinite loop problems in calculated styles? I don’t know if this is an actual problem but it’s brain-bending to me.

@mixin --foo(--val) {
  --val: 2;
}

.parent {
  --val: 1;
  .thing {
    @apply --foo(--val);
    --val: if(
        style(--val: 1): 2;
        else: 1;
      );
  }
}

Like, when evaluating a .thing, --val is 1 because of inheritance, but then we apply a mixin which changes it to 2, then we reset it back to 1, but if it’s 1 shouldn’t it reevaluate to 2? I just don’t know.

Unmixing

Miriam asks can you un-mix a mixin? Which is a great question. It’s very worth thinking about, because if there ends up being an elegant way to do it, it makes native mixins even more powerful and a big feather in their cap above what any preprocessor can do. I don’t hate an @unapply at first thought.

Thoughts?

Are you stoked for native mixins? Against it? Worried?

]]>
https://frontendmasters.com/blog/thoughts-on-native-css-mixins/feed/ 0 8020
CSS Wrapped 2025 https://frontendmasters.com/blog/css-wrapped-2025/ https://frontendmasters.com/blog/css-wrapped-2025/#respond Tue, 09 Dec 2025 00:32:02 +0000 https://frontendmasters.com/blog/?p=7978 Banger page from the Chrome DevRel team showcasing the incredible year CSS had.

]]>
https://frontendmasters.com/blog/css-wrapped-2025/feed/ 0 7978
The Deep Card Conundrum https://frontendmasters.com/blog/the-deep-card-conundrum/ https://frontendmasters.com/blog/the-deep-card-conundrum/#comments Thu, 04 Dec 2025 19:30:21 +0000 https://frontendmasters.com/blog/?p=7957 In the world of web design, we often talk about “cards”. Those neat little rectangles that group information together are the bread and butter of modern UI. But usually, these cards are as flat as the screens they live on. Maybe they have a subtle drop shadow to hint at elevation, but that’s where the illusion ends.

But what if a card wasn’t just a surface? What if it was a window?

Enter the Deep Card.

Imagine a card that isn’t just a 2D plane, but a container with actual volume. A card that holds a miniature 3D world inside it. When you rotate this card, you don’t just see it skew, you see the elements inside it shift in perspective, revealing their depth. It’s like holding a glass box filled with floating objects.

The effect is mesmerizing. It transforms a static element into something tactile and alive. It invites interaction. Whether it’s for a digital trading card game, a premium product showcase, or just a portfolio piece that screams “look at me,” the Deep Card adds a layer of polish and “wow” factor that flat design simply can’t match.

But as I quickly discovered, building this illusion, especially one that feels right and performs smoothly, is a bit more of a puzzle than it first appears.

The CSS Trap

There are plenty of JavaScript libraries out there that can handle this, but I’m a bit of a CSS purist (read: stubborn). I’ve spent years pushing stylesheets to their absolute limits, and I was convinced that a clean, performant, pure CSS solution was hiding in plain sight.

On paper, the logic seems flawless. If you’ve dabbled in 3D CSS before, you know the drill:

  1. Set the Stage: Take a container element and give it some perspective.
  2. Build the World: Position the child elements in 3D space (translateZrotateX, etc.).
  3. Preserve the Illusion: Apply transform-style: preserve-3d so all those children share the same 3D space.

Simple, right?

But here’s the catch. For a true “card” effect, you need the content to stay inside the card boundaries. If a 3D star floats “up” towards the viewer, you don’t want it to break the frame, you want it to be clipped by the card’s edges, reinforcing the idea that it’s inside a container.

So, naturally, you add overflow: clip (or hidden) to the card. And that is the exact moment everything falls apart.

Comparison of overflow properties in CSS: left shows 'overflow: visible;' with layered rectangles, right shows 'overflow: clip;' with clipped edges.

The Spec Says No

Suddenly, your beautiful 3D scene flattens out. The depth vanishes. The magic is gone.

Why? Because according to the CSS Transforms Module Level 2 specification, applying any “grouping property” like overflow (with any value other than visible), opacity less than 1, or filter, forces the element to flatten.

The sad reality: A value of preserve-3d for transform-style is ignored if the element has any grouping property values.

In other words: you can have a 3D container, or you can clip its content. You cannot do both on the same element.

For a long time, this felt like a dead end. How do you keep the 3D depth while keeping the elements contained?!

Faking It

If the spec says we can’t have both perspective and clipping, maybe we can cheat. If we can’t use real 3D depth, perhaps we can fake it.

Faking perspective is a time-honored tradition in 2D graphics. You can simulate depth by manipulating the size and position of elements based on their “distance” from the viewer. In CSS terms, this means using scale() to make things smaller as they get “further away” and translate() to move them relative to the card’s angle.

.card {
  /* --mouse-x and --mouse-y values ranage from -1 to 1 */
  --tilt-x: calc(var(--mouse-y, 0.1) * -120deg); 
  --tilt-y: calc(var(--mouse-x, 0.1) * 120deg); 
  transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}

.card-layer {
  /* Fake perspective with scale and translate */
  scale: calc(1 - var(--i) * 0.02);
  translate:
    calc(var(--mouse-x) * (var(--i)) * -20%)
    calc(var(--mouse-y) * (var(--i)) * -20%);
}

This technique can work wonders. There are some brilliant examples out there, like this one by Jhey, that pull off the effect beautifully without using a single line of perspective or preserve-3d.

It’s a solid approach. It’s performant, it works across browsers, and for subtle effects, it’s often indistinguishable from the real thing.

But it has a ceiling.

The illusion holds up well within a limited range of motion. But the moment you push it too far, say, by rotating the card to a sharp angle or trying to flip it 180 degrees, the math starts to show its cracks. The perspective flattens out, and the movement stops feeling natural.

As you can see, when the card turns, the inner elements lose their spatial relationship. The magic evaporates. So while this is a great tool for the toolbox, it wasn’t the complete solution I was looking for. I wanted the real deal. Full 3D, full rotation, full clipping.

Road to a Nowhere

I spent years (on and off, I’m not that obsessed) trying to crack this. I was convinced there had to be a way to have my cake and eat it too.

Theoretically, there is a way. If you can’t clip the container, you have to clip the children.

Imagine using clip-path on every single layer inside the card. You would need to calculate, in real-time, exactly where the edges of the card are relative to the viewer, and then apply a dynamic clipping mask to each child element so that it cuts off exactly at those boundaries.

This involves a lot of math, even for me. We’re talking about projecting 3D coordinates onto a 2D plane, calculating intersections, and handling the trigonometry of the user’s perspective.

A textured blackboard covered with various mathematical equations, diagrams, and symbols, creating a complex academic backdrop.

I was almost ready to give up and accept that maybe, just maybe, this was one of those things CSS just wasn’t meant to do. And then, I got a message from Cubiq.

The Breakthrough

This wasn’t the first time someone had asked me about this topic. As someone who’s known for pushing CSS 3D to its limits, I get this question a lot. People assume I have the answer. But, well… I didn’t.

So when Cubiq messaged me, showing me a GIF of a fully rotating card with deep 3D elements and asking how to achieve it, I went into auto-pilot. I gave him the standard explanation on why the spec forbids it, why overflow flattens the context, and how he could try to fake it with scale and translate.

I thought that was the end of it, but then, he surprised me.

A screenshot of a messaging app conversation featuring a user's message expressing excitement about a discovery.

My Personal Blind Spot

I’ve tried many tricks over the years, but one property I religiously avoided was perspective-origin.

If you really dig into how CSS calculates perspective, you realize that perspective-origin doesn’t just shift your point of view. It fundamentally skews the entire viewport. It creates this weird, unnatural distortion that usually looks terrible.

I cover this at length in my talk 3D in CSS, and the True Meaning of Perspective, if you’re into that sort of thing.

Cubiq, however, didn’t have my baggage. He looked at the problem with fresh eyes and realized something brilliant: just as perspective-origin can be used to create distortion, it can also be used to correct it.

Alternate blog post title idea: Finally, we found one good use for perspective-origin! 🤣

The Solution

Here is the magic formula that Cubiq came up with:

.card-container {
  transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}

.card {
  perspective: calc(
    cos(var(--tilt-x)) * cos(var(--tilt-y)) * var(--perspective)
  );
  perspective-origin: 
    calc(cos(var(--tilt-x)) * sin(var(--tilt-y)) * var(--perspective) * -1 + 50%)
    calc(sin(var(--tilt-x)) * var(--perspective) + 50%);
  overflow: clip;
}

It looks a bit scary at first glance, but the logic is actually quite elegant.

Since we are using overflow: clip, the 3D context is flattened. This means the browser treats the card as a flat surface and renders its children onto that surface. Normally, this flattening would kill the 3D effect of the children. They would look like a flat painting on a rotating wall.

But here is the trick: We use perspective and perspective-origin to counter-act the rotation.

By dynamically calculating the perspective-origin based on the card’s tilt, we are essentially telling the browser: “Hey, I know you flattened this element, but I want you to render the perspective of its children as if the viewer is looking at them from this specific angle.”

We are effectively projection-mapping the 3D scene onto the 2D surface of the card. The math ensures that the projection aligns perfectly with the card’s physical rotation, creating the illusion of a deep, 3D space inside a container that the browser considers “flat.”

It’s not about moving the world inside of the card, it’s about tricking the flat projection to look 3D by aligning the viewer’s perspective with the card’s rotation.

The Lesson

I love this solution not just because it works (and it works beautifully), but because it taught me a humbling lesson.

I had written off perspective-origin as a “bad” property. I had a mental block against it because I only saw it as a source of distortion. I was so focused on the “right” way to do 3D, that I blinded myself to the tools that could actually solve the problem.

Cubiq didn’t have that bias. He saw a math problem: “I need the projection to look like X when the rotation is Y.” And he found the property that controls projection.

Breaking It Down

Now that we know it’s possible, let’s break down exactly what’s happening here, step by step, and look at some examples of what you can do with it. Let’s start with the basics.

The HTML

At its core, the structure is simple. We have a .card-container that holds the .card, which in turn contains the .card-content, that is the ‘front’ of the card and where all the inner layers live. and the card-back for the back face.

<div class="outer-container">
  <div class="card">
    <div class="card-content">
      <!-- Inner layers go here -->
    </div>
    <div class="card-back">
      <!-- Back face content -->
    </div>
  </div>
</div>

Inside the .card-content, we can now add .card-layers with multiple layers in it. Here I’m setting a --i custom property on each layer to later control its depth.

<div class="card-layers">
  <div class="card-layer" style="--i: 0"></div>
  <div class="card-layer" style="--i: 1"></div>
  <div class="card-layer" style="--i: 2"></div>
  <div class="card-layer" style="--i: 3"></div>
  <!-- more layers as needed -->
</div>

Now we can fill each layer with content, images, text, or whatever we want.

The Movement

To create the rotation effect, we need to track the mouse position and convert it into tilt angles for the card. So the first thing we need to do is to map the mouse position into two CSS variables, --mouse-x and --mouse-y.

This is done with few simple lines of JavaScript:

const cardContainer = document.querySelector('.card-container');

window.addEventListener('mousemove', (e) => {
  const rect = cardContainer.getBoundingClientRect();
  const x = (e.clientX - rect.left) / rect.width * 2 - 1;
  const y = (e.clientY - rect.top) / rect.height * 2 - 1;
  cardContainer.style.setProperty('--mouse-x', x);
  cardContainer.style.setProperty('--mouse-y', y);
});

This gives us normalized values between -1 and 1 on each axis, so we can use them regardless of the card size or aspect ratio.

We convert these values to --tilt-x and --tilt-y in CSS, by multiplying them by the number of degrees we want the card to rotate:

--tilt-x: calc(var(--mouse-y, 0.1) * -120deg);
--tilt-y: calc(var(--mouse-x, 0.1) * 120deg);

The higher the degree value, the more dramatic the rotation. 20–30 degrees will give us a subtle effect, while 180 degrees will spin the card all the way around.

Notice that --mouse-x affects --tilt-y, because movement of the mouse along the X axis should actually rotate the card around the Y axis, and vice versa. Also, we multiply --mouse-y by a negative number, because the Y axis on the screen is inverted compared to the mathematical Y axis.

Now that we have --tilt-x and --tilt-y, we can start using them. And first, we apply them to the card container to rotate it in 3D space:

.card {
  transform: rotateX(var(--tilt-x)) rotateY(var(--tilt-y));
}

This gives us the basic rotation effect. The card will now tilt and spin based on the mouse position.

The Perspective

We need to remember that we need to set two different perspectives: one for the card’s container (to create the 3D effect), and one for the card’s content (to maintain the depth of the inner elements).

on the .card-container we set a standard perspective:

.card-container {
  perspective: var(--perspective);
}

You can set --perspective to any value you like, but a good starting point is around 800px. Lower values will create a more dramatic perspective, while higher values will make it more subtle.

To preserve the 3D space and making sure all the inner elements share the same 3D context, we set transform-style: preserve-3d. I’m using the universal selector here to apply it to all children elements:

* {
  transform-style: preserve-3d;
}

To deal with the inner perspective, we set up the perspective and perspective-origin on the .card-content element, which holds all the inner layers:

.card-content {
  perspective: calc(
    cos(var(--tilt-x)) * cos(var(--tilt-y)) * var(--perspective)
  );
  perspective-origin: 
    calc(cos(var(--tilt-x)) * sin(var(--tilt-y)) * var(--perspective) * -1 + 50%)
    calc(sin(var(--tilt-x)) * var(--perspective) + 50%);
  overflow: clip;
}

Note that we added overflow: clip to the .card-content to ensure that the inner elements are clipped by the card boundaries. This combination of perspective, perspective-origin, and overflow: clip is what allows us to maintain the 3D depth of the inner elements while keeping them contained within the card.

The Depth

Now that we have the rotation and perspective set up, we can start adding depth to the inner layers. Each layer will be positioned in 3D space using translateZ, based on its --i value.

.card-layer {
  position: absolute;
  transform: translateZ(calc(var(--i) * 1rem));
}

This will space out the layers along the Z axis, creating the illusion of depth. You can adjust the multiplier (here 1rem) to control how far apart the layers are.

Putting It All Together

Using the techniques outlined above, we can create a fully functional Deep Card that responds to mouse movement, maintains 3D depth, and clips its content appropriately.

Here is a complete ‘boilerplate’ example:

You can customize it to your needs, set the number of layers, their depth, and add content within each layer to create a wide variety of Deep Card effects.

Getting Deeper

To improve the Deep Card effect and further enhance the perception of depth, we can add shadows and darkening effects to the layers.

One way to achieve darker colors is just using darker colors. We can calculate the brightness of each layer based on its depth, making deeper layers darker to simulate light falloff.

.card-layer {
  color: hsl(0 0% calc(100% - var(--i) * 9%));
}

Another technique is to add semi-transparent background to each layer. This way each layer is like screen that slightly darkens the layers behind it, enhancing the depth effect.

.card-layer {
  background-color: #2224;
}

Here is an example of a two cards with different effects: The first card uses darker colors for deeper layers, while the second card uses semi-transparent overlays to create a more pronounced depth effect.

Choose the one that fits your design best, or combine both techniques for an even richer depth experience.

The z-index Effect

You might notice that I’m placing all the layers inside a container (.card-layers) rather than making them direct children of .card-content. The reason is that since we’re moving the layers along the Z axis, we don’t want them to be direct children of an element with overflow: clip; (like .card-content).

As mentioned earlier, once you set overflow: clip; on .card-content, its transform-style becomes flat, which means all of its direct children are rendered on a single plane. Their stacking order is determined by z-index, not by their position along the Z axis. By wrapping the layers in a container, we preserve their 3D positioning and allow the depth effect to work as intended.

The Twist

Now that we understand this limitation, let’s turn it to our advantage and see what kinds of effects we can create with it.

Here are the exact same two cards as in the previous example, but this time without a .card-layers container. The layers are direct children of .card-content:

Adding Interaction

We often use cards that need to display extra information. One of my favorite things to do in these cases is to rotate the card 180 degrees and reveal the additional content on the back side. Now, we can do exactly that, and build an entire 3D world inside the card.

In this example, we have a front face (.card-content) and a back face (.card-back). When the user clicks the card, we toggle a checkbox that rotates the card 180 degrees, revealing the back face.

<label class="card-container">
  <input type="checkbox">
  <div class="card">
    <div class="card-content">
      <!-- front face content -->
    </div>
    <div class="card-back">
      <!-- back face content -->
    </div>
  </div>
</label>
.card-container {
  cursor: pointer;
    
  &:has(input:checked) .card {
    rotate: y 180deg;
  }
  
  input[type="checkbox"] {
    display: none;
  }
}

You can also use a button or any other interactive element to toggle the rotation, depending on your use case, and use any animation technique you like to make the rotation smooth.

Inner Movement

Of course, we can also use any animation on the inner layers to create dynamic effects. It can be wild and complex, or subtle and elegant. The key is that since the layers are in 3D space, any movement along the Z axis will enhance the depth effect.

Here a simple example with parallax layers. each layer animates it’s background position on the X axis, and to enhance the depth effect, I’m animating the layers at different speeds based on their depth:

.card-layer {
  animation: layer calc(var(--i) * 8s) infinite linear;
}

And the result:

Deep Text Animation

This technique works beautifully with the concept of layered text, opening up a world of creative possibilities. There’s so much you can do with it, from subtle depth effects to wild, animated 3D lettering.

I actually wrote an entire article about this, featuring 20+ examples, and every single one of them looks fantastic inside a Deep Card. Here’s one of the examples from that article, now living inside a card:

Going Full 360

up until now, we’ve mostly focused on layering our inner content and using the Z axis to create depth. But we can definitely take it a step further, break out of the layering concept, and build a fully 3D object that you can spin around in all directions.

From here, the possibilities are truly endless. You can keep experimenting—add more interactions, more layers, or even create effects on both sides of the card to build two complete worlds, one on each face. Or, go all in and design an effect that dives deep into the card itself. The only real limit is your imagination.

Conclusion

The Deep Card is now a solved problem. We can have our cake (3D depth), eat it (clipping), and even spin it around 360 degrees without breaking the illusion.

So, the next time you hit a wall with CSS, and you’re sure you’ve tried everything, maybe take a second look at those properties you swore you’d never use. You might just find your answer hiding in the documentation you skipped.

Now, go build something deep.

]]>
https://frontendmasters.com/blog/the-deep-card-conundrum/feed/ 3 7957
The Downsides of scrollbar-gutter: stable; (and one weird trick) https://frontendmasters.com/blog/the-downsides-of-scrollbar-gutter-stable-and-one-weird-trick/ https://frontendmasters.com/blog/the-downsides-of-scrollbar-gutter-stable-and-one-weird-trick/#comments Wed, 03 Dec 2025 16:22:36 +0000 https://frontendmasters.com/blog/?p=7892 The esteemed Zach (Leatherpants), blogged about the virtues of scrollbar-gutter: stable;, suggesting it might be good in a starter stylesheet. (Hey, I’ve got one of those.) The thing that it solves is content-shifting. If you have a page that, upon first render, doesn’t have a vertical scrollbar, but more content loads later and gets one, it can shift content horizontally to make room for the scrollbar, which is a smidge annoying. That’s assuming your OS setting has always visible scrollbars enabled. (I do in my macOS settings, I prefer it.)

Also, navigating from a page that doesn’t require vertical scrolling to one that does (or vice versa) causes a layout shift to the tune of one scrollbar width.

Using scrollbar-gutter: stable; (supported everywhere) means that the browser will “reserve space” for a scrollbar and thus totally remove the layout-shifting downsides mentioned above.

You can see the visual shift when we add content that overflows vertically. But if we flip on scrollbar-gutter: stable; the content stays steady when it goes back and forth between overflowing and not.

Notice in the video above though, the shifting-fix is accomplished by putting the space of the scrollbar there. You can see it’s quite literally a white bar. This only seems to happen when the page is rendered in an <iframe> like it is on CodePen, but I still find it highly obnoxious and a downside (as there is no way I’ve found to make it not a white, or dark-in-dark-mode, bar).

Here’s that demo:

Fortunately, the “literal white bar” problem isn’t there on regularly-rendered pages (outside of an iframe), as that would be an instant deal breaker.

The remaining problem is centering.

The space that is reserved for the maybe-maybe-not scrollbar cannot be factored into centering (like margin: auto; and whatnot).

Screenshot showing a content area with a gray background, below which there is a large white space indicating the absence of a vertical scrollbar, accompanied by red arrows pointing towards the white space.
😭

So if you really need to visually center something, it’ll be fine-ish if there is a scrollbar, and noticeably not-centered-looking if there isn’t. Boooo.

To me, this is just annoying enough to not put it in a starter stylesheet.

But!

Just for fun we could look at a newfangled CSS alternative. My big idea here is that we actually can tell if the page is overflowing and has a scrollbar or not these days. We can do this via scroll-state queries.

So we make the whole page a scroll-state container then watch for when it is scrollable and push the whole page in a little bit the same width as the scrollbar.

html {
  container-type: scroll-state;
  
  --scrollbar-width: 25px;
  &::--webkit-scrollbar {
    width: var(--scrollbar-width);
  }
}

body {
  @container scroll-state(scrollable: block) {
    padding-left: var(--scrollbar-width);
  }
}

Notice I’m attempting to wrestle control over the width of the scrollbar there using those non-standard vendor prefixes. But 25px is the generally standard width of the scrollbar anyway. But that could change if a user set something like scrollbar-width: thin; or something. Makes me wish there was an env() variable or something that always reflects the width of the scrollbar at any DOM level. Anyway, if you have Chrome, you can see this approach working here:

Certainly the scrollbar-gutter approach is easier and far better supported, but it’s neat to know there are future options.

]]>
https://frontendmasters.com/blog/the-downsides-of-scrollbar-gutter-stable-and-one-weird-trick/feed/ 1 7892
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