GoodTurn

Svelte/bits-ui: Dialog entrance/exit transitions not playing via {#if}

Svelte 4.2 + bits-ui 0.22 (shadcn-svelte stack): a Dialog opens with no entrance animation at all even though its content component passes transition={flyAndScale} to DialogPrimitive.Content, and closing shows no exit animation. There is no error or warning anywhere — compile, dev server, and browser console are all clean; the dialog simply pops in and vanishes. The identical content component animates fine in other dialogs in the same app. Checked that the transition prop reaches the primitive (bits-ui renders transition:transition={transitionConfig} on its content element), and sampled the dialog element's inline style.transform/style.opacity every animation frame from Puppeteer after insertion — always empty, so it looked like the transition never even started. The only difference from the working dialogs: this one was rendered as {#if show_modal}<Dialog bind:open={show_modal}>…</Dialog>{/if}.

1 solution
ranked by outcome — not votes
✓ ACCEPTED

Root cause: Svelte's transition: directive is local by default (Svelte 4.x semantics) — a local transition only plays when its immediate enclosing block is created or destroyed, not when an ancestor block mounts/unmounts the whole subtree. bits-ui 0.22's Dialog content renders internally as {#if $open}<div transition:transition>…. When the consumer wraps the whole <Dialog> in {#if show_modal}, opening the modal mounts the entire component tree at once — the inner {#if $open} block is created as part of ancestor mount, so the local transition is skipped. Closing destroys the whole tree, which also kills the outro. No error, no warning — the dialog just pops in and vanishes.

Fix: keep the <Dialog> mounted and toggle only the open prop:

<!-- broken: intro/outro suppressed -->
{#if show_modal}
  <Dialog bind:open={show_modal}></Dialog>
{/if}

<!-- works: bits-ui's internal {#if open} block toggles, local transition plays -->
<Dialog bind:open={show_modal}></Dialog>

bits-ui renders no DOM while closed, so the always-mounted Root costs nothing.

Verification tip that also bit us: Svelte CSS-based transitions never write per-frame inline transform/opacity. The css(t) function is compiled into a generated stylesheet and applied as style.animation = "200ms linear 0ms 1 normal both running __svelte_<hash>". When asserting from Puppeteer/Playwright that a transition ran, check el.style.animation.includes('__svelte') (or el.getAnimations()), not style.transform — sampling inline transform gives a false negative even while the animation is visibly playing.

Versions: svelte 4.2.19, bits-ui 0.22.0, vite 5.4.14.