Hire a web Developer and Designer to upgrade and boost your online presence with cutting edge Technologies

Wednesday, February 5, 2025

Transitioning Top-Layer Entries And The Display Property In CSS

 We are getting spoiled with so many new features involving animations with CSS, from scroll-driven animations to view transitions, and plenty of things in between. But it’s not always the big features that make our everyday lives easier; sometimes, it’s those ease-of-life features that truly enhance our projects. In this article, Brecht De Ruyte puts two features on display: @starting-style and transition-behavior — two properties that are absolutely welcome additions to your everyday work with CSS animations.

Animating from and to display: none; was something we could only achieve with JavaScript to change classes or create other hacks. The reason why we couldn’t do this in CSS is explained in the new CSS Transitions Level 2 specification:

“In Level 1 of this specification, transitions can only start during a style change event for elements that have a defined before-change style established by the previous style change event. That means a transition could not be started on an element that was not being rendered for the previous style change event.”

In simple terms, this means that we couldn’t start a transition on an element that is hidden or that has just been created.

What Does transition-behavior: allow-discrete Do?

allow-discrete is a bit of a strange name for a CSS property value, right? We are going on about transitioning display: none, so why isn’t this named transition-behavior: allow-display instead? The reason is that this does a bit more than handling the CSS display property, as there are other “discrete” properties in CSS. A simple rule of thumb is that discrete properties do not transition but usually flip right away between two states. Other examples of discrete properties are visibility and mix-blend-mode. I’ll include an example of these at the end of this article.

To summarise, setting the transition-behavior property to allow-discrete allows us to tell the browser it can swap the values of a discrete property (e.g., display, visibility, and mix-blend-mode) at the 50% mark instead of the 0% mark of a transition.

What Does @starting-style Do?

The @starting-style rule defines the styles of an element right before it is rendered to the page. This is highly needed in combination with transition-behavior and this is why:

When an item is added to the DOM or is initially set to display: none, it needs some sort of “starting style” from which it needs to transition. To take the example further, popovers and dialog elements are added to a top layer which is a layer that is outside of your document flow, you can kind of look at it as a sibling of the <html> element in your page’s structure. Now, when opening this dialog or popover, they get created inside that top layer, so they don’t have any styles to start transitioning from, which is why we set @starting-style. Don’t worry if all of this sounds a bit confusing. The demos might make it more clearly. The important thing to know is that we can give the browser something to start the animation with since it otherwise has nothing to animate from.

A Note On Browser Support

At the moment of writing, the transition-behavior is available in Chrome, Edge, Safari, and Firefox. It’s the same for @starting-style, but Firefox currently does not support animating from display: none. But remember that everything in this article can be perfectly used as a progressive enhancement.

