RSS FeedTwitterMastodonBlueskyShare IconHeart IconGithub IconArrow IconClock IconGUI Challenges IconHome IconNote IconBlog IconCSS IconJS IconHTML IconShows IconGit IconSpeaking IconTools Icon
transition to and from display none
A series of images of an avatar doing a bunch of skateboard tricks.

Using @starting-style and transition-behavior for enter and exit stage effects

Updated 11 min read
css

I've been playing around with @starting-style and had a thought; that combining it with transition-behavior could aid in completing a purely CSS transition strategy for toggling display: none or first render.

Dialog and popover both have display toggled via the browser.

Interruptible transitions > keyframe animations.

Try a full demo of what I'm gonna break down here on Codepen:

The result?

  1. Shared styles for showing/hiding elements with display: none
  2. Custom entry and exit effects
  3. Interruptible motion
  4. Easy potential for staggering

It's almost, a unified orchestration of DOM elements using transitions. The only catch is you have to use display: none to kick something off stage.

Caniuse? #

There's a handful of new goodies used in this post, here's caniuse links to all of it.

🧐
  1. @starting-style
  2. transition-behavior
  3. <dialog> Baseline 2022
  4. :modal Baseline 2022
  5. [popover] Baseline 2024
  6. :popover-open Baseline 2024
  7. @layer Baseline 2022
  8. [hidden] Old School

Enter Stage #

Here's the original post's CSS and its few lines of glory:

* {
  transition: opacity .5s ease-in;
  @starting-style { opacity: 0 }
}

A crossfade entry transition occurs:

  • on page load
  • inserted into the page
  • coming from display: none

@starting-style is describing the off stage starting place. I've said go from opacity: 0 to a natural resting place on the stage, which by default is opacity: 1.

To aid in distinguishing between stage enter and exit, enter will start scaled up, so it scales down into the resting place. Like it floated in from above.

@starting-style { 
  opacity: 0; 
  scale: 1.1; 👈
}

Adding scale to the transition means we need to update the transition-property list so it interpolates:

* {
  transition: 
    opacity .5s ease-in, 
    scale   .5s ease-in; 👈

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

Great, now things scale down and fade in when entering the stage 👍🏻

But, don't forget, be mindful, we're not just cross fading anymore.

* {
  @media (prefers-reduced-motion: no-preference) { 👈
    transition: 
      opacity .5s ease-in, 
      scale   .5s ease-in;  
  }

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

Exit Stage #

This is where transition-behavior comes in.

If you set display: none on an element, none of the transitions have time to run before the browser immediately removes it from the stage. Adding transition-behavior allow-discrete allows you to specify a duration of time that should pass before applying the display: none change.

* {
  @media (prefers-reduced-motion: no-preference) {
    transition: 
      opacity .5s ease-in, 
      scale   .5s ease-in,
      display .5s ease-in;               👈
    transition-behavior: allow-discrete; 👈
  }

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

Now, the browser won't instantly hide elements when they're set to display none, it will wait .5s, which is exactly how long our transition runs. Cool.

But we're missing something.. oh yes, the exit stage styles! Something needs to trigger a state we can style with opacity, scale and display.

I chose to lean into the browser provided attribute that sets display: none, the [hidden] attribute!

* {
  @media (prefers-reduced-motion: no-preference) {
    transition: 
      opacity .5s ease-in, 
      scale   .5s ease-in,
      display .5s ease-in;
    transition-behavior: allow-discrete;
  }

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

  &[hidden] { 👈
    opacity: 0;
    scale: .9;
    display: none !important; 
    transition-duration: .4s;
    transition-timing-function: ease-out;
  }
}

So when any element has been given the [hidden] attribute, we describe the off stage styles. I've made the duration .4s with an ease-out effect because I think it's nice when exit animations happen slightly faster than entry animations.

Notice !important added. The browser provides that automatically for the attribute, but it's specificity is weak. If elements have any custom display type set, none isn't applied and the effect is lost. This also means you could employ your own type of flag here.

You can try out this effect in the demo when using any of the "Toggle [hidden]" buttons.

Dialog and popover #

We can add onto the &[hidden] selector and then the <dialog> and [popover] elements can leave the stage the same way as everyone else.

* {
  @media (prefers-reduced-motion: no-preference) {
    transition: 
      opacity .5s ease-in, 
      scale   .5s ease-in,
      display .5s ease-in;
    transition-behavior: allow-discrete;
  }

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

  &[hidden],
  dialog:not(:modal),              👈
  &[popover]:not(:popover-open) {  👈
    opacity: 0;
    scale: .9;
    display: none !important; 
    transition-duration: .4s;
    transition-timing-function: ease-out;
  }
}

For <dialog>, I'm using the :modal pseudo class for knowing when the dialog is hidden.

For [popover], I'm using the :popover-open pseudo class for knowing when a popover is hidden.

You can try out this effect in the demo when using the dialog or popover show buttons.

A little @layer flavor #

With cascade layers you can make an anonymous layer. I like to think of it as a 2 part feature, which works nicely for this wildly * applied entry and exit transition style.

  1. should be easy to override
  2. shouldn't be too much noise in devtools
@layer {

}

By wrapping the entire nested set of styles we've made so far, we'll be accomplishing #1 and #2 because:

  1. a demoted anonymous layer is weaker than unlayered styles or can be imported into another layer the author knows is a weaker layer
  2. the layer drops the styles in the styles pane underneath the other styles
@layer { 👈
  * {
    @media (prefers-reduced-motion: no-preference) {
      transition: 
        opacity .5s ease-in, 
        scale   .5s ease-in,
        display .5s ease-in;
      transition-behavior: allow-discrete;
    }

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

    &[hidden],
    dialog:not(:modal), 
    &[popover]:not(:popover-open) { 
      opacity: 0;
      scale: .9;
      display: none !important; 
      transition-duration: .4s;
      transition-timing-function: ease-out;
    }
  }
}

I like the effect, I also like cascade layers. But you could def get away without this @layer if you wanted.

Transitions complete? #

So the items have transitioned to display none, rad, but now you wanna clean up the node.

function onTransitionsEnded(node) {
  return Promise.allSettled(
    node.getAnimations().map(animation =>
      animation.finished))
}

Using it is pretty nice:

async () => {
  node.hidden = true
  await onTransitionsEnded(node)
  node.remove()
}

Now cleanup is nice and easy.

You can try out this effect in the demo when using the "remove" button.

Conclusion #

Some of this CSS looks a bit voodoo, but I think the journey to the end snippet makes sense.

I also think it'd make sense to scope it to a class or something, there's probably some scenarios where the results aren't desirable for every element. But conceptually targetting everything sure is fun.

View transitions are a good option for some of what this solves, but they aren't interruptible. Also not as easy to integrate with dialog and popovers.

Transitions are rad, and for dialog and popover is definitely valuable for the user to be able to interrupt any effects.

Thanks for following along with this wierd CSS thought!

Update #1:

If you want to transition the <dialog> ::backdrop you're going to need to add some styles. You may also need to transition overlay depending on what you're after. For a less experimental snippet that does fully transition dialogs and popovers, see this Codepen and steal the CSS from there.

Update #2:

My function for knowing when all the transitions have finished has been updated. It's now 100% reliable and using getAnimations(). I'd tried this in my testing and found the array to be empty. BUT, later found out that it's because none of the transitions are running. Once they're kicked off, getAnimations() returns each transition PLUS a .finished promise.

Mentions #

Join the conversation on

34 likes
13 reposts
  • 7 Juicy Herbs For Inner Nourishment
  • Aleš Roubíček
  • Michael G
  • Toby Evans
  • Alexander Lehner, CPACC
  • David Leininger
  • Geoff Rich
  • nate
  • Tony Ward
  • kazuhito
  • GENKI
  • Luke
  • Apple Annie :prami:

for knowing when all transitions are finished, it might be better to figure out which is the longest running and only observe that one transition's end

Adam ArgyleAdam Argyle

@argyleink Yeah that was a cool one! ????????

FelixFelix

@argyleink Thanks Adam so much for writing this one. I learned a ton from this single article! I finally understood what `transition-behavior: allow-discrete;` does. So so so good post!

Paweł GrzybekPaweł Grzybek

Crawl the CSS Webring?

previous sitenext site
a random site