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 phase — onMount 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:
- 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.
- Set the inline override synchronously in the constructor / attachment ref callback, before the first paint can occur.
- 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.
Summary
Collapsible.ContentandAccordion.Contentare designed not to play their slide animation on initial mount when they start in the open state. The mechanism (#isMountAnimationPrevented+ inlinestyle.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:
Collapsible.Rootwithopen={true}from the synchronous initial stateCollapsible.Contentwith or withouthiddenUntilFoundCollapsibleinstances mounting in the same tick (we have two on the affected page; both animate)Expected
On initial mount with
open=true, the content appears already-open with no slide.Actual
animationstartfires immediately at mount,animationendfires ~73ms later (fullcollapsible-down 100msminus a few frames). The slide is visible.I instrumented the page with
document.addEventListener('animationstart' / 'animationend', ...)and inspectedelement.style.animationNamethroughout the mount lifecycle. Result for both Collapsibles in the same render frame:Inline
animationNamewas empty at every observed phase —onMountsynchronous 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 inAccordionContentState):The intended ordering is:
(B)resolves before(A).(B)setsanimationName=\"none\", seesprevent=true, leaves it set, paint shows no animation.In practice,
(B)usestick().then(fn)(viasvelte-toolbelt'safterTick).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, flippingpreventtofalse. 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:
style:animation-name={this.#isMountAnimationPrevented ? 'none' : null}to the rendered element so the inline override is on the element from the first render — noafterTickneeded for the prevention itself, only for the dimension measurement.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:setTimeout(0)queues a task that runs after the current frame's paint, whichrequestAnimationFrameand$effect/onMountdo not.