Events & Attributes
Events are just properties now
Section titled “Events are just properties now”In Svelte 5, DOM event handlers are regular attributes named like the property: onclick, oninput, onsubmit. You pass a function, exactly as you would in plain HTML/JS.
<script> let count = $state(0);</script>
<button onclick={() => count++}>+1</button><input oninput={(e) => console.log(e.currentTarget.value)} />This is a deliberate change from Svelte 4, which used a special on:click directive. If you read older code or tutorials, you’ll see on:click={handler} — the modern equivalent is onclick={handler}. The new form is just a property, so it composes naturally and can be spread with other attributes.
Event delegation: one listener, not one per element
Section titled “Event delegation: one listener, not one per element”Svelte doesn’t attach a native listener to every element with an onclick. The docs are direct about this:
To reduce memory footprint and increase performance, Svelte uses a technique called event delegation. This means that for certain events… a single event listener at the application root takes responsibility for running any handlers on the event’s path.
You can see it directly in the compiler’s own output — compiling a trivial counter with svelte/compiler’s compile():
import { test, expect } from 'vitest';import { compile } from 'svelte/compiler';
const source = `<script> let count = $state(0);</script><button onclick={() => count++}>clicked {count} times</button>`;
test('a click handler compiles to delegated dispatch, not a per-element listener', () => { const { js } = compile(source, { filename: 'Tiny.svelte' }); // No addEventListener anywhere in the output... expect(js.code).not.toMatch(/addEventListener/); // ...instead, the handler is registered for delegation, and the event // type is collected into a single call that wires up the root listener. expect(js.code).toMatch(/\$\.delegated\('click', button, /); expect(js.code).toMatch(/\$\.delegate\(\['click'\]\)/);});✓ a click handler compiles to delegated dispatch, not a per-element listenerThe consequence: a stopPropagation() between the root and your element can eat the event
Section titled “The consequence: a stopPropagation() between the root and your element can eat the event”Because Svelte’s own dispatch logic only runs once the native event has actually reached the root, anything in between that stops native bubbling prevents Svelte from ever finding out the click happened — even though the click visibly landed on your button. The docs call this out explicitly:
When using
addEventListenerdirectly, avoid callingstopPropagation, as it prevents the event from reaching the root and invoking handlers.
Proven with a real ancestor that does exactly that — a plain addEventListener (via an attachment, so it runs on a real DOM node) between the root and the button, calling stopPropagation():
<script lang="ts"> let { events }: { events: string[] } = $props();
function blockBubble(node: HTMLElement) { function handle(e: MouseEvent) { events.push('ancestor-vanilla-listener'); e.stopPropagation(); // stops NATIVE bubbling before it reaches the app root } node.addEventListener('click', handle); return () => node.removeEventListener('click', handle); }</script>
<div {@attach blockBubble}> <button onclick={() => events.push('svelte-onclick')}>click</button></div>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import DelegationDemo from './DelegationDemo.svelte';
test('a vanilla stopPropagation() on an ancestor prevents the delegated Svelte onclick from firing', async () => { const events: string[] = []; render(DelegationDemo, { props: { events } }); await fireEvent.click(screen.getByRole('button')); // The ancestor's own listener ran — but the button's onclick never did. expect(events).toEqual(['ancestor-vanilla-listener']);});✓ a vanilla stopPropagation() on an ancestor prevents the delegated Svelte onclick from firingThis is exactly why the docs recommend on (imported from svelte/events) over raw addEventListener for code that has to coexist with Svelte’s own handlers: it “ensure[s] correct execution order and proper handling of stopPropagation” instead of silently racing against a mechanism your code can’t see.
onclickcapture, and on for passive/once
Section titled “onclickcapture, and on for passive/once”Two more DOM event details Svelte 5 keeps directly reachable, without an old-style modifier syntax:
- The capture phase: append
captureto the event name —onclickcapture={...}fires during the capture phase, before delegated bubble-phase handlers. passive/once: these require the listener to know its own options when it’s attached, which a plain attribute can’t express — so use theonfunction fromsvelte/eventsinstead, inside an attachment or$effect.
<script lang="ts"> import { on } from 'svelte/events'; let { events }: { events: string[] } = $props();
function passiveOnce(node: HTMLElement) { // `on` returns a cleanup function, like addEventListener + removeEventListener in one call. return on(node, 'click', () => events.push('on()-passive-once'), { passive: true, once: true }); }</script>
<div onclickcapture={() => events.push('capture')}> <button {@attach passiveOnce} onclick={() => events.push('bubble-onclick')}>click</button></div>import { test, expect } from 'vitest';import { render, screen, fireEvent } from '@testing-library/svelte';import CaptureOnce from './CaptureOnce.svelte';
test('onclickcapture runs in the capture phase; on() with { once: true } only fires once', async () => { const events: string[] = []; render(CaptureOnce, { props: { events } }); const button = screen.getByRole('button');
await fireEvent.click(button); // Capture phase first, then the on()-attached listener, then the delegated onclick. expect(events).toEqual(['capture', 'on()-passive-once', 'bubble-onclick']);
await fireEvent.click(button); // Second click: capture and the delegated onclick fire again — but on()'s // `once: true` listener has already removed itself. expect(events).toEqual([ 'capture', 'on()-passive-once', 'bubble-onclick', 'capture', 'bubble-onclick', ]);});✓ onclickcapture runs in the capture phase; on() with { once: true } only fires onceAttribute shorthands and spreads
Section titled “Attribute shorthands and spreads”When a variable has the same name as the attribute, use the shorthand {value}:
<script> let src = $state('/logo.png'); let alt = $state('Logo');</script>
<img {src} {alt} /> <!-- shorthand for src={src} alt={alt} -->Spread a whole object of attributes with {...obj} — handy for forwarding props to an element:
<script> let { ...rest } = $props(); // collect remaining props</script>
<button {...rest}>click</button> <!-- forward them all to the button -->Ordering matters: later always wins
Section titled “Ordering matters: later always wins”When a spread and an explicit attribute set the same name, Svelte applies attributes in document order — whichever comes later in the markup wins, exactly like assigning the same object key twice in JavaScript. Proven both directions:
<script lang="ts"> let rest: Record<string, string> = { title: 'from-rest' };</script><!-- explicit title AFTER the spread: explicit wins --><button {...rest} title="explicit" data-testid="before">go</button><script lang="ts"> let rest: Record<string, string> = { title: 'from-rest' };</script><!-- explicit title BEFORE the spread: spread wins --><button title="explicit" {...rest} data-testid="after">go</button>import { test, expect } from 'vitest';import { render, screen } from '@testing-library/svelte';import SpreadBefore from './SpreadBefore.svelte';import SpreadAfter from './SpreadAfter.svelte';
test('an explicit attribute written after a spread overrides it', () => { render(SpreadBefore); expect(screen.getByTestId('before').getAttribute('title')).toBe('explicit');});
test('a spread written after an explicit attribute overrides it', () => { render(SpreadAfter); expect(screen.getByTestId('after').getAttribute('title')).toBe('from-rest');});✓ an explicit attribute written after a spread overrides it✓ a spread written after an explicit attribute overrides itWhen the spread’s shape is statically known (not just Record<string, string>, but an object literal with a title property inferred directly), svelte-check goes further and flags the ambiguity at compile time, rather than silently picking a winner:
<!-- @expect-error "title" is specified more than once, so this usage will be overwritten. --><script lang="ts"> let rest = { title: 'from-rest' }; // inferred as { title: string }, not Record<string, string></script><button title="explicit" {...rest}>go</button>"title" is specified more than once, so this usage will be overwritten.If you want a prop to always win regardless of what a caller spreads in (or always lose to it), the position of {...rest} relative to the explicit attribute is the entire mechanism — there’s no separate “priority” concept to reach for.
The class and style directives
Section titled “The class and style directives”Toggling classes and inline styles reactively is common enough that Svelte gives them dedicated directives.
class:name={condition} adds the class when the condition is truthy:
<div class:active={isActive} class:done={isDone}>…</div>Since Svelte 5.16, the plain class attribute also accepts an object or an array, processed the same way the popular clsx helper works (Svelte uses that exact algorithm internally): object keys with truthy values become classes; array entries are flattened and falsy entries dropped — arrays and objects can nest:
<div class={{ active: isActive, done: isDone }}>…</div> <!-- object --><div class={[faded && 'opacity-50', large && 'scale-200']}>…</div> <!-- array -->The array form composes especially well when a component both has its own class and accepts one from a caller:
<script> let props = $props();</script><button {...props} class={['btn', props.class]}>{@render props.children?.()}</button>style:prop={value} sets an inline CSS property reactively:
<div style:color={textColor} style:font-size="{size}px">…</div>All of these compile to precise updates — when isActive flips, only that class is toggled; when textColor changes, only that style property is set.
flowchart LR
cls["class:active={isActive}"] --> u1["toggle exactly that class"]
arr["class={[a, b]} (5.16+, clsx-style)"] --> u1
sty["style:color={textColor}"] --> u2["set exactly that CSS property"]
ev["onclick={handler}"] --> u3["registered for root-level delegation"] Legacy code recognition: one table for four patterns at once
Section titled “Legacy code recognition: one table for four patterns at once”Code written for Svelte 4 — or generated by a model trained mostly on Svelte 4 examples — mixes four patterns that all changed in Svelte 5. Recognizing all four in one glance is the actual skill; here they are side by side:
| Svelte 4 (legacy) | Svelte 5 (runes) | What happens if you mix legacy into a runes-mode component |
|---|---|---|
export let value | let { value } = $props() | Compile error: Cannot use export letin runes mode — use$props() instead |
$: doubled = count * 2 | let doubled = $derived(count * 2) | Compile error: `$:` is not allowed in runes mode, use `$derived` or `$effect` instead |
<slot /> / <slot name="x" /> | {@render children?.()} / snippet props | Compiles, with a deprecation warning — <slot> still renders |
on:click={handler} | onclick={handler} | Compiles, with a deprecation warning — on:click still works |
createEventDispatcher() + dispatch('x', detail) | a callback prop, e.g. onchange?.(detail) | Compiles with no warning at all — still fully supported, just not idiomatic |
A component enters runes mode automatically the moment it uses any rune ($state, $derived, $effect, $props, …) anywhere in the file, or when runes: true is set explicitly in compiler options. Once in runes mode, the reactivity primitives from legacy mode (export let, top-level let as implicit state, $:) are rejected outright — but content-projection (<slot>) and event syntax (on:click) are considered separate, softer deprecations that still compile.
Proven against the real compiler — mixing $state with export let, or with $:, in the same file:
import { test, expect } from 'vitest';import { compile } from 'svelte/compiler';
test('`export let` alongside a rune is a hard compile error in runes mode', () => { const source = `<script> let count = $state(0); export let label;</script><p>{label}: {count}</p>`; expect(() => compile(source, { filename: 'BadExportLet.svelte' })).toThrowError( /Cannot use `export let` in runes mode/, );});
test('`$:` alongside a rune is a hard compile error in runes mode', () => { const source = `<script> let count = $state(0); $: doubled = count * 2;</script><p>{doubled}</p>`; expect(() => compile(source, { filename: 'BadReactive.svelte' })).toThrowError( /`\$:` is not allowed in runes mode/, );});
test('`<slot>` and `on:click` still compile in runes mode — with warnings, not errors', () => { const slotSource = `<script>\n let count = $state(0);\n</script>\n<slot />`; const slotResult = compile(slotSource, { filename: 'StillSlot.svelte' }); expect(slotResult.warnings.map((w) => w.code)).toContain('slot_element_deprecated');
const onColonSource = `<script>\n let count = $state(0);\n</script>\n<button on:click={() => count++}>{count}</button>`; const onColonResult = compile(onColonSource, { filename: 'StillOnColon.svelte' }); expect(onColonResult.warnings.map((w) => w.code)).toContain('event_directive_deprecated');});✓ `export let` alongside a rune is a hard compile error in runes mode✓ `$:` alongside a rune is a hard compile error in runes mode✓ `<slot>` and `on:click` still compile in runes mode — with warnings, not errorsThe real errors, verbatim (svelte-check reports the same text compile() throws):
Cannot use `export let` in runes mode — use `$props()` insteadhttps://svelte.dev/e/legacy_export_invalid
`$:` is not allowed in runes mode, use `$derived` or `$effect` insteadhttps://svelte.dev/e/legacy_reactive_statement_invalid
Using `<slot>` to render parent content is deprecated. Use `{@render ...}` tags insteadhttps://svelte.dev/e/slot_element_deprecated
Using `on:click` to listen to the click event is deprecated. Use the event attribute `onclick` insteadhttps://svelte.dev/e/event_directive_deprecatedMental Model
Section titled “Mental Model”Think of every DOM-facing feature in this lesson as the compiler picking, at build time, the cheapest correct way to reach the same outcome: one root listener instead of many (delegation), a document-order write instead of a “priority” system (spread), a clsx-equivalent string builder instead of a runtime class-list library (class={{...}}/class={[...]}). None of it is magic layered on top of the DOM — it’s the DOM, generated for you in the shape you’d have hand-written for that one specific case. Legacy mode and runes mode are two separate compiler code paths, not one mode with optional features; that’s why mixing their reactivity primitives (export let + $state, $: + $derived) is a hard error instead of “unexpected but tolerated” — the compiler genuinely can’t merge the two rewriting strategies in one pass.
Trade-offs
Section titled “Trade-offs”| What you get | Benefit | Cost |
|---|---|---|
| Event delegation | Fewer listeners for the browser to manage; smaller generated code | A stopPropagation() anywhere between the root and your element silently defeats delegated handlers (proven above) |
on from svelte/events over raw addEventListener | Correct ordering relative to Svelte’s own dispatch; a single call handles add + remove | One more import to reach for instead of the DOM API you already know |
class={[...]}/class={{...}} (5.16+) | Composes with props (class={['btn', props.class]}) far more naturally than class:x chains | Requires 5.16+; a large object/array is less scannable at a glance than a short class: list |
| Runes mode rejecting legacy reactivity outright | Impossible to silently mix two incompatible reactivity systems in one file | An AI-generated snippet that pattern-matches on older training data fails loudly instead of “half-working” |
Common Misconceptions
Section titled “Common Misconceptions”- “Calling
stopPropagation()on a wrapper only affects other listeners on that wrapper.” Proven above: it also prevents the event from ever reaching the root, so it silently defeats a delegatedonclickseveral levels further in, with no error anywhere. - “
{...rest}always wins, because it’s spreading a whole object.” It’s just an attribute like any other in source order — proven above in both directions: whichever is written later, spread or explicit, overrides the other. - “Legacy syntax always throws once you touch runes.” Only
export letand$:do (they’re reactivity primitives with nothing to fall back to);<slot>,on:click, andcreateEventDispatcherall still compile and run, some with a deprecation warning and some with none at all — proven above. - “
class={{...}}needs a library likeclsxinstalled.” Since 5.16 the behavior is built into the compiler’s output — no dependency, no import.
Reading AI-generated code
Section titled “Reading AI-generated code”When reviewing generated markup and event code, check for:
- Does an
onclick/oninput/etc. handler sit inside a subtree where something else (a modal library, a manually-attached vanilla listener) might callstopPropagation()on an ancestor — silently eating the event before Svelte’s delegated dispatch ever sees it? - Is a
use:-style or manually-attached listener using rawaddEventListenerwhereonfromsvelte/eventswould guarantee correct ordering? - Does
{...rest}appear in a position (before or after an explicit attribute) that doesn’t match the stated intent — e.g. spreading after an explicitclasswhen the intent was for the explicit one to always win? - Does the file mix a rune (
$state,$derived,$props, …) withexport letor a top-level$:block — a real, hard compile error, not a style nit (proven above)? - Is
createEventDispatcherused in new Svelte 5 code out of habit, where a plain callback prop would be simpler and is what the component’s own consumers now expect?
🐛 Spot the bug
<script>export let initialCount = 0;let count = $state(initialCount);
$: label = count === 1 ? '1 item' : `${count} items`;</script>
<button onclick={() => count++}>{label}</button>Show the answer
This is exactly the “half-migrated” shape a model trained mostly on Svelte 4 examples tends to produce: count is correctly upgraded to $state(...), but initialCount is still declared with export let, and the derived label still uses $:. The moment any rune appears in the file, the whole component is in runes mode — and both export let and $: are then hard compile errors, proven above:
Cannot use `export let` in runes mode — use `$props()` instead`$:` is not allowed in runes mode, use `$derived` or `$effect` insteadThe fix upgrades both forms to their rune equivalents, not just the one that happened to get touched first:
<script> let { initialCount = 0 } = $props(); let count = $state(initialCount); let label = $derived(count === 1 ? '1 item' : `${count} items`);</script>
<button onclick={() => count++}>{label}</button>