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

Wednesday, February 19, 2025

Integrations: From Simple Data Transfer To Modern Composable Architectures

 In today’s web development landscape, the concept of a monolithic application has become increasingly rare. Modern applications are composed of multiple specialized services, each of which handles specific aspects of functionality. This shift didn’t happen overnight — it’s the result of decades of evolution in how we think about and implement data transfer between systems. Let’s explore this journey and see how it shapes modern architectures, particularly in the context of headless CMS solutions.

When computers first started talking to each other, the methods were remarkably simple. In the early days of the Internet, systems exchanged files via FTP or communicated via raw TCP/IP sockets. This direct approach worked well for simple use cases but quickly showed its limitations as applications grew more complex.

# Basic socket server example
import socket

server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.bind(('localhost', 12345))
server_socket.listen(1)

while True:
    connection, address = server_socket.accept()
    data = connection.recv(1024)
    # Process data
    connection.send(response)

The real breakthrough in enabling complex communication between computers on a network came with the introduction of Remote Procedure Calls (RPC) in the 1980s. RPC allowed developers to call procedures on remote systems as if they were local functions, abstracting away the complexity of network communication. This pattern laid the foundation for many of the modern integration approaches we use today.

At its core, RPC implements a client-server model where the client prepares and serializes a procedure call with parameters, sends the message to a remote server, the server deserializes and executes the procedure, and then sends the response back to the client.

Here’s a simplified example using Python’s XML-RPC.

# Server
from xmlrpc.server import SimpleXMLRPCServer

def calculate_total(items):
    return sum(items)

server = SimpleXMLRPCServer(("localhost", 8000))
server.register_function(calculate_total)
server.serve_forever()

# Client
import xmlrpc.client

proxy = xmlrpc.client.ServerProxy("http://localhost:8000/")
try:
    result = proxy.calculate_total([1, 2, 3, 4, 5])
except ConnectionError:
    print("Network error occurred")

RPC can operate in both synchronous (blocking) and asynchronous modes.

Modern implementations such as gRPC support streaming and bi-directional communication. In the example below, we define a gRPC service called Calculator with two RPC methods, Calculate, which takes a Numbers message and returns a Result message, and CalculateStream, which sends a stream of Result messages in response.

// protobuf
service Calculator {
  rpc Calculate(Numbers) returns (Result);
  rpc CalculateStream(Numbers) returns (stream Result);
}

Modern Integrations: The Rise Of Web Services And SOA

The late 1990s and early 2000s saw the emergence of Web Services and Service-Oriented Architecture (SOA). SOAP (Simple Object Access Protocol) became the standard for enterprise integration, introducing a more structured approach to system communication.

<?xml version="1.0"?>
<soap:Envelope xmlns:soap="http://www.w3.org/2003/05/soap-envelope">
  <soap:Header>
  </soap:Header>
  <soap:Body>
    <m:GetStockPrice xmlns:m="http://www.example.org/stock">
      <m:StockName>IBM</m:StockName>
    </m:GetStockPrice>
  </soap:Body>
</soap:Envelope>

While SOAP provided robust enterprise features, its complexity, and verbosity led to the development of simpler alternatives, especially the REST APIs that dominate Web services communication today.

But REST is not alone. Let’s have a look at some modern integration patterns.

RESTful APIs

REST (Representational State Transfer) has become the de facto standard for Web APIs, providing a simple, stateless approach to manipulating resources. Its simplicity and HTTP-based nature make it ideal for web applications.

First defined by Roy Fielding in 2000 as an architectural style on top of the Web’s standard protocols, its constraints align perfectly with the goals of the modern Web, such as performance, scalability, reliability, and visibility: client and server separated by an interface and loosely coupled, stateless communication, cacheable responses.

In modern applications, the most common implementations of the REST protocol are based on the JSON format, which is used to encode messages for requests and responses.

