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
React has changed, your Hooks should too https://frontendmasters.com/blog/react-has-changed-your-hooks-should-too/ https://frontendmasters.com/blog/react-has-changed-your-hooks-should-too/#comments Thu, 08 Jan 2026 01:23:41 +0000 https://frontendmasters.com/blog/?p=8204 Matt Smith:

React Hooks have been around for years, but most codebases still use them the same way: a bit of useState, an overworked useEffect, and a lot of patterns that get copy-pasted without much thought. […]

Before reaching for useEffect, ask yourself:

  • Is this driven by something external (network, DOM, subscriptions)?
  • Or can I compute this during render?

If it’s the latter, tools like useMemouseCallback, or framework-provided primitives will make your component a lot less fragile.

]]>
https://frontendmasters.com/blog/react-has-changed-your-hooks-should-too/feed/ 1 8204
RSCs https://frontendmasters.com/blog/rscs/ https://frontendmasters.com/blog/rscs/#respond Tue, 06 Jan 2026 22:28:58 +0000 https://frontendmasters.com/blog/?p=8185 Despite some not-great recent news about security vulnerabilities, React Server Components (RSCs) are likely in pretty high volume use around the internet thanks to default usage within Next.js, perhaps without users even really knowing it. I enjoyed Nadia Makarevich’s performance-focuced look at them in Bundle Size Investigation: A Step-by-Step Guide to Shrinking Your JavaScript. The how/when/why to take advantage of RSCs is not exactly crystal clear. Myself, I feel like a “basically get it” but sometimes the more I read the more confused I get 🙃. Dan Ambrov’s writing can be helpful.

]]>
https://frontendmasters.com/blog/rscs/feed/ 0 8185
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
A first look at the Web Install API https://frontendmasters.com/blog/a-first-look-at-the-web-install-api/ https://frontendmasters.com/blog/a-first-look-at-the-web-install-api/#respond Fri, 02 Jan 2026 12:55:23 +0000 https://frontendmasters.com/blog/?p=8142 Bruce Lawson:

I was excited to see that the proposed new Web Install API has entered Origin Trial in Chromium. It kind of works in Chromium Canary, but is most complete in Microsoft Edge beta […] The reason I was excited is because I read the Web install API explainer when it was announced a few months ago:

The Web Install API provides a way to democratise and decentralise web application acquisition, by enabling “do-it-yourself” end users and developers to have control over the application discovery and distribution process. It provides the tools needed to allow a web site to install a web app.

Here’s a pretty straightforward explainer site. Basically still PWAs (Progressive Web Apps) except you can offer a button-click to install them. There just might be proper cross-browser interest, but it sounds like Safari will be the hardest to get on board. But to be fair, they have their own “Add to Dock” thing.

]]>
https://frontendmasters.com/blog/a-first-look-at-the-web-install-api/feed/ 0 8142
!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
Preserve State While Moving Elements in the DOM https://frontendmasters.com/blog/preserve-state-while-moving-elements-in-the-dom/ https://frontendmasters.com/blog/preserve-state-while-moving-elements-in-the-dom/#respond Wed, 31 Dec 2025 23:03:28 +0000 https://frontendmasters.com/blog/?p=8131 Bramus wrote this almost a year ago, but I’d still call it a relatively new feature of JavaScript and one very worth knowing about.

With Node.prototype.moveBefore you can move elements around a DOM tree, without resetting the element’s state.

You don’t need it to maintain event listeners, but, as Bramus notes, it’ll keep an iframe loaded, animations running, dialogs open, etc.

]]>
https://frontendmasters.com/blog/preserve-state-while-moving-elements-in-the-dom/feed/ 0 8131
How I Write Custom Elements with lit-html https://frontendmasters.com/blog/custom-elements-with-lit-html/ https://frontendmasters.com/blog/custom-elements-with-lit-html/#comments Mon, 29 Dec 2025 14:11:35 +0000 https://frontendmasters.com/blog/?p=8102 When I started learning more about web development, or more specifically about front-end frameworks, I thought writing components was so much better and more maintainable than calling .innerHTML() whenever you need to perform DOM operations. JSX felt like a great way to mix HTML, CSS, and JS in a single file, but I wanted a more vanilla JavaScript solution instead of having to install a JSX framework like React or Solid.

So I’ve decided to go with lit-html for writing my own components.

Why not use the entire lit package instead of just lit-html?

Honestly, I believe something like lit-html should be a part of vanilla JavaScript (maybe someday?). So by using lit-html, I basically pretend like it is already. It’s my go-to solution when I want to write HTML in JavaScript. For more solid reasons, you can refer to the following list:

  • Size difference. This often does not really matter for most projects anyway.)
    • lit-html – 7.3 kb min, 3.1 kb min + gzip
    • lit – 15.8 kb min, 5.9 kb min + gzip
  • LitElement creates a shadow DOM by default. I don’t want to use the shadow DOM when creating my own components. I prefer to allow styling solutions like Tailwind to work instead of having to rely on solutions like CSS shadow parts to style my components. The light DOM can be nice.
  • import { html, render } from "lit-html" is all you need to get started to write lit-html templates whereas Lit requires you to learn about decorators to use most of its features. Sometimes you may want to use Lit directives if you need performant renders but it’s not necessary to make lit-html work on your project.

I will be showing two examples with what I consider to be two distinct methods to create a lit-html custom element. The first example will use what I call a “stateless render” because there won’t be any state parameters passed into the lit-html template. Usually this kind of component will only call the render method once during its lifecycle since there is no state to update. The second example will use a “stateful render” which calls the render function every time a state parameter changes.

Stateless Render

For my first example, the custom-element is a <textarea> wrapper that also has a status bar similar to Notepad++ that shows the length and lines of the content inside the <textarea>. The status bar will also display the position of the cursor and span of the selection if any characters are selected. Here is a picture of what it looks like for those readers that haven’t used Notepad++ before.

A screenshot of a text editor displaying an excerpt about Lorem Ipsum, highlighting the text in yellow and showing line and character counts.

I used a library called TLN (“Textarea with Line Numbers”) to make the aesthetic of the textarea feel more like Notepad++, similar to the library’s official demo. Since the base template has no state parameters, I’m using plain old JavaScript events to manually modify the DOM in response to changes within the textarea. I also used the render function again to display the updated status bar contents instead of user .innerHTML() to keep it consistent with the surrounding code.

Using lit-html to render stateless components like these is useful, but perhaps not taking full advantage of the power of lit-html. According to the official documentation:

When you call render, lit-html only updates the parts of the template that have changed since the last render. This makes lit-html updates very fast.

You may ask: “Why should you use lit-html in examples like this where it won’t make that much of a difference performance wise? Since the root render function is really only called once (or once every connectedCallback()) in the custom elements lifecycle.”

My answer is that, yes, it’s not necessary if you just want rendering to the DOM to be fast. The main reason I use lit-html is that the syntax is so much nicer to me compared to setting HTML to raw strings. With vanilla JavaScript, you have to perform .createElement(), .append(), and .addEventListener() to create deeply nested HTML structures. Calling .innerHTML() = `<large html structure></>` is much better, but you still need to perform .querySelector() to lookup the newly created HTML and add event listeners to it.

The @event syntax makes it much more clear where the event listener is located compared to the rest of the template. For example…

class MyElement extends LitElement {
  ...
  render() {
    return html`
      <p><button @click="${this._doSomething}">Click Me!</button></p>
    `;
  }
  _doSomething(e) {
    console.log("something");
  }
}

It also makes it much more apparent to me on first glance that event.currentTarget can only be the HTMLElement where you attached the listener and event.target can be the same but also may come from any child of the said HTMLElement. The template also calls .removeEventListener() on its own when the template is removed from the DOM so that’s also one less thing to worry about.

The Status Bar Area

Before I continue explaining the change events that make the status bar work, I would like to highlight one of the drawbacks of the “stateless render”: there isn’t really a neat way to render the initial state of HTML elements. I could add placeholder content for when the input is empty and no selection was made yet, but the render() function only appends the template to the given root. It doesn’t delete siblings within the root so the status bar text would end up being doubled. This could be fixed if I call an initial render somewhere in the custom element, similar to the render calls within the event listeners, but I’ve opted to omit that to keep the example simple.

