Theme
On this page (8)

§The challenge

Animating elements that go from display: none to visible (popovers, dialogs, accordions) has historically been impossible in CSS. The element either exists or doesn't - there was no in-between state to transition through. Libraries solved this with JS class toggling and requestAnimationFrame hacks.

Modern CSS solves this with two new features: @starting-style (defines the initial state before an element appears) and allow-discrete (lets display participate in transitions).

§@starting-style

The @starting-style at-rule defines the values a property should start from when the element first appears (enters the top layer, gains display, gets inserted into the DOM). The browser applies these values first, then transitions to the element's actual computed styles.

MDN: @starting-style

/* 1. The element's normal (visible) state */
.popover:popover-open {
  opacity: 1;
  translate: 0 0;
}
/* 2. The starting state - before the transition begins */
@starting-style {
  .popover:popover-open {
    opacity: 0;
    translate: 0 -4px;
  }
}
/* Flow: @starting-style values → transition → :popover-open values
   Result: a smooth fade-in + slide-up animation */

§allow-discrete transitions

The display property was historically not animatable - it snaps between none and block instantly. The allow-discrete keyword in the transition shorthand tells the browser to keep the element visible during the exit transition before hiding it.

MDN: transition-behavior

.popover {
  /* Animate opacity, translate, AND display */
  transition:
    opacity 150ms ease,
    translate 150ms ease,
    display 150ms allow-discrete;
  /* Exit state: invisible */
  opacity: 0;
  translate: 0 -4px;
}
/* Without allow-discrete: element disappears instantly on close
   With allow-discrete: element fades out, THEN display changes to none */

§Popover & dialog animation pattern

Most overlay components (popover, dropdown, tooltip, dialog, sheet, toast) follow the same three-part animation recipe. Here's the complete pattern used by the Popover component:

@layer components {
  .popover {
    /* 1. Base state (also the exit target) */
    opacity: 0;
    translate: 0 -4px;
    transition:
      opacity 150ms ease,
      translate 150ms ease,
      display 150ms allow-discrete;
    /* 2. Open state - what it transitions TO */
    &:popover-open {
      opacity: 1;
      translate: 0 0;
    }
  }
  /* 3. Starting style - what it transitions FROM */
  @starting-style {
    .popover:popover-open {
      opacity: 0;
      translate: 0 -4px;
    }
  }
}
Enter@starting-style values → transition → :popover-open values. The popover fades in and slides up.Exit:popover-open values → transition → base state values. The allow-discrete on display keeps the element visible during the fade-out.

§Dialog & sheet animations

Dialogs and sheets use the same pattern but with dialog[open] instead of :popover-open, and they also animate the ::backdrop pseudo-element.

dialog.dialog {
  opacity: 0;
  translate: 0 -0.5rem;
  scale: 0.98;
  transition:
    opacity 200ms ease,
    translate 200ms ease,
    scale 200ms ease,
    display 200ms allow-discrete;
  &[open] {
    opacity: 1;
    translate: 0 0;
    scale: 1;
  }
  /* Backdrop fades in/out too */
  &::backdrop {
    background: oklch(0 0 0 / 0);
    backdrop-filter: blur(0px);
    transition: all 200ms ease,
      display 200ms allow-discrete;
  }
  &[open]::backdrop {
    background: oklch(0 0 0 / 0.45);
    backdrop-filter: blur(3px);
  }
}
@starting-style {
  dialog.dialog[open] {
    opacity: 0;
    translate: 0 -0.5rem;
    scale: 0.98;
  }
  dialog.dialog[open]::backdrop {
    background: oklch(0 0 0 / 0);
    backdrop-filter: blur(0px);
  }
}

Sheets use translate instead of scale - a sheet with data-side="right" slides in from translateX(100%).

§Accordion animations

Accordions animate their height using the ::details-content pseudo-element and the block-size property. This is one of the most-requested CSS features - animating height: 0 to height: auto - now possible natively.

MDN: ::details-content

.accordion-item {
  /* Target the collapsible content area */
  &::details-content {
    block-size: 0;
    overflow-y: clip;
    transition:
      block-size 200ms ease,
      content-visibility 200ms allow-discrete;
  }
  /* When open, expand to natural height */
  &[open]::details-content {
    block-size: auto;
  }
}
/* Start from 0 when opening */
@starting-style {
  .accordion-item[open]::details-content {
    block-size: 0;
  }
}
block-size: autoThe browser calculates the natural height and smoothly transitions from 0 to that value. No JavaScript height measurement needed.content-visibilityThe allow-discrete on content-visibility prevents the content from disappearing before the close animation finishes.

§Programmatic exit animations

Most components handle exit purely through CSS transitions. For cases where you need to run logic after the exit animation completes (like removing a toast element from the DOM), the Web Animations API provides a promise-based approach.

// Animate out (Web Animations API), then remove through df$
el.animate(
  [
    { opacity: 1, translate: '0 0' },
    { opacity: 0, translate: '0 0.5rem' },
  ],
  { duration: 200, easing: 'ease', fill: 'forwards' },
).finished.then(() => {
  el.hidePopover();
  df$(el).remove();
});

Used by: Toast (dismiss animation before removing the element from the DOM)

§Animation summary

Which components use animations and what type of animation they use.

animation patterns by component
ComponentAnimationTechnique
PopoverFade + slide up@starting-style + :popover-open
DropdownFade + slide up@starting-style + :popover-open
TooltipFade in@starting-style + :popover-open
DialogFade + slide + scale@starting-style + dialog[open] + ::backdrop
Alert DialogFade + slide + scale@starting-style + dialog[open] + ::backdrop
SheetSlide from edge@starting-style + dialog[open] + ::backdrop
ToastSlide in, slide out@starting-style + Web Animations API for exit
AccordionHeight expand/collapse@starting-style + ::details-content + block-size
CollapsibleHeight expand/collapse@starting-style + ::details-content + block-size
CommandFade + scale up@starting-style + dialog[open]

All of these techniques are pure CSS (except toast exit). No animation libraries, no requestAnimationFrame hacks, no JS class toggling. See the Native Web APIs page for the broader set of platform APIs used across the system.

Comments, ideas or improvements? Edit this page's source on GitHub