Now that we have the theory of all this behind us, let’s get practical. I’ll be covering three use cases in this article:

  • Animating from and to display: none in the DOM.
  • Animating dialogs and popovers entering and exiting the top layer.
  • More “discrete properties” we can handle.

    Animating From And To display: none In The DOM

    For the first example, let’s take a look at @starting-style alone. I created this demo purely to explain the magic. Imagine you want two buttons on a page to add or remove list items inside of an unordered list.

    This could be your starting HTML:

    <button type="button" class="btn-add">
      Add item
    </button>
    <button type="button" class="btn-remove">
      Remove item
    </button>
    <ul role="list"></ul>
    

    Next, we add actions that add or remove those list items. This can be any method of your choosing, but for demo purposes, I quickly wrote a bit of JavaScript for it:

    document.addEventListener("DOMContentLoaded", () => {
      const addButton = document.querySelector(".btn-add");
      const removeButton = document.querySelector(".btn-remove");
      const list = document.querySelector('ul[role="list"]');
    
      addButton.addEventListener("click", () => {
        const newItem = document.createElement("li");
        list.appendChild(newItem);
      });
    
      removeButton.addEventListener("click", () => {
        if (list.lastElementChild) {
          list.lastElementChild.classList.add("removing");
          setTimeout(() => {
            list.removeChild(list.lastElementChild);
          }, 200);
        }
      });
    });
    

    When clicking the addButton, an empty list item gets created inside of the unordered list. When clicking the removeButton, the last item gets a new .removing class and finally gets taken out of the DOM after 200ms.

    With this in place, we can write some CSS for our items to animate the removing part:

    ul {
        li {
          transition: opacity 0.2s, transform 0.2s;
    
          &.removing {
            opacity: 0;
            transform: translate(0, 50%);
          }
        }
      }
    

    This is great! Our .removing animation is already looking perfect, but what we were looking for here was a way to animate the entry of items coming inside of our DOM. For this, we will need to define those starting styles, as well as the final state of our list items.

    First, let’s update the CSS to have the final state inside of that list item:

    ul {
        li {
          opacity: 1;
          transform: translate(0, 0);
          transition: opacity 0.2s, transform 0.2s;
    
          &.removing {
            opacity: 0;
            transform: translate(0, 50%);
          }
        }
      }
    

    Not much has changed, but now it’s up to us to let the browser know what the starting styles should be. We could set this the same way we did the .removing styles like so:

    ul {
        li {
          opacity: 1;
          transform: translate(0, 0);
          transition: opacity 0.2s, transform 0.2s;
    
          @starting-style {
            opacity: 0;
            transform: translate(0, 50%);
          }
    
          &.removing {
            opacity: 0;
            transform: translate(0, 50%);
          }
        }
      }
    

    Now we’ve let the browser know that the @starting-style should include zero opacity and be slightly nudged to the bottom using a transform. The final result is something like this:

    But we don’t need to stop there! We could use different animations for entering and exiting. We could, for example, update our starting style to the following:

    @starting-style {
      opacity: 0;
      transform: translate(0, -50%);
    }
    

    Doing this, the items will enter from the top and exit to the bottom. See the full example in this CodePen:

    See the Pen @starting-style demo - up-in, down-out [forked] by utilitybend.

    When To Use transition-behavior: allow-discrete

    In the previous example, we added and removed items from our DOM. In the next demo, we will show and hide items using the CSS display property. The basic setup is pretty much the same, except we will add eight list items to our DOM with the .hidden class attached to it:

      <button type="button" class="btn-add">
        Show item
      </button>
      <button type="button" class="btn-remove">
        Hide item
      </button>
    
    <ul role="list">
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
      <li class="hidden"></li>
    </ul>
    

    Once again, for demo purposes, I added a bit of JavaScript that, this time, removes the .hidden class of the next item when clicking the addButton and adds the hidden class back when clicking the removeButton:

    document.addEventListener("DOMContentLoaded", () => {
      const addButton = document.querySelector(".btn-add");
      const removeButton = document.querySelector(".btn-remove");
      const listItems = document.querySelectorAll('ul[role="list"] li');
    
      let activeCount = 0;
    
      addButton.addEventListener("click", () => {
        if (activeCount < listItems.length) {
          listItems[activeCount].classList.remove("hidden");
          activeCount++;
        }
      });
    
      removeButton.addEventListener("click", () => {
        if (activeCount > 0) {
          activeCount--;
          listItems[activeCount].classList.add("hidden");
        }
      });
    });
    

    Let’s put together everything we learned so far, add a @starting-style to our items, and do the basic setup in CSS:

    ul {
        li {
          display: block;
          opacity: 1;
          transform: translate(0, 0);
          transition: opacity 0.2s, transform 0.2s;
    
          @starting-style {
            opacity: 0;
            transform: translate(0, -50%);
          }
    
          &.hidden {
            display: none;
            opacity: 0;
            transform: translate(0, 50%);
          }
        }
      }
    

    This time, we have added the .hidden class, set it to display: none, and added the same opacity and transform declarations as we previously did with the .removing class in the last example. As you might expect, we get a nice fade-in for our items, but removing them is still very abrupt as we set our items directly to display: none.

    This is where the transition-behavior property comes into play. To break it down a bit more, let’s remove the transition property shorthand of our previous CSS and open it up a bit:

    ul {
        li {
          display: block;
          opacity: 1;
          transform: translate(0, 0);
          transition-property: opacity, transform;
          transition-duration: 0.2s;
        }
      }
    

    All that is left to do is transition the display property and set the transition-behavior property to allow-discrete:

    ul {
        li {
          display: block;
          opacity: 1;
          transform: translate(0, 0);
          transition-property: opacity, transform, display;
          transition-duration: 0.2s;
          transition-behavior: allow-discrete;
          /* etc. */
        }
      }
    

    We are now animating the element from display: none, and the result is exactly as we wanted it:

    We can use the transition shorthand property to make our code a little less verbose:

    transition: opacity 0.2s, transform 0.2s, display 0.2s allow-discrete;
    

    You can add allow-discrete in there. But if you do, take note that if you declare a shorthand transition after transition-behavior, it will be overruled. So, instead of this:

    transition-behavior: allow-discrete;
    transition: opacity 0.2s, transform 0.2s, display 0.2s;
    

    …we want to declare transition-behavior after the transition shorthand:

    transition: opacity 0.2s, transform 0.2s, display 0.2s;
    transition-behavior: allow-discrete;
    

    Otherwise, the transition shorthand property overrides transition-behavior.

    See the Pen @starting-style and transition-behavior: allow-discrete [forked] by utilitybend.

    Animating Dialogs And Popovers Entering And Exiting The Top Layer

    Let’s add a few use cases with dialogs and popovers. Dialogs and popovers are good examples because they get added to the top layer when opening them.

    What Is That Top Layer?

    We’ve already likened the “top layer” to a sibling of the <html> element, but you might also think of it as a special layer that sits above everything else on a web page. It’s like a transparent sheet that you can place over a drawing. Anything you draw on that sheet will be visible on top of the original drawing.

    The original drawing, in this example, is the DOM. This means that the top layer is out of the document flow, which provides us with a few benefits. For example, as I stated before, dialogs and popovers are added to this top layer, and that makes perfect sense because they should always be on top of everything else. No more z-index: 9999!

    But it’s more than that:

  • z-index is irrelevant: Elements on the top layer are always on top, regardless of their z-index value.
  • DOM hierarchy doesn’t matter: An element’s position in the DOM doesn’t affect its stacking order on the top layer.
  • Backdrops: We get access to a new ::backdrop pseudo-element that lets us style the area between the top layer and the DOM beneath it.

