Transitions & Animations
transition animate element ตอนเข้าและออก
หัวข้อที่มีชื่อว่า “transition animate element ตอนเข้าและออก”เมื่อ element ถูกเพิ่มเข้าหรือลบออกจาก DOM (ผ่าน {#if}, {#each} ฯลฯ) transition animate การเปลี่ยนแปลงนั้น ใช้ด้วย transition: directive โดยใช้ตัวที่มีมาให้จาก svelte/transition:
<script> import { fade } from 'svelte/transition'; let visible = $state(true);</script>
<button onclick={() => (visible = !visible)}>toggle</button>
{#if visible} <p transition:fade>Fades in when added, fades out when removed</p>{/if}transition: ใช้กับ ทั้ง เข้าและออก เมื่อต้องการ animation ต่างกันสำหรับแต่ละอัน ใช้ in: และ out: แยกกัน:
<script> import { fly, fade } from 'svelte/transition';</script>
{#if visible} <div in:fly={{ y: 20 }} out:fade>Flies in, fades out</div>{/if}ตัวที่มีมาให้รวมถึง fade, fly, slide, scale, blur และ draw (สำหรับ SVG) แต่ละอันรับพารามิเตอร์ — duration, delay, easing และ option เฉพาะ transition เช่น x/y ของ fly:
<p transition:fly={{ y: 50, duration: 400, delay: 100 }}>…</p>local โดย default — และ modifier |global
หัวข้อที่มีชื่อว่า “local โดย default — และ modifier |global”transition เป็น local โดย default: มันเล่นแค่ตอน block ที่มันเป็นสมาชิกโดยตรงถูกสร้างหรือทำลาย ไม่ใช่ ตอน block ที่อยู่สูงกว่าใน tree ถูกสร้าง/ทำลาย นี่กลายเป็น default ตั้งแต่ Svelte 4 (เพื่อไม่ให้ transition ที่ nest อยู่เล่นซ้ำตอน navigate หน้าที่ไม่เกี่ยวข้อง) และ Svelte 5 ยังคงกติกาเดิม เพิ่ม |global เพื่อให้ transition เล่นซ้ำทุกครั้งที่ ancestor block ไหนก็ตาม ถูกสร้างหรือทำลาย ไม่ใช่แค่ของตัวมันเอง:
<script lang="ts"> import { fade } from 'svelte/transition'; let { outer, inner, onIntro }: { outer: boolean; inner: boolean; onIntro: (which: string) => void } = $props();</script>{#if outer} {#if inner} <p transition:fade onintrostart={() => onIntro('local')}>local</p> <p transition:fade|global onintrostart={() => onIntro('global')}>global</p> {/if}{/if}การ toggle block outer ปิดแล้วเปิดใหม่ — โดยไม่แตะ inner เลย — จะเล่นซ้ำแค่ intro ของ paragraph ที่เป็น |global เท่านั้น ตัว local จะถูกข้าม เพราะ block ที่มันเป็นสมาชิกโดยตรง (inner {#if}) ไม่ใช่ตัวที่เปลี่ยน:
// jsdom has no Web Animations API; Svelte's transition engine creates a// zero-duration "dummy" animation via element.animate() and waits for its// onfinish before running the real one. This stub resolves on a microtask,// which is enough to drive the transition lifecycle (onintrostart etc.) in// a test without a real browser.if (typeof Element !== 'undefined' && !('animate' in Element.prototype)) { // @ts-ignore Element.prototype.animate = function () { const anim: any = { onfinish: null, oncancel: null, cancel() {}, finish() { anim.onfinish?.(); } }; queueMicrotask(() => anim.onfinish && anim.onfinish()); return anim; };}import './animate-polyfill';import { render } from '@testing-library/svelte';import { tick } from 'svelte';import { expect, test } from 'vitest';import LocalVsGlobal from './LocalVsGlobal.svelte';
async function settle() { await new Promise((r) => setTimeout(r, 0)); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await tick();}
test('a local transition only replays when its OWN block is (re)created; |global also replays when an ancestor block is', async () => { const seen: string[] = []; const onIntro = (which: string) => seen.push(which); const { rerender } = render(LocalVsGlobal, { props: { outer: true, inner: true, onIntro } }); await settle(); seen.length = 0; // discard the initial-mount intros
// Toggle the OUTER block off then on again WITHOUT touching `inner`. await rerender({ outer: false, inner: true, onIntro }); await settle(); await rerender({ outer: true, inner: true, onIntro }); await settle();
expect(seen).toEqual(['global']); // local was skipped; only the |global one replayed});✓ a local transition only replays when its OWN block is (re)created; |global also replays when an ancestor block is
Test Files 1 passed (1) Tests 1 passed (1){#key} เพื่อ re-trigger transition
หัวข้อที่มีชื่อว่า “{#key} เพื่อ re-trigger transition”{#key} block ทำลายแล้วสร้างเนื้อหาข้างในใหม่ทุกครั้งที่ key expression เปลี่ยน — วิธีมาตรฐานในการเล่น transition ซ้ำตอนค่าเปลี่ยน ไม่ใช่แค่ตอน add/remove:
<script lang="ts"> import { fade } from 'svelte/transition'; let { value, onIntro }: { value: number; onIntro: () => void } = $props();</script>{#key value} <p transition:fade onintrostart={onIntro}>{value}</p>{/key}import './animate-polyfill';import { render } from '@testing-library/svelte';import { tick } from 'svelte';import { expect, test } from 'vitest';import KeyReset from './KeyReset.svelte';
async function settle() { await new Promise((r) => setTimeout(r, 0)); await Promise.resolve(); await Promise.resolve(); await Promise.resolve(); await tick();}
test('{#key} re-triggers the intro transition only when the key expression actually changes', async () => { const intros: number[] = []; const { rerender } = render(KeyReset, { props: { value: 1, onIntro: () => intros.push(1) } }); await settle(); await rerender({ value: 1, onIntro: () => intros.push(1) }); // same value: no new block, no re-intro await settle(); await rerender({ value: 2, onIntro: () => intros.push(1) }); // changed value: {#key} tears down + rebuilds await settle(); expect(intros.length).toBe(1);});✓ {#key} re-triggers the intro transition only when the key expression actually changes
Test Files 1 passed (1) Tests 1 passed (1)animate: สำหรับเรียง list ใหม่
หัวข้อที่มีชื่อว่า “animate: สำหรับเรียง list ใหม่”transition จัดการ add/remove แต่ไม่จัดการ การเคลื่อนที่ เมื่อ item ใน keyed {#each} เรียงใหม่ animate: directive เลื่อน item ไปตำแหน่งใหม่อย่างลื่น ตัวที่มีมาให้คือ flip (First-Last-Invert-Play) จาก svelte/animate ทำสิ่งนี้พอดี:
<script> import { flip } from 'svelte/animate'; let items = $state([1, 2, 3, 4]); function shuffle() { items = items.toSorted(() => Math.random() - 0.5); }</script>
<button onclick={shuffle}>shuffle</button>
{#each items as item (item)} <div animate:flip={{ duration: 300 }}>{item}</div>{/each}animate: ต้องการ each block ที่ keyed — key (item) คือวิธีที่ Svelte รู้ว่า element ไหนย้ายไปไหน ง่ายที่จะสมมติว่า transition: เพียงอย่างเดียวจะเลื่อน item ที่เรียงใหม่อย่างลื่น — มันไม่ทำแบบนั้น เพราะการเรียง keyed each block ใหม่ reuse DOM node เดิม แทนที่จะลบแล้วเพิ่มใหม่ ดังนั้นไม่มี intro/outro ใดถูก trigger สำหรับการย้ายนั้นเลย:
<script lang="ts"> let { items }: { items: number[] } = $props(); function noop(_node: HTMLElement) { return {}; }</script><ul> {#each items as item (item)} <li data-item={item} transition:noop>{item}</li> {/each}</ul>import { render } from '@testing-library/svelte';import { flushSync } from 'svelte';import { expect, test } from 'vitest';import ReorderNoFlip from './ReorderNoFlip.svelte';
test('reordering a keyed each reuses the same DOM node — no add/remove happens, so transition: never fires for the move', () => { const { container, rerender } = render(ReorderNoFlip, { props: { items: [1, 2, 3] } }); const nodeFor1 = container.querySelector('[data-item="1"]'); rerender({ items: [3, 2, 1] }); flushSync(); const nodeFor1After = container.querySelector('[data-item="1"]'); expect(nodeFor1After).toBe(nodeFor1); // same DOM node instance, just moved — proves no add/remove});✓ reordering a keyed each reuses the same DOM node — no add/remove happens, so transition: never fires for the move
Test Files 1 passed (1) Tests 1 passed (1)รวม animate:flip กับ transition: บน list เดียวกันเพื่อ animate การเพิ่ม ลบ และ เรียงใหม่พร้อมกัน — animate: คือส่วนที่ตกหล่นได้ง่าย เพราะทุกอย่างยัง compile และ “เกือบ” ทำงานถูก (add/remove ยัง animate) แม้ไม่มีมันก็ตาม
Custom transition: css และ tick
หัวข้อที่มีชื่อว่า “Custom transition: css และ tick”custom transition function รับ node กับพารามิเตอร์ของมัน แล้ว return object ที่อธิบาย animation — ไม่ว่าจะเป็น function css(t, u) (Svelte แปลงมันเป็น Web Animation ทำให้รันนอก main thread ได้) หรือ function tick(t, u) (run ทุก frame สำหรับ effect ที่ css ทำไม่ได้):
<script lang="ts"> import { elasticOut } from 'svelte/easing'; let visible = $state(true);
function whoosh(node: HTMLElement, params: { delay?: number; duration?: number } = {}) { const existingTransform = getComputedStyle(node).transform.replace('none', ''); return { delay: params.delay ?? 0, duration: params.duration ?? 400, easing: elasticOut, css: (t: number, u: number) => `transform: ${existingTransform} scale(${t})`, }; }</script>{#if visible} <div in:whoosh>whooshes in</div>{/if}t ไล่จาก 0 ไป 1 ตอน intro (1 ไป 0 ตอน outro); u คือ 1 - t เลือกใช้ css เมื่อไหร่ที่ effect นั้นเขียนเป็น CSS ได้ — มันคือสิ่งที่ทำให้ Svelte ส่ง animation ให้ compositor ของเบราว์เซอร์แทนที่จะรัน function ของคุณทุก frame
เคารพ prefers-reduced-motion
หัวข้อที่มีชื่อว่า “เคารพ prefers-reduced-motion”transition รันผ่าน Web Animations API ไม่ใช่ CSS — ดังนั้น rule @media (prefers-reduced-motion: reduce) ที่ครอบทั้งหน้าซึ่ง zero animation-duration ไม่มีผล กับมันเลย ทางแก้ของ Svelte เองคือ prefersReducedMotion (จาก svelte/motion เพิ่มใน Svelte 5.7): ค่า reactive ที่ผูกกับ media query ทำให้คุณอ่าน .current แล้วปรับ transition เองได้:
<script lang="ts"> import { prefersReducedMotion } from 'svelte/motion'; import { fly } from 'svelte/transition'; let visible = $state(true);</script>{#if visible} <p transition:fly={{ y: prefersReducedMotion.current ? 0 : 200 }}>hi</p>{/if}ด้วย y: 0 “fly” จะกลายเป็น transition ที่แทบจะทันที แทนที่จะเป็น motion ใหญ่ — เคารพ preference ระดับระบบของ user โดยไม่ต้องปิดกลไก transition ไปเลยทั้งหมด
transition ข้างใน pending boundary
หัวข้อที่มีชื่อว่า “transition ข้างใน pending boundary”<svelte:boundary> ที่มี pending snippet จะโชว์เนื้อหา placeholder ระหว่างที่ await expression ข้างในกำลัง resolve แล้วสลับไปเนื้อหาจริงเมื่อเสร็จ (อธิบายเต็มในบท Async Svelte ซึ่งยัง experimental อยู่หลัง compilerOptions.experimental.async) การสลับนั้นคือการ add DOM ธรรมดา — transition: บนเนื้อหาที่เพิ่งเผยออกมาจะเล่นเหมือนที่มันเล่นหลัง {#if} ทั่วไปทุกประการ:
<script lang="ts"> import { fade } from 'svelte/transition'; async function loadMessage() { await new Promise((r) => setTimeout(r, 10)); return 'loaded!'; }</script><svelte:boundary> <p transition:fade>{await loadMessage()}</p> {#snippet pending()} <p>loading…</p> {/snippet}</svelte:boundary>flowchart LR add["element added to DOM"] --> intro["in: / transition: plays the intro"] remove["element removed"] --> outro["out: / transition: plays the outro"] outro --> gone["element leaves after the animation"] reorder["keyed each reorders (same node, no add/remove)"] --> flip["only animate:flip moves it smoothly"]
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”transition ถูก trigger ด้วยการ add หรือ remove DOM เท่านั้น จบ — ไม่ใช่ด้วย “ค่าเปลี่ยน” หรือ “ลำดับ list เปลี่ยน” {#if}/{#each}/{#await}/{#key} คือสิ่งเดียวที่ add และ remove node ดังนั้นมันคือที่เดียวที่ transition:/in:/out: จะ fire ได้ animate: มีอยู่เพราะการเรียง keyed each block ใหม่คือกรณีทั่วไปกรณีเดียวที่ Svelte ตั้งใจ reuse node เดิมแทนที่จะทำลายแล้วสร้างใหม่ (เพื่อ performance และรักษา focus/scroll/input state) มันเลยต้องมีกลไก animation ของตัวเองแยกต่างหาก พอเห็นว่า “transition = add/remove, animate = เปลี่ยนตำแหน่ง” ที่เหลือ (local เทียบ global, {#key} บังคับ add/remove ใหม่) ก็ตามมาจากกฎข้อเดียวนั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| local transition (default) | transition ที่ nest ไม่เล่นซ้ำตอน ancestor เปลี่ยนแบบไม่เกี่ยวข้อง (เช่น page navigation) | อาจแปลกใจง่ายเมื่อ transition ที่คาดว่าจะเล่นซ้ำไม่เล่น ถ้ามัน nest ลึกกว่าที่คิด |
|global transition | เล่นซ้ำเมื่อ ancestor ไหนก็ตามถูกสร้าง/ทำลาย — ตรงกับ “ควร animate เสมอเมื่อปรากฏ” | trigger เกินได้: ancestor toggle ไหนก็เล่นซ้ำ รวมถึงตัวที่ไม่เกี่ยวกับความหมายของ element นี้เอง |
{#key} เพื่อบังคับ replay | วิธีง่าย ชัดเจนในการบอกว่า “ปฏิบัติกับสิ่งนี้เหมือน element ใหม่เอี่ยมตอน X เปลี่ยน” | ทำลายแล้วสร้าง state ทั้ง block ใหม่ (component state ข้างในหายไปด้วย) ไม่ใช่แค่ DOM ของมัน |
animate:flip คู่กับ transition: | เรียงใหม่ได้ลื่นและถูกต้อง แทบไม่ต้องเขียนโค้ดเพิ่ม | ต้องการ keyed each block — list ที่ไม่ keyed ใช้ animate: ไม่ได้เลย |
custom css transition | รันนอก main thread (Web Animations API) | ต้องเขียน effect เป็น CSS property ได้ — tick จำเป็นสำหรับสิ่งที่ CSS ทำไม่ได้ |
ความเข้าใจผิดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ความเข้าใจผิดที่พบบ่อย”- “
transition:จะ animate item ที่เรียงใหม่ใน keyed{#each}อย่างลื่น” ไม่จริง — พิสูจน์ด้านบน: การเรียงใหม่ reuse DOM node เดิม (ไม่มี add/remove) จึงไม่มี intro/outro ถูก fire สำหรับการย้ายเลย มีแค่animate:flipเท่านั้นที่จัดการการเรียงใหม่ - “rule
prefers-reduced-motionของ CSS ที่ครอบทั้งหน้าจะลด transition ของ Svelte ให้อ่อนลงอัตโนมัติ” ไม่จริง — transition รันผ่าน Web Animations API ไม่ใช่ propertyanimation/transitionของ CSS ดังนั้น media query นั้นไม่มีผลกับมันโดย default ใช้prefersReducedMotionจากsvelte/motionแทน - “local เป็นแค่ default เก่า
|globalคือวิธีเขียน transition แบบสมัยใหม่” ตรงกันข้าม: local เป็น default มาตั้งแต่ Svelte 4 โดยตั้งใจเพื่อ เลี่ยง ไม่ให้ transition เล่นซ้ำตอน ancestor เปลี่ยนแบบไม่เกี่ยวข้อง;|globalคือ opt-in ที่ตั้งใจสำหรับกรณีที่พบน้อยกว่า - “
{#key}มีประโยชน์แค่กับ list{#each}” มันทำงานกับ expression อะไรก็ได้ — การใช้ทั่วไปคือบังคับ remount เต็มรูปแบบ (และเล่น transition ซ้ำ) เมื่อ route param หรือ selected item id เปลี่ยน โดยไม่มี list เกี่ยวข้องเลย
อ่านโค้ดที่ AI เขียน
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI เขียน”เมื่อ component ที่ generate มาใช้ transition หรือ animation ให้ตรวจสิ่งเหล่านี้ก่อนเชื่อมัน:
- keyed
{#each}พึ่งพาtransition:เพียงอย่างเดียวเพื่อ animate การเรียงใหม่ โดยไม่มีanimate:flipหรือเปล่า? - transition ถูก mark
|globalทั้งที่จริง ๆ ตั้งใจให้เล่นแค่ตอน block ที่เป็นสมาชิกโดยตรงของมันเปลี่ยน (trigger เกินตอน ancestor toggle ที่ไม่เกี่ยวข้อง) หรือเปล่า? - โค้ดสมมติว่า rule CSS
prefers-reduced-motionที่ครอบทั้งหน้าจะปิดเสียง transition ของ Svelte แทนที่จะอ่านprefersReducedMotionจากsvelte/motionหรือเปล่า? {#key}ถูกใช้ในที่ที่{#if}/การอัปเดต reactive prop ธรรมดาก็พอแล้ว — ทำลายแล้วสร้าง component state ใหม่โดยไม่จำเป็นแค่เพื่อเล่น transition ซ้ำหรือเปล่า?- function
cssของ custom transition เมิน parameteru(1 - t) ทั้งที่ effect จริง ๆ ต้องการทั้งคู่ (เช่น รวม property สองตัวที่เคลื่อนไปคนละทิศ) หรือเปล่า?
🐛 หา bug ในโค้ดนี้
<script>import { fade } from 'svelte/transition';let items = $state([ { id: 1, label: 'Alpha' }, { id: 2, label: 'Beta' }, { id: 3, label: 'Gamma' },]);function sortAlpha() { items = items.toSorted((a, b) => a.label.localeCompare(b.label));}</script>
<button onclick={sortAlpha}>Sort A→Z</button>{#each items as item (item.id)}<div transition:fade>{item.label}</div>{/each}เฉลย
list ถูก key ถูกต้อง ((item.id)) ดังนั้น Svelte รู้แน่ชัดว่าแถวไหนย้ายไปไหน — แต่ transition:fade fire แค่ตอน add กับ remove เท่านั้น การเรียงใหม่ทำให้แถวที่มีอยู่แล้วเปลี่ยนตำแหน่ง ไม่มีแถวไหนถูก add หรือ remove ดังนั้น sortAlpha() จะทำให้เกิดการกระโดดทันทีที่กระตุกตา ไม่มี animation เลย ทั้งที่โค้ด “มี transition” พิสูจน์ด้านบน (reorder.test.ts): DOM node เดิมรอดจากการเรียงใหม่โดยไม่ถูกแตะ ดังนั้นไม่มี intro/outro ถูก trigger สำหรับการย้ายนั้นเลย
วิธีแก้คือเพิ่ม animate:flip (ส่วนที่จัดการการเปลี่ยนตำแหน่งจริง ๆ):
<script> import { flip } from 'svelte/animate';</script>{#each items as item (item.id)} <div transition:fade animate:flip={{ duration: 250 }}>{item.label}</div>{/each}ตอนนี้ add/remove ยัง fade (transition:) และทุกการเรียงใหม่จะเลื่อนไปตำแหน่งใหม่อย่างลื่น (animate:) — สอง directive นี้เสริมกัน ไม่ใช่ซ้ำซ้อนกัน