Skip to content

Commit 277370a

Browse files
fix: properly delay intro transitions (#12389)
* fix: properly delay intro transitions WAAPI applies the styles of a delayed animation only when that animation starts. In the case of fade-in transitions that means the element is visible, then goes invisible and fades in. Fix that by never applying a delay on intro transitions, instead add keyframes of the initial state for the duration of the delay. Fixes #10876 * fix bug, make test pass * make test more selfcontained, test outro delay aswell and add functionality for that in animation-helpers * lint --------- Co-authored-by: Rich Harris <[email protected]>
1 parent f846cb4 commit 277370a

File tree

5 files changed

+93
-7
lines changed

5 files changed

+93
-7
lines changed

.changeset/tricky-ears-shout.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'svelte': patch
3+
---
4+
5+
fix: properly delay intro transitions

packages/svelte/src/internal/client/dom/elements/transitions.js

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -268,6 +268,8 @@ export function transition(flags, element, get_fn, get_params) {
268268
* @returns {import('#client').Animation}
269269
*/
270270
function animate(element, options, counterpart, t2, callback) {
271+
var is_intro = t2 === 1;
272+
271273
if (is_function(options)) {
272274
// In the case of a deferred transition (such as `crossfade`), `option` will be
273275
// a function rather than an `AnimationConfig`. We need to call this function
@@ -276,7 +278,7 @@ function animate(element, options, counterpart, t2, callback) {
276278
var a;
277279

278280
queue_micro_task(() => {
279-
var o = options({ direction: t2 === 1 ? 'in' : 'out' });
281+
var o = options({ direction: is_intro ? 'in' : 'out' });
280282
a = animate(element, o, counterpart, t2, callback);
281283
});
282284

@@ -322,15 +324,25 @@ function animate(element, options, counterpart, t2, callback) {
322324
var keyframes = [];
323325
var n = Math.ceil(duration / (1000 / 60)); // `n` must be an integer, or we risk missing the `t2` value
324326

327+
// In case of a delayed intro, apply the initial style for the duration of the delay;
328+
// else in case of a fade-in for example the element would be visible until the animation starts
329+
if (is_intro && delay > 0) {
330+
let m = Math.ceil(delay / (1000 / 60));
331+
let keyframe = css_to_keyframe(css(0, 1));
332+
for (let i = 0; i < m; i += 1) {
333+
keyframes.push(keyframe);
334+
}
335+
}
336+
325337
for (var i = 0; i <= n; i += 1) {
326338
var t = t1 + delta * easing(i / n);
327339
var styles = css(t, 1 - t);
328340
keyframes.push(css_to_keyframe(styles));
329341
}
330342

331343
animation = element.animate(keyframes, {
332-
delay,
333-
duration,
344+
delay: is_intro ? 0 : delay,
345+
duration: duration + (is_intro ? delay : 0),
334346
easing: 'linear',
335347
fill: 'forwards'
336348
});

packages/svelte/tests/animation-helpers.js

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ class Animation {
3535
#target;
3636
#keyframes;
3737
#duration;
38+
#delay;
3839

3940
#offset = raf.time;
4041

@@ -47,12 +48,13 @@ class Animation {
4748
/**
4849
* @param {HTMLElement} target
4950
* @param {Keyframe[]} keyframes
50-
* @param {{ duration: number }} options // TODO add delay
51+
* @param {{ duration: number, delay: number }} options
5152
*/
52-
constructor(target, keyframes, { duration }) {
53+
constructor(target, keyframes, { duration, delay }) {
5354
this.#target = target;
5455
this.#keyframes = keyframes;
5556
this.#duration = duration;
57+
this.#delay = delay ?? 0;
5658

5759
// Promise-like semantics, but call callbacks immediately on raf.tick
5860
this.finished = {
@@ -73,7 +75,9 @@ class Animation {
7375
}
7476

7577
_update() {
76-
this.currentTime = raf.time - this.#offset;
78+
this.currentTime = raf.time - this.#offset - this.#delay;
79+
if (this.currentTime < 0) return;
80+
7781
const target_frame = this.currentTime / this.#duration;
7882
this.#apply_keyframe(target_frame);
7983

@@ -168,7 +172,7 @@ function interpolate(a, b, p) {
168172

169173
/**
170174
* @param {Keyframe[]} keyframes
171-
* @param {{duration: number}} options
175+
* @param {{duration: number, delay: number}} options
172176
* @returns {globalThis.Animation}
173177
*/
174178
HTMLElement.prototype.animate = function (keyframes, options) {
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import { flushSync } from '../../../../src/index-client.js';
2+
import { test } from '../../test';
3+
4+
export default test({
5+
test({ assert, raf, target }) {
6+
const btn = target.querySelector('button');
7+
8+
// in
9+
btn?.click();
10+
flushSync();
11+
assert.htmlEqual(
12+
target.innerHTML,
13+
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
14+
);
15+
raf.tick(1);
16+
assert.htmlEqual(
17+
target.innerHTML,
18+
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
19+
);
20+
21+
raf.tick(99);
22+
assert.htmlEqual(
23+
target.innerHTML,
24+
'<button>toggle</button><p style="opacity: 0;">delayed fade</p>'
25+
);
26+
27+
raf.tick(150);
28+
assert.htmlEqual(
29+
target.innerHTML,
30+
'<button>toggle</button><p style="opacity: 0.5;">delayed fade</p>'
31+
);
32+
33+
raf.tick(200);
34+
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
35+
36+
// out
37+
btn?.click();
38+
flushSync();
39+
raf.tick(275);
40+
assert.htmlEqual(target.innerHTML, '<button>toggle</button><p style="">delayed fade</p>');
41+
42+
raf.tick(350);
43+
assert.htmlEqual(
44+
target.innerHTML,
45+
'<button>toggle</button><p style="opacity: 0.5;">delayed fade</p>'
46+
);
47+
}
48+
});
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
<script>
2+
function fade(_) {
3+
return {
4+
delay: 100,
5+
duration: 100,
6+
css: (t) => `opacity: ${t}`
7+
};
8+
}
9+
10+
let visible = $state(false);
11+
</script>
12+
13+
<button onclick={() => (visible = !visible)}>toggle</button>
14+
15+
{#if visible}
16+
<p transition:fade>delayed fade</p>
17+
{/if}

0 commit comments

Comments
 (0)