// Request
async function fetchUserData() {
  const response = await fetch('https://api.example.com/users/123');
  const userData = await response.json();
  return userData;
}

// Response
{
  "id": "123",
  "name": "John Doe",
  "_links": {
    "self": { "href": "/users/123" },
    "orders": { "href": "/users/123/orders" },
    "preferences": { "href": "/users/123/preferences" }
  }
}

GraphQL

GraphQL emerged from Facebook’s internal development needs in 2012 before being open-sourced in 2015. Born out of the challenges of building complex mobile applications, it addressed limitations in traditional REST APIs, particularly the issues of over-fetching and under-fetching data.

At its core, GraphQL is a query language and runtime that provides a type system and declarative data fetching, allowing the client to specify exactly what it wants to fetch from the server.

// graphql
type User {
  id: ID!
  name: String!
  email: String!
  posts: [Post!]!
}

type Post {
  id: ID!
  title: String!
  content: String!
  author: User!
  publishDate: String!
}

query GetUserWithPosts {
  user(id: "123") {
    name
    posts(last: 3) {
      title
      publishDate
    }
  }
}

Often used to build complex UIs with nested data structures, mobile applications, or microservices architectures, it has proven effective at handling complex data requirements at scale and offers a growing ecosystem of tools.

Webhooks

Modern applications often require real-time updates. For example, e-commerce apps need to update inventory levels when a purchase is made, or content management apps need to refresh cached content when a document is edited. Traditional request-response models can struggle to meet these demands because they rely on clients’ polling servers for updates, which is inefficient and resource-intensive.

Webhooks and event-driven architectures address these needs more effectively. Webhooks let servers send real-time notifications to clients or other systems when specific events happen. This reduces the need for continuous polling. Event-driven architectures go further by decoupling application components. Services can publish and subscribe to events asynchronously, and this makes the system more scalable, responsive, and simpler.

import fastify from 'fastify';

const server = fastify();
server.post('/webhook', async (request, reply) => {
  const event = request.body;
  
  if (event.type === 'content.published') {
    await refreshCache();
  }
  
  return reply.code(200).send();
});

This is a simple Node.js function that uses Fastify to set up a web server. It responds to the endpoint /webhook, checks the type field of the JSON request, and refreshes a cache if the event is of type content.published.

With all this background information and technical knowledge, it’s easier to picture the current state of web application development, where a single, monolithic app is no longer the answer to business needs, but a new paradigm has emerged: Composable Architecture.

Composable Architecture And Headless CMSs

This evolution has led us to the concept of composable architecture, where applications are built by combining specialized services. This is where headless CMS solutions have a clear advantage, serving as the perfect example of how modern integration patterns come together.

Headless CMS platforms separate content management from content presentation, allowing you to build specialized frontends relying on a fully-featured content backend. This decoupling facilitates content reuse, independent scaling, and the flexibility to use a dedicated technology or service for each part of the system.

Take Storyblok as an example. Storyblok is a headless CMS designed to help developers build flexible, scalable, and composable applications. Content is exposed via API, REST, or GraphQL; it offers a long list of events that can trigger a webhook. Editors are happy with a great Visual Editor, where they can see changes in real time, and many integrations are available out-of-the-box via a marketplace.

Imagine this ContentDeliveryService in your app, where you can interact with Storyblok’s REST API using the open source JS Client:

import StoryblokClient from "storyblok-js-client";

class ContentDeliveryService {
  constructor(private storyblok: StoryblokClient) {}

  async getPageContent(slug: string) {
    const { data } = await this.storyblok.get(`cdn/stories/${slug}`, {
      version: 'published',
      resolve_relations: 'featured-products.products'
    });

    return data.story;
  }

  async getRelatedContent(tags: string[]) {
    const { data } = await this.storyblok.get('cdn/stories', {
      version: 'published',
      with_tag: tags.join(',')
    });

    return data.stories;
  }
}

