TransitionsFree

Dialog Curtain

A modal dialog that opens in three beats: the background darkens, two curtains slide in from the edges and meet in the center in 0.6 s, then the content grows in front of them after 0.5 s — and everything closes in reverse order. A real dialog element opened with showModal (inert page behind, focus inside the dialog, Escape), 2.3 KB of JavaScript, final state at once when the user reduces motion.

CSSDialogTransition

Updated

Stage Curtain

The content appears once the curtains have closed.

Hover or click the scene to interact

This effect is free — the Transitions category contains 52 effects total, including 9 free. Effect.Labs has 840 vanilla effects. Explore the category →

Usage examples

Mock test · series 12Question 40 / 40

At the next junction, who has right of way?

A · Me, I am on the main road B · Vehicles coming from the right C · Whoever arrives first

Answer saved. 39 questions answered before this one.

① Mock test result — a driving theory test prep platform

WhenThe learner submits the 40th question of the series and taps "See my result": blue curtains close over the quiz screen, then the "37/40 — Passed" dialog appears in front of them.
WhyThe three beats build a little suspense before the score, and the reverse closing hands control back cleanly to the series just finished.
SettingsCurtains in a blue gradient (#1e3a8a → #1d4ed8); 300 px white card, 40 px score in green #15803d, light gray "Review my mistakes" button; durations and delays of the code unchanged.

Cirque Volpi

Sat, March 14 · 3 pm

Your seats

Stand B · row 4 · seats 12 to 14

€84

② Seats booked — a circus's online box office

WhenOn the big top's seating plan, the spectator picked three seats in stand B and taps "Book 3 seats": the ring curtains close and the confirmation shows in front.
WhyThe ring curtain closing on the confirmation echoes the circus world, at the exact moment the purchase is made.
SettingsRed curtains with sharp folds (repeating-linear-gradient of 16, 12 and 8 px) and an inner shadow toward the center; cream card with a gold border (#fbbf24), 21 px serif title.

COMPAGNIE DU GRENIER

Amateur theater troupe · 2027 season

Spring showLe Malade imaginaireMolière · 3 acts

● The director has published the cast list

First rehearsal Tuesday at 8 pm · community hall

③ Role assigned — an amateur theater troupe's app

WhenThe director publishes the cast list; the actress taps "Reveal my role": the red curtains close and the dialog announces "Toinette — Le Malade imaginaire · 14 rehearsals".
WhyThe stage curtain is the troupe's very world; the dialog only appears once the curtains are closed, so the role cannot be half-guessed during the animation.
SettingsPleated burgundy velvet curtains (repeating-linear-gradient from #5b0f1f to #881337) edged with a 10 px gold fringe; 320 px near-black card with a gold border, role in 34 px serif; dialog opened by the "Reveal my role" button.

How it works

The dialog is a full-screen <dialog> element (position: fixed, inset: 0, 100% × 100%, no border or background) holding four layers: a .dialog-curtain-backdrop, two curtains .dialog-curtain-left and .dialog-curtain-right, 50% wide each, and the .dialog-curtain-content card centered with translate(-50%, -50%). At rest, the curtains wait off-screen (translateX(-100%) and translateX(100%)), the backdrop is transparent and the card is opacity: 0 and scale(0.8). The .dialog-curtain-open class gives the end state: black backdrop at 50%, curtains at translateX(0), card at 1 and 1.

The three beats come from durations and delays: backdrop in 0.6 s, curtains in 0.6 s on cubic-bezier(0.65, 0, 0.35, 1), card in 0.4 s with transition-delay: 0.5s and a slight bounce. Measured 250 ms after opening: left curtain still at −334 px out of 450, backdrop at 0.35, card invisible; at 700 ms, curtains closed and card at 0.8; everything is in place around 0.9 s (same values in Chromium 151, Firefox 153 and WebKit 26.5). Closing sets .dialog-curtain-closing, which reverses the order: the card fades in 0.2 s, then curtains and backdrop leave with a 0.15 s delay. The script gives this exit 700 ms before calling dialog.close().

The script (2.3 KB) links each .dialog-curtain-btn button to its dialog through aria-controls, which allows several dialogs on the same page. On opening, it calls showModal(), reads offsetWidth and only then adds the class: the dialog leaves display: none, and without that intermediate calculation the browser would have no starting state and would show the end state directly, with none of the three beats. It intercepts the cancel event (Escape) to play the animated closing instead of a dry disappearance, also closes on a click on the backdrop or the curtains — once closed, they cover the whole screen — and resets the classes on every close event, whatever its origin. The .dialog-curtain-preview thumbnail replays the curtains in small in the page; it is optional.

Accessibility

  • A real modal dialog: showModal() makes the rest of the page inert (no click or tabbing behind), places the dialog above everything and focuses its first button, "Close" (measured in all three engines). On closing, focus returns to the button that opened it (measured in Chromium and Firefox; in Safari, when it was opened with the keyboard). The dialog is named by its title (aria-labelledby) and the button announces aria-haspopup="dialog".
  • Keyboard: Escape closes with the reverse animation; a second Escape during the exit closes at once (measured: dialog closed, normal reopening afterwards). Tabbing stays in the dialog: in Chromium it goes through the browser bar and comes back to "Close", never to the page behind.
  • Reduced motion: under prefers-reduced-motion, every transition of the stage and the dialog is cut: curtains and card take their state directly, and the dialog closes without delay (measured: card at 1 from 250 ms, closed 200 ms after Escape).
  • Contrast: white text on the card (#1e1e2e → #2d2b55) at 16.4 and 13.2:1; the paragraph at 70% opacity stays above 7:1. The "Close" button, white on #6366f1, is at 4.47:1 for 13.6 px bold text: just under the AA threshold, darken it to #4f46e5 (6.29:1) if needed.

Browser compatibility

<dialog> element and showModal(), ::backdrop pseudo-element, CSS transitions + 2.3 KB of JavaScript, no dependency. Measured in Chromium 151, Firefox 153 and WebKit 26.5: same three beats on opening, same reverse closing, animated Escape, focus inside the dialog then returned to the button, two independent dialogs on the same page. showModal() exists since Chrome 37, Firefox 98 and Safari 15.4; the inset property since Chrome 87 and Safari 14.1.

Chrome 87+✓ Full
Firefox 98+✓ Full
Safari 15.4+✓ Full
Edge 87+✓ Full
Mobile iOS 15.4+✓ Full
Android Chrome 87+✓ Full

Without the dialog element (browsers older than 2022), the script does not wire the button and the dialog stays closed: provide a link to a confirmation page if the content is essential. Without JavaScript, same thing: the button does nothing. Under reduced motion, the dialog opens and closes without animation, with the same focus guarantees.

The code

Copy the three blocks into your page. No dependencies.

HTML
<div class="dialog-curtain-stage">
  <div class="dialog-curtain-preview" aria-hidden="true">
    <div class="dialog-curtain-preview-left"></div>
    <div class="dialog-curtain-preview-right"></div>
    <span class="dialog-curtain-preview-label">Stage Curtain</span>
  </div>
  <button class="dialog-curtain-btn" type="button" aria-haspopup="dialog" aria-controls="dialogCurtainModal">Open the dialog</button>
  <dialog class="dialog-curtain-modal" id="dialogCurtainModal" aria-labelledby="dialogCurtainTitle">
    <div class="dialog-curtain-backdrop"></div>
    <div class="dialog-curtain-left"></div>
    <div class="dialog-curtain-right"></div>
    <div class="dialog-curtain-content">
      <h3 id="dialogCurtainTitle">Stage Curtain</h3>
      <p>The content appears once the curtains have closed.</p>
      <button class="dialog-curtain-close" type="button">Close</button>
    </div>
  </dialog>
</div>
CSS
.dialog-curtain-stage,
.dialog-curtain-stage *,
.dialog-curtain-stage *::before,
.dialog-curtain-stage *::after {
  box-sizing: border-box
}

.dialog-curtain-stage {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 250px;
  overflow: hidden;
  position: relative;
  background: rgb(26, 26, 36);
  color: rgb(255, 255, 255);
  font-family: sans-serif;
}

.dialog-curtain-btn {
  padding: 10px 24px;
  font-size: 0.85rem;
  font-weight: 600;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-family: inherit;
  background: #6366f1;
  color: white;
  transition: background 0.2s, transform 0.1s;
}

.dialog-curtain-btn:hover {
  background: #4f46e5;
}

.dialog-curtain-btn:active {
  transform: scale(0.96);
}

.dialog-curtain-preview {
  position: relative;
  width: 200px;
  height: 120px;
  border-radius: 12px;
  overflow: hidden;
  border: 1px solid rgba(255, 255, 255, 0.1);
  background: #0a0a1a;
}

.dialog-curtain-preview-left,
.dialog-curtain-preview-right {
  position: absolute;
  top: 0;
  width: 50%;
  height: 100%;
  transition: transform 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.dialog-curtain-preview-left {
  left: 0;
  background: linear-gradient(135deg, #312e81, #4338ca);
  transform: translateX(-100%);
}

.dialog-curtain-preview-right {
  right: 0;
  background: linear-gradient(225deg, #312e81, #4338ca);
  transform: translateX(100%);
}

.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-left {
  transform: translateX(0);
}

.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-right {
  transform: translateX(0);
}

.dialog-curtain-preview-label {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.8);
  opacity: 0;
  color: white;
  font-weight: 600;
  font-size: 0.75rem;
  z-index: 2;
  transition: opacity 0.3s ease 0.4s, transform 0.3s ease 0.4s;
  white-space: nowrap;
}

.dialog-curtain-preview.dialog-curtain-active .dialog-curtain-preview-label {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

.dialog-curtain-left,
.dialog-curtain-right {
  width: 50%;
  height: 100%;
  background: linear-gradient(135deg, #1a1a2e, #0f172a);
}

.dialog-curtain-left {
  transform-origin: left center;
}

.dialog-curtain-right {
  transform-origin: right center;
}

.dialog-curtain-modal {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  margin: 0;
  padding: 0;
  border: none;
  background: transparent;
  color: #fff;
  overflow: hidden;
  font-family: sans-serif;
}

/* Le fond assombri animé est .dialog-curtain-backdrop : celui du navigateur reste transparent. */
.dialog-curtain-modal::backdrop {
  background: transparent;
}

.dialog-curtain-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0);
  transition: background 0.6s ease;
}

.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-backdrop {
  background: rgba(0, 0, 0, 0.5);
}

.dialog-curtain-modal .dialog-curtain-left,
.dialog-curtain-modal .dialog-curtain-right {
  position: absolute;
  top: 0;
  width: 50%;
  height: 100%;
  transition: transform 0.6s cubic-bezier(0.65, 0, 0.35, 1);
}

.dialog-curtain-modal .dialog-curtain-left {
  left: 0;
  background: linear-gradient(135deg, #1e1b4b, #312e81);
  transform: translateX(-100%);
}

.dialog-curtain-modal .dialog-curtain-right {
  right: 0;
  background: linear-gradient(225deg, #1e1b4b, #312e81);
  transform: translateX(100%);
}

.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-left {
  transform: translateX(0);
}

.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-right {
  transform: translateX(0);
}

.dialog-curtain-content {
  position: absolute;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%) scale(0.8);
  opacity: 0;
  background: linear-gradient(135deg, #1e1e2e, #2d2b55);
  border: 1px solid rgba(255, 255, 255, 0.1);
  border-radius: 20px;
  padding: 32px;
  color: white;
  text-align: center;
  z-index: 2;
  transition: opacity 0.4s ease 0.5s, transform 0.4s cubic-bezier(0.34, 1.56, 0.64, 1) 0.5s;
  min-width: 200px;
}

.dialog-curtain-modal.dialog-curtain-open .dialog-curtain-content {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-content {
  opacity: 0;
  transform: translate(-50%, -50%) scale(0.8);
  transition: opacity 0.2s ease, transform 0.2s ease;
}

.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-left {
  transform: translateX(-100%);
  transition-delay: 0.15s;
}

.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-right {
  transform: translateX(100%);
  transition-delay: 0.15s;
}

.dialog-curtain-modal.dialog-curtain-closing .dialog-curtain-backdrop {
  background: rgba(0, 0, 0, 0);
  transition-delay: 0.15s;
}

.dialog-curtain-content h3 {
  font-size: 1.2rem;
  margin: 0 0 8px;
}

.dialog-curtain-content p {
  margin: 0 0 16px;
  opacity: 0.7;
  font-size: 0.85rem;
}

.dialog-curtain-close {
  padding: 8px 20px;
  background: #6366f1;
  color: white;
  border: none;
  border-radius: 8px;
  cursor: pointer;
  font-family: inherit;
  font-weight: 600;
  font-size: 0.85rem;
}

.dialog-curtain-close:focus-visible {
  outline: 2px solid #c7d2fe;
  outline-offset: 3px;
}

/* Mouvement réduit : pas de rideaux qui glissent, chaque élément prend directement son état. */
@media (prefers-reduced-motion: reduce) {
  .dialog-curtain-stage *,
  .dialog-curtain-modal *,
  .dialog-curtain-modal *::before,
  .dialog-curtain-modal *::after {
    transition: none !important;
  }
}
JavaScript (fx-0716)
(function() {
  /* Dialog Curtain : un vrai <dialog> ouvert en modale (showModal) — le reste de la page devient
     inerte, le focus entre dans la fenêtre et revient au bouton à la fermeture. Les classes
     ne pilotent que l'animation en trois temps : fond, rideaux, puis contenu. */
  var reduce = window.matchMedia('(prefers-reduced-motion: reduce)');

  document.querySelectorAll('.dialog-curtain-btn[aria-controls]').forEach(function(btn) {
    var dialog = document.getElementById(btn.getAttribute('aria-controls'));
    if (!dialog || typeof dialog.showModal !== 'function') return;
    var stage = btn.closest('.dialog-curtain-stage');
    var preview = stage && stage.querySelector('.dialog-curtain-preview');
    var timer = null;

    function reset() {
      clearTimeout(timer);
      timer = null;
      dialog.classList.remove('dialog-curtain-open', 'dialog-curtain-closing');
    }

    function open() {
      if (dialog.open) return;
      reset();
      dialog.showModal();
      void dialog.offsetWidth; /* point de départ des transitions : rideaux ouverts, contenu caché */
      dialog.classList.add('dialog-curtain-open');
      if (preview) preview.classList.add('dialog-curtain-active');
    }

    function close() {
      if (!dialog.open || timer) return;
      dialog.classList.add('dialog-curtain-closing');
      if (preview) preview.classList.remove('dialog-curtain-active');
      timer = setTimeout(function() {
        dialog.close();
      }, reduce.matches ? 0 : 700);
    }

    btn.addEventListener('click', open);
    /* Une fois fermés, les rideaux recouvrent tout le fond : un clic dessus ferme aussi. */
    dialog.querySelectorAll('.dialog-curtain-close, .dialog-curtain-backdrop, .dialog-curtain-left, .dialog-curtain-right').forEach(function(el) {
      el.addEventListener('click', close);
    });
    /* Échap : la fermeture passe par l'animation inverse au lieu d'être immédiate. */
    dialog.addEventListener('cancel', function(e) {
      e.preventDefault();
      close();
    });
    /* Quelle que soit la façon dont la fenêtre se ferme, elle repart propre. */
    dialog.addEventListener('close', function() {
      reset();
      if (preview) preview.classList.remove('dialog-curtain-active');
    });
  });
})();

Customize

Options passed to the API or data-* attributes:

Option / propertyDefaultEffect
Curtains (CSS — .dialog-curtain-modal .dialog-curtain-left and -right) #1e1b4b → #312e81 gradients, 0.6 s on cubic-bezier(0.65, 0, 0.35, 1) A repeating-linear-gradient gives velvet folds; 0.9 s makes the closing more theatrical. Change both curtains together.
Content delay (CSS — transition-delay: 0.5s on .dialog-curtain-content) 0.5 s: the card arrives as the curtains finish 0.2 s brings the card in while the curtains are still sliding; beyond 0.6 s, a dead time sets in between closed curtains and content.
Exit duration (JS — 700 in close()) 700 ms It must roughly cover the CSS exit (0.15 s delay + 0.6 s for the curtains): at 700 ms, the curtains are at 99% of their run, nothing visible is lost. If you lengthen the curtains, lengthen this delay too, otherwise the dialog disappears before they are back.
Darkened backdrop (CSS — rgba(0, 0, 0, 0.5) on .dialog-curtain-open .dialog-curtain-backdrop) black at 50% It only shows while the curtains slide, since they then cover the whole screen. To keep the page visible around, narrow the curtains (40% each): the backdrop then makes full sense.
Close on click (JS — list .dialog-curtain-close, .dialog-curtain-backdrop, .dialog-curtain-left, .dialog-curtain-right) button, backdrop and curtains close Remove the curtains from the list for a confirmation that must not be closed by mistake; keep at least the button and Escape.
Preview thumbnail (HTML — .dialog-curtain-preview) 200 × 120 px, small curtains and a label Delete it without touching the script: it only shows the pattern in the page. The button is enough to open the dialog.

FAQ

The curtains close instead of opening: is that intended?

Yes. The curtains start from the edges and meet in the center, like a stage curtain coming down; the dialog then presents itself in front of them, and they open again on closing. For the opposite effect, curtains that part to reveal the content, put the card under the curtains (z-index), set the curtains at translateX(0) at rest and at ±100% in .dialog-curtain-open, and remove the card's delay.

Why a native dialog element rather than a full-screen div?

A position: fixed div looks like a dialog, but the page behind stays clickable and reachable by keyboard, Escape does nothing and focus stays on the button hidden underneath. showModal() solves all of this at once: inert page, focus inside the dialog then returned to the button, Escape, display above any z-index. The script only adds the animation.

Why does the script read offsetWidth right after showModal()?

Just before showModal(), the dialog is display: none: it has no displayed state to start from. If the .dialog-curtain-open class arrives in the same frame, the browser computes the end state directly and the transitions do not play (curtains already closed, card already visible). Reading offsetWidth forces a calculation of the "curtains open, card hidden" state; the class added afterwards triggers the three beats. @starting-style would do the same job in recent browsers.