Frontend Masters Boost RSS Feed https://frontendmasters.com/blog Helping Your Journey to Senior Developer Fri, 28 Feb 2025 15:44:00 +0000 en-US hourly 1 https://wordpress.org/?v=6.9 225069128 Custom Property Fallbacks https://frontendmasters.com/blog/custom-property-fallbacks/ https://frontendmasters.com/blog/custom-property-fallbacks/#respond Fri, 28 Feb 2025 15:43:59 +0000 https://frontendmasters.com/blog/?p=5257 Look at this CSS and take a guess what color our text will be. No tricks, this is the only relevant code:

:root {
  --color: green;
  --color: notacolor;
  color: red;
  color: var(--color, blue);
}

Perhaps surprisingly, the answer is the default text color, usually black. Let’s figure out why that’s the case and how to write a fallback that works.

var() Fallbacks

The value blue seems a likely candidate to be our color, doesn’t it? After all, we often call the 2nd parameter of var() its “fallback” value. However, this type of fallback is only used in the following circumstances:

  • The custom property isn’t defined.
  • The custom property’s value is the initial keyword.
  • The custom property is animation-tainted and is used in an animation property.

and

  • The custom property isn’t registered with @property.

Since a registered custom property must be given an initial value, it’ll always have a valid value and will never use its fallback.

Our custom property is defined, not animated tainted, and its value isn’t a keyword, so we can throw away blue and keep looking.

Fallback Declarations

Usually in CSS, we can rely on the cascade to fallback to a previous valid value, leading to a common pattern where we declare the property twice:

overflow: hidden;
overflow: clip;

Browsers that don’t support the clip keyword will discard the entire declaration and use hidden instead.

Unfortunately, custom properties don’t work like this.

When parsing a custom property or its matching var() function, the browser doesn’t know if it’s valid until it comes time to compute its value. So instead they are treated as always valid, and any previous declarations get discarded.

If you want to prevent a custom property from being overwritten, you’ll have to mark it as important.

That means --color: green; gets discarded immediately upon discovering --color: notacolor;, and color: red; is discarded when we get to color: var(--notacolor, blue);.

In the end, our CSS computes to:

color: notacolor;

Unsurprisingly, this isn’t valid, and that’s why we get black as our color.

What We Can Do Instead

That all sounds bad but we actually have several options for writing fallbacks that work.

@property

As mentioned before, if a registered custom property is invalid, it’ll always use its initial-value, which means we can use that as our fallback:

@property --color {
  syntax: '<color>';
  inherits: true;
  initial-value: purple;
}

:root {
  --color: notacolor;
  color: var(--color); 
}

Exactly what we want, though:

  • We can only define a single fallback for the entire document (which can be good enough depending on how your custom properties are organised).
  • The intent isn’t very obvious, registering a property is a roundabout way of setting a fallback.

If that’s not a problem for you and/or you’re registering your properties anyway, this is a great option.

@supports

Using @supports lets us check our value is valid before declaring it and that gives us even more flexibility in how we define our fallbacks. Let’s look at two ways to use it:

Set a safe value first, and then inside an @supports block we can redeclare the property:

:root {
  --color: red;
}

@supports (color: notacolor) {
  :root {
    --color: notacolor;
  }
}

Then we can just use it anywhere we like, without having to think about the fallback:

p {
  color: var(--color);
}

We can set it and forget it, confident that when our value isn’t supported we’ll still have our previous declaration to fall back on. 9 out of 10 times this is what I reach for.

Alternatively, let’s skip setting a safe value, and only define the property inside @supports:

@supports (color: notacolor) {
  :root {
    --color: notacolor;
  }
}

Where’s our fallback? Well, since our property only gets declared when it’s supported, we can use the 2nd parameter of var() to write our fallback inline:

p {
  color: var(--color, red);
}

If you find yourself wanting to use different fallbacks for the same custom property, this could be a better option.

The Future

Eventually, we’ll have even more options for dealing with invalid values.

New CSS goodies like like the keyword revert-rule and the first-valid() function will let us do away with @supports and write our fallbacks wherever we want:

:root {
  /* Multiple inline fallbacks when declaring the property */
  --color: first-valid(notacolor, maybeacolor, red);
}

p {
  /* Fallback to a different rule when using the variable */
  color: first-valid(var(--color), revert-rule);
}

You can follow their progress on GitHub:

  • Discussion resulting in the revert-rule keyword resolution.
  • Discussion resulting in first-valid() resolution.