Hopefully, you are starting to understand the importance of the top layer and how we can transition elements in and out of it as we would with popovers and dialogues.

Transitioning The Dialog Element In The Top Layer

The following HTML contains a button that opens a <dialog> element, and that <dialog> element contains another button that closes the <dialog>. So, we have one button that opens the <dialog> and one that closes it.

<button class="open-dialog" data-target="my-modal">Show dialog</button>

<dialog id="my-modal">
  <p>Hi, there!</p>
  <button class="outline close-dialog" data-target="my-modal">
    close
  </button>
</dialog>

A lot is happening in HTML with invoker commands that will make the following step a bit easier, but for now, let’s add a bit of JavaScript to make this modal actually work:

// Get all open dialog buttons.
const openButtons = document.querySelectorAll(".open-dialog");
// Get all close dialog buttons.
const closeButtons = document.querySelectorAll(".close-dialog");

// Add click event listeners to open buttons.
openButtons.forEach((button) =< {
  button.addEventListener("click", () =< {
    const targetId = button.getAttribute("data-target");
    const dialog = document.getElementById(targetId);
    if (dialog) {
      dialog.showModal();
    }
  });
});

// Add click event listeners to close buttons.
closeButtons.forEach((button) =< {
  button.addEventListener("click", () =< {
    const targetId = button.getAttribute("data-target");
    const dialog = document.getElementById(targetId);
    if (dialog) {
      dialog.close();
    }
  });
});

I’m using the following styles as a starting point. Notice how I’m styling the ::backdrop as an added bonus!

dialog {
  padding: 30px;
  width: 100%;
  max-width: 600px;
  background: #fff;
  border-radius: 8px;
  border: 0;
  box-shadow: 
    rgba(0, 0, 0, 0.3) 0px 19px 38px,
    rgba(0, 0, 0, 0.22) 0px 15px 12px;
    
  &::backdrop {
    background-image: linear-gradient(
      45deg in oklab,
      oklch(80% 0.4 222) 0%,
      oklch(35% 0.5 313) 100%
    );
  }
}

This results in a pretty hard transition for the entry, meaning it’s not very smooth:

Let’s add transitions to this dialog element and the backdrop. I’m going a bit faster this time because by now, you likely see the pattern and know what’s happening:

dialog {
  opacity: 0;
  translate: 0 30%;
  transition-property: opacity, translate, display;
  transition-duration: 0.8s;

  transition-behavior: allow-discrete;
  
  &[open] {
    opacity: 1;
    translate: 0 0;

    @starting-style {
      opacity: 0;
      translate: 0 -30%;
    }
  }
}

When a dialog is open, the browser slaps an open attribute on it:

<dialog open> ... </dialog>

And that’s something else we can target with CSS, like dialog[open]. So, in this case, we need to set a @starting-style for when the dialog is in an open state.

Let’s add a transition for our backdrop while we’re at it:

dialog {
  /* etc. */
  &::backdrop {
    opacity: 0;
    transition-property: opacity;
    transition-duration: 1s;
  }

  &[open] {
    /* etc. */
    &::backdrop {
      opacity: 0.8;

      @starting-style {
        opacity: 0;
      }
    }
  }
}

Now you’re probably thinking: A-ha! But you should have added the display property and the transition-behavior: allow-discrete on the backdrop!

But no, that is not the case. Even if I would change my backdrop pseudo-element to the following CSS, the result would stay the same:

 &::backdrop {
    opacity: 0;
    transition-property: opacity, display;
    transition-duration: 1s;
    transition-behavior: allow-discrete;
  }

It turns out that we are working with a ::backdrop and when working with a ::backdrop, we’re implicitly also working with the CSS overlay property, which specifies whether an element appearing in the top layer is currently rendered in the top layer.

And overlay just so happens to be another discrete property that we need to include in the transition-property declaration:

