Skip to content

Mount-animation prevention races against rAF flag flip — Collapsible/Accordion slide on initial mount when open #2039

@MathiasWP

Description

@MathiasWP

Summary

Collapsible.Content and Accordion.Content are designed not to play their slide animation on initial mount when they start in the open state. The mechanism (#isMountAnimationPrevented + inline style.animationName = "none") works in isolation but is sensitive to a timing race that complex apps can lose, causing the slide animation to play on first paint.

Repro

I haven't isolated a minimal repro — the bug manifests in a real-world app with several mounting components on the same page. Repro conditions:

  • bits-ui 2.17.3, Svelte 5.55.4
  • Collapsible.Root with open={true} from the synchronous initial state
  • Collapsible.Content with or without hiddenUntilFound
  • Multiple Collapsible instances mounting in the same tick (we have two on the affected page; both animate)
  • Page tab must be visible (background tabs throttle the animations and mask the bug)

Expected

On initial mount with open=true, the content appears already-open with no slide.

Actual

animationstart fires immediately at mount, animationend fires ~73ms later (full collapsible-down 100ms minus a few frames). The slide is visible.

I instrumented the page with document.addEventListener('animationstart' / 'animationend', ...) and inspected element.style.animationName throughout the mount lifecycle. Result for both Collapsibles in the same render frame:

53397ms  animationstart  collapsible-down  inline animationName=""
53470ms  animationend    collapsible-down  inline animationName=""

Inline animationName was empty at every observed phaseonMount synchronous body, await tick(), single rAF, double rAF, setTimeout(0). The mechanism never left "none" on the element.

Root cause

The prevention mechanism in CollapsibleContentState (mirrored in AccordionContentState):

this.#isMountAnimationPrevented = root.opts.open.current;  // true if open at mount

$effect.pre(() => {
    requestAnimationFrame(() => {
        this.#isMountAnimationPrevented = false;  // (A) flip
    });
});

watch([() => this.opts.ref.current, () => this.present], ([node]) => {
    if (!node) return;
    afterTick(() => {                                      // (B) microtask
        element.style.animationName = "none";              // set
        const rect = node.getBoundingClientRect();
        // ...
        if (!this.#isMountAnimationPrevented) {
            element.style.animationName = originalStyles.animationName;  // restore
        }
    });
});

The intended ordering is: (B) resolves before (A). (B) sets animationName=\"none\", sees prevent=true, leaves it set, paint shows no animation.

In practice, (B) uses tick().then(fn) (via svelte-toolbelt's afterTick). tick() resolves only after Svelte finishes flushing all pending updates. In a heavy mount cycle that flush can cross an animation-frame boundary — at which point (A)'s rAF callback fires first, flipping prevent to false. When (B) finally resolves, it takes the restore branch and undoes its own override. The browser paints with no inline override and the CSS animation plays normally.

The race outcome is non-deterministic and depends on how much work the surrounding page does on mount.

Suggested fixes

Any of these would close the race; (1) is the simplest and most local:

  1. Set the override in the template, not in a microtask. Add style:animation-name={this.#isMountAnimationPrevented ? 'none' : null} to the rendered element so the inline override is on the element from the first render — no afterTick needed for the prevention itself, only for the dimension measurement.
  2. Set the inline override synchronously in the constructor / attachment ref callback, before the first paint can occur.
  3. Drop the rAF-based flag flip in favor of a deterministic signal — e.g., flip the flag the first time the consumer toggles open, not on a frame boundary.

Happy to send a PR if a maintainer agrees on the approach.

Workaround

Override the duration via CSS until a setTimeout(0) resolves after first paint:

<script lang=\"ts\">
    let mounted = \$state(false);
    onMount(() => {
        const id = setTimeout(() => { mounted = true; }, 0);
        return () => clearTimeout(id);
    });
</script>

<Collapsible.Content
    class={[
        'data-[state=open]:animate-collapsible-down data-[state=closed]:animate-collapsible-up overflow-hidden',
        !mounted && '![animation-duration:0s]'
    ]}
>
    <!-- ... -->
</Collapsible.Content>

setTimeout(0) queues a task that runs after the current frame's paint, which requestAnimationFrame and $effect/onMount do not.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions