Elementen animeren naar en van de bovenste laag

Elementen die in de bovenste laag worden gerenderd (zoals <dialog>, elementen met het kenmerk popover of tooltips), waren in het verleden moeilijk te animeren omdat ze wisselen tussen display: none en een zichtbare status. Moderne CSS biedt @starting-style, transition-behavior: allow-discrete en de property overlay om soepele overgangen voor binnenkomst en vertrek voor deze elementen mogelijk te maken. In de voorbeelden hieronder wordt native CSS-nesting gebruikt.

Implementatie

1. Discrete overgangen aanzetten

Als je de eigenschap display wilt animeren, moet je transition-behavior: allow-discrete instellen. Zo blijft het element zichtbaar tijdens de overgang bij afsluiten. Als je overgangsnotaties gebruikt, plaats je de transition-behavior: allow-discrete erna om te voorkomen dat de notatie deze tenietdoet.

2. De overlay-property

Als een element naar de bovenste laag of daaruit wordt verplaatst, moet de property overlay worden overgezet. Zo blijft het element gedurende de animatie in de bovenste laag en wordt het niet voortijdig afgekapt door andere elementen of de viewport.

3. Animaties voor binnenkomst met @starting-style

Gebruik de at-regel @starting-style om de stijlen te definiëren die een element moet overnemen van wanneer het voor het eerst wordt gerenderd of wanneer de display verandert van none.

4. De achtergrond animeren

Het pseudo-element ::backdrop kan op dezelfde manier worden geanimeerd door overgangen toe te passen op de eigen eigenschappen.

Voorbeeld

/* 1. Define the visible (open) state */
dialog[open],
[popover]:popover-open {
  opacity: 1;
  transform: scale(1);

  /* 2. Define the starting state for entry (must come after open state) */
  @starting-style {
    opacity: 0;
    transform: scale(0.9);
  }
}

/* 3. Define the base (closed/exit) state and transitions */
dialog,
[popover] {
  opacity: 0;
  transform: scale(0.9);

  /* MANDATORY: transition display and overlay for top-layer elements */
  transition-property: opacity, transform, display, overlay;
  transition-duration: 0.3s;
  transition-timing-function: ease-out;
  /* Applies to discrete properties like display and overlay */
  transition-behavior: allow-discrete; /* Note: be sure to write this after the shorthand */
}

/* 4. Animate the backdrop */
dialog::backdrop,
[popover]::backdrop {
  background-color: rgba(0, 0, 0, 0);
  transition-property: background-color, display, overlay;
  transition-duration: 0.3s;
  transition-timing-function: ease-out;
  transition-behavior: allow-discrete;
}

dialog[open]::backdrop,
[popover]:popover-open::backdrop {
  background-color: rgba(0, 0, 0, 0.5);

  @starting-style {
    background-color: rgba(0, 0, 0, 0);
  }
}

/* 5. Respect user preference for reduced motion */
@media (prefers-reduced-motion: reduce) {
  dialog,
  [popover] {
    /* Disable movement and shorten duration for a simple fade */
    transform: none;
    transition-duration: 0.1s;
  }

  @starting-style {
    dialog[open],
    [popover]:popover-open {
      transform: none;
    }
  }
}

Beperkingen en toegankelijkheid

  • VERPLICHT: Neem overlay op in je transition-lijst voor elk element dat naar of uit de bovenste laag wordt verplaatst.
  • VERPLICHT: Gebruik allow-discrete voor de overgang van de display-property.
  • VERPLICHT: Respecteer de gebruikersvoorkeuren voor minder beweging met prefers-reduced-motion door overgangen te vereenvoudigen (bijvoorbeeld door transformaties te verwijderen en de duur te verkorten).
  • WEL DOEN: Plaats het @starting-style-blok in of na de kiezer voor de status 'open' om te zorgen dat de trapsgewijze opmaak correct is.
  • NIET DOEN: Gebruik @starting-style niet voor animaties bij sluiten. Animaties bij sluiten worden gedefinieerd door de overgang naar de basisstatus (gesloten).

Reservestrategieën

Fallbacks en browserondersteuning voor transition-behavior

Firefox 129+ parseert transition-behavior: allow-discrete (CSS.supports('transition-behavior', 'allow-discrete') retourneert true) zonder de property display daadwerkelijk over te zetten (Firefox-bug 1882408), waardoor elementen onmiddellijk verdwijnen bij het afsluiten.

Als je ondersteuning voor discrete display-overgangen betrouwbaar wilt detecteren, controleer je of de berekende display van een tijdelijk element zichtbaar blijft bij de overgang naar none in plaats van alleen te vertrouwen op CSS.supports('transition-behavior', 'allow-discrete'):