dialog {
  /* etc. */

&::backdrop {
  transition-property: opacity, display, overlay;
  /* etc. */
}

Unfortunately, this is currently only supported in Chromium browsers, but it can be perfectly used as a progressive enhancement.

And, yes, we need to add it to the dialog styles as well:

dialog {
  transition-property: opacity, translate, display, overlay;
  /* etc. */

&::backdrop {
  transition-property: opacity, display, overlay;
  /* etc. */
}
See the Pen Dialog: starting-style, transition-behavior, overlay [forked] by utilitybend.

It’s pretty much the same thing for a popover instead of a dialog. I’m using the same technique, only working with popovers this time:

See the Pen Popover transition with @starting-style [forked] by utilitybend.

Other Discrete Properties

There are a few other discrete properties besides the ones we covered here. If you remember the second demo, where we transitioned some items from and to display: none, the same can be achieved with the visibility property instead. This can be handy for those cases where you want items to preserve space for the element’s box, even though it is invisible.

So, here’s the same example, only using visibility instead of display.

See the Pen Transitioning the visibility property [forked] by utilitybend.

The CSS mix-blend-mode property is another one that is considered discrete. To be completely honest, I can’t find a good use case for a demo. But I went ahead and created a somewhat trite example where two mix-blend-modes switch right in the middle of the transition instead of right away.

See the Pen Transitioning mix-blend-mode [forked] by utilitybend.

Wrapping Up

That’s an overview of how we can transition elements in and out of the top layer! In an ideal world, we could get away without needing a completely new property like transition-behavior just to transition otherwise “un-transitionable” properties, but here we are, and I’m glad we have it.

But we also got to learn about @starting-style and how it provides browsers with a set of styles that we can apply to the start of a transition for an element that’s in the top layer. Otherwise, the element has nothing to transition from at first render, and we’d have no way to transition them smoothly in and out of the top layer.

Tuesday, February 4, 2025

Navigating The Challenges Of Modern Open-Source Authoring: Lessons Learned

 Alvaro Saburido delves into the current state and challenges of Open-Source authoring, sharing lessons learned from both community- and company-driven initiatives.

Open source is the backbone of modern software development. As someone deeply involved in both community-driven and company-driven open source, I’ve had the privilege of experiencing its diverse approaches firsthand. This article dives into what modern OSS (Open Source) authoring looks like, focusing on front-end JavaScript libraries such as TresJS and tools I’ve contributed to at Storyblok.

But let me be clear:

There’s no universal playbook for OSS. Every language, framework, and project has its own workflows, rules, and culture — and that’s okay. These variations are what make open source so adaptable and diverse.

The Art Of OSS Authoring

Authoring an open-source project often begins with scratching your own itch — solving a problem you face as a developer. But as your “experiment” gains traction, the challenge shifts to addressing diverse use cases while maintaining the simplicity and focus of the original idea.

Take TresJS as an example. All I wanted was to add 3D to my personal Nuxt portfolio, but at that time, there wasn’t a maintained, feature-rich alternative to React Three Fiber in VueJS. So, I decided to create one. Funny enough, after two years after the library’s launch, my portfolio remains unfinished.

Community-driven OSS Authoring: Lessons From TresJS

Continuing with TresJS as an example of a community-driven OSS project, the community has been an integral part of its growth, offering ideas, filing issues (around 531 in total), and submitting pull requests (around 936 PRs) of which 90% eventually made it to production. As an author, this is the best thing that can happen — it’s probably one of the biggest reasons I fell in love with open source. The continuous collaboration creates an environment where new ideas can evolve into meaningful contributions.

However, it also comes with its own challenges. The more ideas come in, the harder it becomes to maintain the project’s focus on its original purpose.

As authors, it’s our responsibility to keep the vision of the library clear — even if that means saying no to great ideas from the community.

Over time, some of the most consistent collaborators became part of a core team, helping to share the responsibility of maintaining the library and ensuring it stays aligned with its original goals.

Another crucial aspect of scaling a project, especially one like TresJS, which has grown into an ecosystem of packages, is the ability to delegate. The more the project expands, the more essential it becomes to distribute responsibilities among contributors. Delegation helps in reducing the burden of the massive workload and empowers contributors to take ownership of specific areas. As a core author, it’s equally important to provide the necessary tools, CI workflows, and clear conventions to make the process of contributing as simple and efficient as possible. A well-prepared foundation ensures that new and existing collaborators can focus on what truly matters — pushing the project forward.

Company-driven OSS Authoring: The Storyblok Perspective

Now that we’ve explored the bright spots and challenges of community-driven OSS let’s jump into a different realm: company-driven OSS.

I had experience with inner-source and open-source in previous companies, so I already had a grasp of how OSS works in the context of a company environment. However, my most meaningful experience would come later, specifically earlier this year, when I switched my role from DevRel to a full-time Developer Experience Engineer, and I say “full-time” because before taking the role, I was already contributing to Storyblok’s SDK ecosystem.

At Storyblok, open source plays a crucial role in how we engage with developers and how they seamlessly use our product with their favorite framework. Our goal is to provide the same developer experience regardless of the flavor, making the experience of using Storyblok as simple, effective, and enjoyable as possible.

To achieve this, it’s crucial to balance the needs of the developer community — which often reflect the needs of the clients they work for — with the company’s broader goals. One of the things I find more challenging is managing expectations. For instance, while the community may want feature requests and bug fixes to be implemented quickly, the company’s priorities might dictate focusing on stability, scalability, and often strategic integrations. Clear communication and prioritization are key to maintaining healthy alignment and trust between both sides.

One of the unique advantages of company-driven open source is the availability of resources:

  • Dedicated engineering time,
  • Infrastructure (which many OSS authors often cannot afford),
  • Access to knowledge from internal teams like design, QA, and product management.

However, this setup often comes with the challenge of dealing with legacy codebases — typically written by developers who may not be familiar with OSS principles. This can lead to inconsistencies in structure, testing, and documentation that require significant refactoring before the project can align with open-source best practices.

I like to think of community-driven OSS as being like jazz music—freeform, improvised, and deeply collaborative. In contrast, company-driven OSS resembles an orchestra, with a conductor guiding the performance and ensuring all the pieces fit together seamlessly.

The truth is that most OSS projects — if not the vast majority — exist somewhere along this spectrum. For example, TresJS began as a purely community-driven project, but as it matured and gained traction, elements of structured decision-making — more typical of company-driven projects — became necessary to maintain focus and scalability. Together with the core team, we defined a vision and goals for the project to ensure it continued to grow without losing sight of its original purpose.

Interestingly, the reverse is also true: Company-driven OSS can benefit significantly from the fast-paced innovation seen in community-driven projects.

Many of the improvements I’ve introduced to the Storyblok ecosystem since joining were inspired by ideas first explored in TresJS. For instance, migrating the TresJS ecosystem to pnpm workspaces demonstrated how streamlined dependency management could improve development workflows like playgrounds and e2e — an approach we gradually adapted later for Storyblok’s ecosystem.

Similarly, transitioning Storyblok testing from Jest to Vitest, with its improved performance and developer experience, was influenced by how testing is approached in community-driven projects. Likewise, our switch from Prettier to ESLint’s v9 flat configuration with auto-fix helped consolidate linting and formatting into a single workflow, streamlining developer productivity.

Even more granular processes, such as modernizing CI workflows, found their way into Storyblok. TresJS’s evolution from a single monolithic release action to granular steps for linting, testing, and building provided a blueprint for enhancing our pipelines at Storyblok. We also adopted continuous release practices inspired by pkg.pr.new, enabling faster delivery of incremental changes and testing package releases in real client projects to gather immediate feedback before merging the PRs.

That said, TresJS also benefited from my experiences at Storyblok, which had a more mature and battle-tested ecosystem, particularly in adopting automated processes. For example, we integrated Dependabot to keep dependencies up to date and used auto-merge to reduce manual intervention for minor updates, freeing up contributors’ time for more meaningful work. We also implemented an automatic release pipeline using GitHub Actions, inspired by Storyblok’s workflows, ensuring smoother and more reliable releases for the TresJS ecosystem.

The Challenges of Modern OSS Authoring

Throughout this article, we’ve touched on several modern OSS challenges, but if one deserves the crown, it’s managing breaking changes and maintaining compatibility. We know how fast the pace of technology is, especially on the web, and users expect libraries and tools to keep up with the latest trends. I’m not the first person to say that hype-driven development can be fun, but it is inherently risky and not your best ally when building reliable, high-performance software — especially in enterprise contexts.

Breaking changes exist. That’s why semantic versioning comes into play to make our lives easier. However, it is equally important to balance innovation with stability. This becomes more crucial when introducing new features or refactoring for better performance, breaking existing APIs. One key lesson I’ve learned — particularly during my time at Storyblok — is the importance of clear communication. Changelogs, migration guides, and deprecation warnings are invaluable tools to smoothen the transition for users.

A practical example:

My first project as a Developer Experience Engineer was introducing @storyblok/richtext, a library for rich-text processing that (at the time of writing) sees around 172k downloads per month. The library was crafted during my time as a DevRel, but transitioning users to it from the previous rich-text implementation across the ecosystem required careful planning. Since the library would become a dependency of the fundamental JS SDK — and from there propagate to all the framework SDKs — together with my manager, we planned a multi-month transition with a retro-compatible period before the major release. This included communication campaigns, thorough documentation, and gradual adoption to minimize disruption.

Despite these efforts, mistakes happened — and that’s okay. During the rich-text transition, there were instances where updates didn’t arrive on time or where communication and documentation were temporarily out of sync. This led to confusion within the community, which we addressed by providing timely support on GitHub issues and Discord. These moments served as reminders that even with semantic versioning, modular architectures, and meticulous planning, OSS authoring is never perfect. Mistakes are part of the process.

And that takes us to the following point.

Conclusion

Open-source authoring is a journey of continuous learning. Each misstep offers a chance to improve, and each success reinforces the value of collaboration and experimentation.

There’s no “perfect” way to do OSS, and that’s the beauty of it. Every project has its own set of workflows, challenges, and quirks shaped by the community and its contributors. These differences make open source adaptable, dynamic, fun, and, above all, impactful. No matter if you’re building something entirely new or contributing to an existing project, remember that progress, not perfection, is the goal.

So, keep contributing, experimenting, and sharing your work. Every pull request, issue, and idea you put forward brings value &mdashp not just to your project but to the broader ecosystem.

Happy coding!