The last piece of the puzzle is a real example of integration.

Again, many are already available in the Storyblok marketplace, and you can easily control them from the dashboard. However, to fully leverage the Composable Architecture, we can use the most powerful tool in the developer’s hand: code.

Let’s imagine a modern e-commerce platform that uses Storyblok as its content hub, Shopify for inventory and orders, Algolia for product search, and Stripe for payments.

Once each account is set up and we have our access tokens, we could quickly build a front-end page for our store. This isn’t production-ready code, but just to get a quick idea, let’s use React to build the page for a single product that integrates our services.

First, we should initialize our clients:

import StoryblokClient from "storyblok-js-client";
import { algoliasearch } from "algoliasearch";
import Client from "shopify-buy";


const storyblok = new StoryblokClient({
  accessToken: "your_storyblok_token",
});
const algoliaClient = algoliasearch(
  "your_algolia_app_id",
  "your_algolia_api_key",
);
const shopifyClient = Client.buildClient({
  domain: "your-shopify-store.myshopify.com",
  storefrontAccessToken: "your_storefront_access_token",
});

Given that we created a blok in Storyblok that holds product information such as the product_id, we could write a component that takes the productSlug, fetches the product content from Storyblok, the inventory data from Shopify, and some related products from the Algolia index:

async function fetchProduct() {
  // get product from Storyblok
  const { data } = await storyblok.get(`cdn/stories/${productSlug}`);

  // fetch inventory from Shopify
  const shopifyInventory = await shopifyClient.product.fetch(
    data.story.content.product_id
  );

  // fetch related products using Algolia
  const { hits } = await algoliaIndex.search("products", {
    filters: `category:${data.story.content.category}`,
  });
}

We could then set a simple component state:

const [productData, setProductData] = useState(null);
const [inventory, setInventory] = useState(null);
const [relatedProducts, setRelatedProducts] = useState([]);

useEffect(() =>
  // ...
  // combine fetchProduct() with setState to update the state
  // ...

  fetchProduct();
}, [productSlug]);

And return a template with all our data:

<h1>{productData.content.title}</h1>
<p>{productData.content.description}</p>
<h2>Price: ${inventory.variants[0].price}</h2>
<h3>Related Products</h3>
<ul>
  {relatedProducts.map((product) => (
    <li key={product.objectID}>{product.name}</li>
  ))}
</ul>

We could then use an event-driven approach and create a server that listens to our shop events and processes the checkout with Stripe (credits to Manuel Spigolon for this tutorial):

const stripe = require('stripe')

module.exports = async function plugin (app, opts) {
  const stripeClient = stripe(app.config.STRIPE_PRIVATE_KEY)

  server.post('/create-checkout-session', async (request, reply) => {
    const session = await stripeClient.checkout.sessions.create({
      line_items: [...], // from request.body
      mode: 'payment',
      success_url: "https://your-site.com/success",
      cancel_url: "https://your-site.com/cancel",
    })

    return reply.redirect(303, session.url)
  })
// ...

And with this approach, each service is independent of the others, which helps us achieve our business goals (performance, scalability, flexibility) with a good developer experience and a smaller and simpler application that’s easier to maintain.

Conclusion

The integration between headless CMSs and modern web services represents the current and future state of high-performance web applications. By using specialized, decoupled services, developers can focus on business logic and user experience. A composable ecosystem is not only modular but also resilient to the evolving needs of the modern enterprise.

These integrations highlight the importance of mastering API-driven architectures and understanding how different tools can harmoniously fit into a larger tech stack.

In today’s digital landscape, success lies in choosing tools that offer flexibility and efficiency, adapt to evolving demands, and create applications that are future-proof against the challenges of tomorrow.

If you want to dive deeper into the integrations you can build with Storyblok and other services, check out Storyblok’s integrations page. You can also take your projects further by creating your own plugins with Storyblok’s plugin development resources.

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.