Further Learning

]]>
https://frontendmasters.com/blog/custom-property-fallbacks/feed/ 0 5257
Animating the Dialog Element https://frontendmasters.com/blog/animating-dialog/ https://frontendmasters.com/blog/animating-dialog/#comments Thu, 23 May 2024 14:41:24 +0000 https://frontendmasters.com/blog/?p=2341 When the <dialog> element became widely available in 2022, I was thrilled. Opening a dialog? Easy. Closing a dialog? Even easier. Nested dialogs and keyboard interactions? Built-in, for free. It’s like living in the future.

But what about animating? That’s a little trickier. At first glance it doesn’t appear to be animatable in CSS—transitions and animations don’t seem to work. JavaScript can do it, but that requires managing the state of your dialogs manually, losing some of the simplicity of using <dialog> in the first place.

Fortunately, thanks to modern CSS, we can do it without resorting to JavaScript.

Here we’ll take a look at opening and closing animations separately, discussing solutions using transitions and animations for each.

To keep my code simple I’ll stick to only animating opacity, though these techniques still apply to more complex examples.

The nice thing about only animating opacity is we don’t have any extra accessibility concerns. If you’re involving some form of motion in your animations, you’ll need to ensure the relevant code is wrapped in a media query like:

@media (prefers-reduced-motion: no-preference) { }

Opening Animations

Transition with @starting-style

You might have tried something like this, only to find it doesn’t work:

dialog {
  transition: opacity 1s;
  opacity: 0;
  
  &[open] {
    opacity: 1;
  }
}

The problem here is when a <dialog> opens, the browser doesn’t know what opacity value it’s meant to transition from. The first style update our <dialog open> receives sets opacity: 1 , and since that’s also our end value, no transition takes place. We see this problem pop up whenever we attempt to transition any element that changes to or from display: none. How do we fix this?

One way is with @starting-style, an at-rule that allows us to specify the values we’d like to transition from when the element is first rendered.

We can nest it directly in our existing [open] rule like so:

dialog {
  transition: opacity 1s;
  opacity: 0;
  
  &[open] {
    opacity: 1;
	  
    @starting-style {
      opacity: 0;
    }
  }
}

Success! That’s all it takes, our <dialog> will now transition opacity while opening.

We can think of @starting-style as a third state for our dialog, the ‘pre-open’ state. Often we’d want this to be the same as our ‘closed’ state, and while this might seem like an annoying bit of duplication, it’s useful that we can define it separately as it allows our opening and closing transitions to be different.

The downside here, at least at the time of writing, is browser support. @starting-style isn’t in Firefox, and only in recent versions of Chromium and WebKit based browsers. Depending on your requirements that can easily be good enough since:

  1. We’re using @starting-style as a progressive enhancement. In non-supporting browsers the dialog will simply open with no transition.
  2. @starting-style is an Interop 2024 target, so we can expect cross-browser support by the end of the year.

So what if we need a cross-browser opening animation right now? Are we out of luck? Fortunately not.

Animation with @keyframes

By using @keyframes we can get the same effect with browser support limited only by <dialog> itself and remove the need to use @starting-style:

dialog[open] {
  animation: open 1s forwards;
}

@keyframes open {
  from { opacity: 0 }
  to   { opacity: 1 }
}

That’s all we need! We solve the problem of the browser needing to know what initial value to use by explicitly declaring it within the animation.

@keyframes debatably has a few downsides, mostly notably its need for a unique name. That doesn’t sound like a big deal, but naming things can be hard, and name conflicts can be confusing to debug. All else being equal, a technique requiring a unique name is worse than a technique that doesn’t.

Personally however, until @starting-style has near universal support, this will remain my preferred technique. In my opinion it’s equally readable, rarely more verbose, and the fact it works everywhere makes me (and my clients) happy.

Closing Animations

Unfortunately when our <dialog> closes, we run into a few more problems:

  1. It changes to display: none.
  2. It’s removed from the top layer.

Both of these things happen as soon as the close event is fired, and since they both hide our element, any animations or transitions we attempt won’t be visible. We’ll need to delay these while our animation completes, and we can do it in one line with CSS:

transition:
  display 1s allow-discrete,
  overlay 1s allow-discrete;

There’s a few new things in this one declaration, so let’s expand on each of them.

transition-behavior: allow-discrete

Usually when attempting to transition discrete properties we see it doesn’t work, or more accurately, the property’s value updates at 0%, causing an instant change with no transition.