let supportsDisplayTransition;
function canTransitionDisplay() {
  if (supportsDisplayTransition !== undefined) return supportsDisplayTransition;
  if (!window.CSS?.supports?.('transition-behavior', 'allow-discrete') || !document.body) {
    return false;
  }
  const probe = document.createElement('div');
  // The shorthand is intentional here: browsers that don't parse allow-discrete
  // drop the whole declaration, so display: none applies instantly and the probe
  // correctly returns false. !important guards against global reduced-motion
  // resets like `* { transition: none !important }`.
  probe.style.cssText = 'transition: display 1s allow-discrete !important; display: block;';
  document.body.appendChild(probe);
  getComputedStyle(probe).display;
  probe.style.display = 'none';
  supportsDisplayTransition = getComputedStyle(probe).display === 'block';
  probe.remove();
  return supportsDisplayTransition;
}

Fallback voor afsluiten van bovenlaag

Invoeranimaties werken in pure CSS in alle browsers die @starting-style ondersteunen. Er is geen .is-opening-klasse nodig omdat invoerovergangen niet afhankelijk zijn van overlay of afzonderlijke display-overgangen.

Voor afsluitanimaties is ondersteuning voor zowel overlay als discrete display-overgangen vereist. Als een van beide niet wordt ondersteund (zoals in Firefox en Safari), plaats je de selectors voor de open status in :is() en voeg je :where(:not([data-closing])) toe (nesting &::backdrop en @starting-style) zodat de instelling data-closing de afsluitovergang activeert terwijl het element in de bovenste laag blijft. Wacht daarna tot getAnimations() is voltooid voordat je .close() of .hidePopover() aanroept:

:is(dialog[open], [popover]:popover-open):where(:not([data-closing])) {
  opacity: 1;
  transform: scale(1);

  @starting-style {
    opacity: 0;
    transform: scale(0.9);
  }

  &::backdrop {
    background-color: rgb(0 0 0 / 0.5);

    @starting-style {
      background-color: transparent;
    }
  }
}
// Evaluate lazily: canTransitionDisplay() needs document.body, so a top-level
// const would be permanently false if this script runs in <head>.
function supportsTopLayerExit() {
  return window.CSS?.supports?.('overlay', 'auto') && canTransitionDisplay();
}

async function closeTopLayer(element) {
  if (!supportsTopLayerExit()) {
    element.setAttribute('data-closing', '');
    const animations = element.getAnimations({ subtree: true });
    if (animations.length > 0) {
      await Promise.race([
        Promise.allSettled(animations.map((a) => a.finished)),
        new Promise((r) => setTimeout(r, 2000)),
      ]);
    }
    if (!element.hasAttribute('data-closing')) return;
    element.removeAttribute('data-closing');
  }

  element.close();
  // Or for popover:
  // element.hidePopover();
}

// Route native close requests (Esc, closedby light dismiss) through the same
// helper so they animate too, instead of closing instantly.
dialog.addEventListener('cancel', (event) => {
  event.preventDefault();
  closeTopLayer(dialog);
});

Popover light dismiss en popovertarget-schakelaars kunnen niet worden onderschept (beforetoggle kan alleen worden geannuleerd bij het openen), dus in browsers die de fallback nodig hebben, zijn die exits onmiddellijk. Zorg voor een expliciete sluitingsoptie die closeTopLayer() aanroept als de sluitingsanimatie belangrijk is.

Fallbacks en browserondersteuning voor Popover

De Popover API is voornamelijk progressive enhancement, maar de bepalende gedragingen (promotie van de bovenste laag, licht sluiten en aanroepen van popovertarget) hebben geen equivalent dat alleen CSS gebruikt. Oudere browsers hebben een polyfill nodig, of een handmatige fallback als je er liever geen verzendt.

Polyfill: Als je het kenmerk popover wilt ondersteunen in oudere browsers, laad je @oddbird/popover-polyfill voorwaardelijk. VERPLICHT: Voer een functiedetectie uit door te controleren op de property popover op HTMLElement.prototype en laad de polyfill alleen als systeemeigen ondersteuning ontbreekt. Laad de polyfill NIET onvoorwaardelijk.

Met een bundler of importtoewijzing:

// MANDATORY: Feature detect 'popover' on HTMLElement.prototype.
if (!("popover" in HTMLElement.prototype)) {
  import("@oddbird/popover-polyfill");
}

Zonder bundler importeer je vanuit een CDN in een <script type="module">:

<script type="module">
  if (!("popover" in HTMLElement.prototype)) {
    import("https://unpkg.com/@oddbird/popover-polyfill@latest/dist/popover.min.js");
  }
</script>

Opmerking over de stijl: De polyfill kan de echte pseudoklasse :popover-open niet definiëren, dus wordt in plaats daarvan de klasse .\:popover-open toegepast. VERPLICHT: Combineer de 2 met :is() of :where(), anders negeren browsers zonder :popover-open de hele regel:

[popover]:is(:popover-open, .\:popover-open) {
  display: block;
}

Als alternatief kun je voor een verouderde fallback zonder polyfill position: fixed gebruiken en de coördinaten handmatig berekenen via getBoundingClientRect() of vertrouwen op de standaardpositionering met inset: auto als dat acceptabel is voor de use case.