The input change event is one of the more common change events. It’s straightforward to see that this will be the change event used to calculate and display the updated input length and the number of newlines that the input has.

I thought I would have a much harder time displaying the live status of selected text, but the selectionchange event provides everything I need to calculate the selection status within the textarea. This change event is relatively new too, having only been a part of baseline last September 2024.

Since I’ve already highlighted the two main events driving the status bar, I’ll proceed to the next example.

Stateful Render

My second example is a <pokemon-card> custom-element. The pokemon card component will generate a random Pokémon from a specific pokemon TCG set. The specifications of the web component are as follows:

  • The placeholder will be this generic pokemon card back.
  • A Generate button that adds a new Pokémon card from the TCG set.
  • Left and right arrow buttons for navigation.
  • Text that shows the name and page of the currently displayed Pokémon.

In this example, only two other external libraries were used for the web component that weren’t related to lit and lit-html. I used shuffle from es-toolkit to make sure the array of cards is in a random order each time the component is instantiated. Though the shuffle function itself is likely small enough that you could just write your own implementation in the same file if you want to minimize dependencies.

I also wanted to mention es-toolkit in this article for readers that haven’t heard about it yet. I think it has a lot of useful utility functions so I included it in my example. According to their introduction, “es-toolkit is a modern JavaScript utility library that offers a collection of powerful functions for everyday use.” It’s a modern alternative to lodash, which used to be a staple utility library in every JavaScript project especially during the times before ES6 was released.

There are many ways to implement a random number generator or how to randomly choose an item from a list. I decided to just create a list of all possible choices, shuffle it, then use the pop method so that it’s guaranteed no card will get generated twice. The es-toolkit shuffle type documentation states that it “randomizes the order of elements in an array using the Fisher-Yates algorithm”.

Handling State using Signals

Vanilla JavaScript doesn’t come with a state management solution. While LitElement’s property and state decorators do count as solutions, I want to utilize a solution that I consider should be a part of Vanilla JavaScript just as with lit-html. The state management solution for the component will be JavaScript Signals. Unlike lit-html, signals are already a Stage 1 Proposal so there is a slightly better chance it will become a standard part of the JavaScript specification within the next few years.

As you can see from the Stage 1 Proposal, explaining JavaScript Signals from scratch can be very long that it might as well be its own multi-part article series so I will just give a rundown on how I used it in the <pokemon-card> custom-element. If you’re interested in a quick explanation of what signals are, the creator of SolidJS, which is a popular framework that uses signals, explains their thoughts here.

Signals need an effect implementation to work which is not a part of the proposed signal API, since according to the proposal, it ties into “framework-specific state or strategies which JS does not have access to”. I will be copy and pasting the watcher code in the example despite the comments recommending otherwise. My components are also too basic for any performance related issues to happen anyways. I also used the @lit-labs/signals to keep the component “lit themed” but you can also just use the recommended signal-polyfill directly too.

Signal Syntax

The syntax I used to create a signal state in my custom HTMLElement are as follows:

#visibleIndex = new Signal.State(0)

get visibleIndex() {
  return this.#visibleIndex.get()
}

set visibleIndex(value: number) {
  this.#visibleIndex.set(value)
}

There is a much more concise way to define the above example which involves auto accessors and decorators. Unfortunately, CodePen only supports TypeScript 4.1.3 as of writing, so I’ve opted to just use long-hand syntax in the example. An example of the accessor syntax involving signals is also shown in the signal-polyfill proposal.

Card Component Extras

The Intersection Observer API was used to allow the user to navigate the card component via horizontal scroll bar while also properly updating the state of the current page being displayed.

There is also a keydown event handler present to also let the user navigate between the cards via keyboard presses. Depending on the key being pressed, it calls either the handlePrev() or handleNext() method to perform the navigation.

Finally, while entirely optional, I also added a feature to the component that will preload the next card in JavaScript to improve loading times between generating new cards.

]]>
https://frontendmasters.com/blog/custom-elements-with-lit-html/feed/ 1 8102
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