What transition-behavior: allow-discrete usually does is allow us to request that this change occur at 50% of the way through the transition, rather than 0%. I say usually, because for transitions that involve display: none, this change will instead occur at either 100% or 0%, based on if we’re animating to or from display: none. This ensures that our element will remain visible for the entire duration of the transition. Problem #1 solved.

Since the value changes at the beginning or end of the transition, it doesn’t matter what value we use for animation-timing-function so feel free to omit it from the shorthand.

transition-behavior is currently not available in Firefox or Safari, but as it’s also an Interop 2024 target along with @starting-style, we can be optimistic that it’ll be widely available by the end of the year.

It’s also not available in a non-American spelling, so make sure you leave out the ‘u’.

The overlay Property

The overlay property has two possible values: auto and none, and it specifies if an element in the top layer should be rendered in the top layer. Very simply, an element with overlay: auto will render in the top layer and be visible, and an element with overlay: none will not.

What complicates this slightly is that the overlay property is fairly unique in that it’s not possible for you to set it yourself. You can’t set it directly on an element, or use it in a @keyframes animation. The only one who can change the value of this property is the browser. Using it in a transition in combination with allow-discrete is actually our only way of interacting with it at all.

This is also another property that transitions differently than normal discrete properties where it’ll remain overlay: auto for the entire transition. Exactly what we need to solve problem #2.

The overlay keyword is our only method of keeping an element in the top layer, so any CSS only solution to <dialog> closing animations will require it. Unfortunately it’s currently only available Chromium at the time of writing, and since it’s not an Interop 2024 target, we might be waiting a little longer for cross-browser support.

Closing Transition

Lets combine this with our previous example using @starting-style by adding to our existing transition declaration:

dialog {
  transition:
    display 1s allow-discrete,
    overlay 1s allow-discrete,
    opacity 1s;
  opacity: 0;
  
  &[open] {
    opacity: 1;
	  
    @starting-style {
      opacity: 0;
    }
  }
}

And with that we have a <dialog> with both opening and closing transitions! If you’re looking for the simplest solution then you can stop here, it doesn’t come easier than this.

Closing Animation with @keyframes

If you’re like me and want to take advantage of CSS animations to provide a cross-browser opening animation, we’ll need to do a bit more.

It’s possible to use our transition only code to handle the closing animation while keeping @keyframes for our opening animation. But if you’re like me, you might find it a bit easier to understand if both animations are controlled via keyframes.

Since both display and overlay are set by the browser, we still need to transition these values outside of our animations:

dialog {
  transition:
    display 1s allow-discrete,
    overlay 1s allow-discrete;
							
  &[open] {
    animation: open 1s forwards;
  }
}

While I find it a little weird to be using both animation and transition, I like that our animation code is kept separate from our management of the browser’s default behaviour.

We need to ensure our animation-duration is at least as large as our transition-duration to ensure neither overlay or display change before the end of our animation.

Next up is the closing animation itself.

My first instinct was to reuse the same animation but play it in reverse. Unfortunately we can’t do that since it’s not possible to change animation-direction without also starting a new animation with a different name.

Instead, lets define a new set of @keyframes for our closing animation and apply it to the default (closed) state:

dialog {
  transition:
    display 1s allow-discrete,
    overlay 1s allow-discrete;
	
  animation: close 1s forwards;					
  &[open] {
    animation: open 1s forwards;
  }
}

@keyframes open {
  from { opacity: 0 }
  to   { opacity: 1 }
}

@keyframes close {
  from { opacity: 1 }
  to   { opacity: 0 }
}

And that’s all it takes! A <dialog> with a cross-browser opening animation and a progressively enhanced closing animation. It’s a little less concise with a bit more duplication than our transition only example, but you can decide if the extra browser support is worth it for you.

Conclusion

It’s honestly quite amazing how little CSS is required to make this happen. Tools like <dialog>, overlay and transition-behavior have taken what was once an incredibly complicated task and reduced it to just a few lines of CSS.

Dialogs are easier than they’ve ever been, and as long as we don’t get tempted to over use them, that’s cause for celebration to me 🎉

What about popover and ::backdrop?

I kept my explanation focused on the <dialog> element to keep things simple, but everything we’ve just covered also applies popover elements and ::backdrop too! They exist in the top layer and have their display toggled by the browser in the same way <dialog> does, so can be animated using these same techniques.

Here’s Adam Argyle with a snippet that handles popovers and backdrops also, just note it’s using @starting-style so support will be limited for now:

]]>
https://frontendmasters.com/blog/animating-dialog/feed/ 4 2341