The Complete Guide to Event Handling in Svelte

Events are how your application responds to user interaction—clicks, keystrokes, touches, form submissions. Svelte 5 handles events through element attributes, making them consistent with how you set other properties. This guide covers everything from basic click handlers to complex patterns like focus trapping and gesture handling.

Event Attributes: The Basics

Svelte 5 uses standard HTML event attributes: onclick, onkeydown, onsubmit, onmousemove, when you assign a function to them, that function is called whenever the event occurs on that element.

<script>
	let count = $state(0)

	function handleClick() {
		count++
	}
</script>

<button onclick={handleClick}>
	Clicked {count} times
</button>

For simple operations, inline arrow functions work well and have no performance penalty in Svelte:

<button onclick={() => count++}>
	Clicked {count} times
</button>

Named Functions vs Inline Handlers

Use named functions when your handler has multiple steps, needs a descriptive name for readability, or will be reused:

<script>
	let items = $state([])

	function handleSubmit(event) {
		event.preventDefault()
		const formData = new FormData(event.target)
		const newItem = formData.get('item')

		if (newItem.trim()) {
			items = [...items, newItem.trim()]
			event.target.reset()
		}
	}
</script>

<form onsubmit={handleSubmit}>
	<input name="item" placeholder="Add item" />
	<button type="submit">Add</button>
</form>

Use inline handlers for simple, single-purpose actions where a named function would be overkill:

<button onclick={() => (showModal = true)}>Open</button>
<button onclick={() => (items = [])}>Clear All</button>

Working with the Event Object

When an event fires, the browser creates an event object containing information about what happened. Svelte passes this object to your handler function automatically.

<script>
	function handleClick(event) {
		console.log('Event type:', event.type) // "click"
		console.log('Target element:', event.target) // The clicked element
		console.log('Mouse position:', event.clientX, event.clientY)
		console.log('Shift held:', event.shiftKey)
	}
</script>

<button onclick={handleClick}>Click me</button>

Passing Custom Data to Handlers

Often you need to pass additional data to your handler—like which item in a list was clicked. Wrap your handler in an arrow function to capture that data:

<script>
	let tasks = $state([
		{ id: 1, text: 'Learn Svelte' },
		{ id: 2, text: 'Build something' },
		{ id: 3, text: 'Ship it' }
	])

	function deleteTask(taskId) {
		tasks = tasks.filter((t) => t.id !== taskId)
	}
</script>

<ul>
	{#each tasks as task}
		<li>
			{task.text}
			<button onclick={() => deleteTask(task.id)}>Delete</button>
		</li>
	{/each}
</ul>

The arrow function creates a closure that captures the specific task.id for each list item. When clicked, it calls deleteTask with that ID.

If you need both the event object AND custom data:

<script>
	function handleAction(taskId, event) {
		console.log('Task:', taskId)
		console.log('Clicked at:', event.clientX, event.clientY)
	}
</script>

<button onclick={(e) => handleAction(task.id, e)}>Action</button>

Key Event Properties by Type

Mouse events include clientX/clientY (viewport coordinates), pageX/pageY (document coordinates), button (which button: 0=left, 1=middle, 2=right), and modifier key states (shiftKey, ctrlKey, altKey, metaKey).

Keyboard events include key (the key value like "Enter", "a", "ArrowDown"), code (physical key like "KeyA" regardless of layout), and repeat (true if key is held down).

Form events give you event.target as the form element, which you can pass to FormData or access individual fields via event.target.elements.

Event Propagation and Delegation

When you click a button inside a div, both elements can potentially handle that click. The browser sends events through three phases:

  1. Capture phase: Event travels DOWN from window to target
  2. Target phase: Event arrives at the element that was actually clicked
  3. Bubble phase: Event travels back UP from target to window

By default, handlers fire during the bubble phase. This means a click on a nested element will bubble up and trigger handlers on parent elements too.

Target vs CurrentTarget

This distinction matters when you attach a handler to a container but users click on children inside it:

<script>
	function handleContainerClick(event) {
		// event.target = the actual element clicked (could be p, button, or the div)
		// event.currentTarget = the element with the handler (always the div)
		console.log('Clicked on:', event.target.tagName)
		console.log('Handler on:', event.currentTarget.tagName)
	}
</script>

<div onclick={handleContainerClick}>
	<p>Click this paragraph</p>
	<button>Or this button</button>
</div>

Click the paragraph: target is P, currentTarget is DIV. Click the button: target is BUTTON, currentTarget is DIV.

Event Delegation in Svelte

Svelte uses event delegation for common events like click, input, keydown, and many others. Instead of attaching a listener to every element, Svelte attaches a single listener at the document root and routes events to the appropriate handlers.

This happens automatically and transparently—you write handlers normally and Svelte optimizes behind the scenes. The benefits are significant: lower memory usage, better performance with dynamic content, and no manual cleanup needed.

However, this has implications you should understand:

Custom events must bubble. If you dispatch custom events manually, include bubbles: true or Svelte’s delegation won’t catch them:

// This won't reach Svelte's delegated handlers
element.dispatchEvent(new CustomEvent('myevent'))

// This will work correctly
element.dispatchEvent(new CustomEvent('myevent', { bubbles: true }))

Stopping propagation can break things. If you add a manual addEventListener and call stopPropagation(), the event won’t reach Svelte’s root listener. Use the on function from svelte/events if you need manual listeners that integrate with Svelte’s delegation.

Capture Phase Handlers

To handle events during the capture phase (before they reach the target), append capture to the event name:

<script>
	function handleCapture(event) {
		console.log('Capture: traveling down to', event.target.tagName)
	}

	function handleBubble(event) {
		console.log('Bubble: traveling up from', event.target.tagName)
	}
</script>

<div onclickcapture={handleCapture} onclick={handleBubble}>
	<button>Click me</button>
</div>

When you click the button, handleCapture fires first (event going down), then handleBubble fires (event going up).

Capture handlers are useful for intercepting events before children handle them—for example, implementing keyboard shortcuts that should work regardless of focus, or creating modal overlays that block interaction with background content.

Controlling Default Behavior

Many events have built-in browser behaviors: links navigate, forms submit and reload the page, right-click shows a context menu. Call event.preventDefault() to stop these defaults:

<script>
	async function handleSubmit(event) {
		event.preventDefault() // Stop page reload

		const formData = new FormData(event.target)
		const response = await fetch('/api/submit', {
			method: 'POST',
			body: formData
		})

		if (response.ok) {
			// Handle success without page navigation
		}
	}
</script>

<form onsubmit={handleSubmit}>
	<input name="email" type="email" required />
	<button type="submit">Subscribe</button>
</form>

Stopping Propagation

To prevent an event from bubbling to parent handlers, use stopPropagation():

<script>
	function handleButtonClick(event) {
		event.stopPropagation()
		console.log('Button clicked - container handler will NOT fire')
	}

	function handleContainerClick() {
		console.log('Container clicked')
	}
</script>

<div onclick={handleContainerClick}>
	<p>Click here triggers container handler</p>
	<button onclick={handleButtonClick}>Click here only triggers button handler</button>
</div>

Use stopImmediatePropagation() to also prevent other handlers on the same element from firing.

Be cautious with stopPropagation()—it can break features that rely on events bubbling, like “click outside to close” patterns or analytics that listen at the document level.

Creating Reusable Modifier Wrappers

If you frequently need to prevent default or stop propagation, create helper functions:

<script>
	function preventDefault(handler) {
		return (event) => {
			event.preventDefault()
			handler(event)
		}
	}

	function stopPropagation(handler) {
		return (event) => {
			event.stopPropagation()
			handler(event)
		}
	}

	// Compose multiple modifiers
	function withModifiers(...modifiers) {
		return (handler) => {
			return (event) => {
				for (const modifier of modifiers) {
					modifier(event)
				}
				handler(event)
			}
		}
	}

	function actualSubmitLogic(event) {
		// Just the business logic, no event mechanics
		const data = new FormData(event.target)
		submitToServer(data)
	}
</script>

<form onsubmit={preventDefault(actualSubmitLogic)}>
	<!-- ... -->
</form>

This separates event mechanics from business logic, making both more reusable and testable.

Component Events with Callbacks

When child components need to notify parents about events, Svelte 5 uses callback props—functions passed from parent to child that the child calls when something happens.

Basic Callback Pattern

<!-- ColorPicker.svelte -->
<script>
	let { value = '#000000', onChange } = $props()
</script>

<input type="color" {value} oninput={(e) => onChange?.(e.target.value)} />
<!-- App.svelte -->
<script>
	import ColorPicker from './ColorPicker.svelte'

	let selectedColor = $state('#3498db')
</script>

<ColorPicker value={selectedColor} onChange={(color) => (selectedColor = color)} />

<p style="color: {selectedColor}">Selected: {selectedColor}</p>

The ?.() syntax (optional chaining) safely calls the callback only if it was provided. This makes callbacks optional by default—components work even if parents don’t need to listen.

Naming Conventions

Callback props conventionally start with on: onClick, onChange, onSubmit, onSelect, onDelete. This mirrors DOM event naming and signals “this is a notification hook, not data.”

For complex components, be more specific: onItemSelect, onFilterChange, onRowDelete. Consistency within your codebase matters more than following any particular convention.

Passing Rich Data Through Callbacks

Callbacks can carry any data the parent needs to handle the event:

<!-- DataTable.svelte -->
<script>
	let { rows, onRowClick, onRowDelete, onSort } = $props()
</script>

<table>
	<thead>
		<tr>
			<th onclick={() => onSort?.('name', 'asc')}>Name</th>
			<th onclick={() => onSort?.('date', 'desc')}>Date</th>
			<th>Actions</th>
		</tr>
	</thead>
	<tbody>
		{#each rows as row}
			<tr onclick={() => onRowClick?.(row)}>
				<td>{row.name}</td>
				<td>{row.date}</td>
				<td>
					<button
						onclick={(e) => {
							e.stopPropagation() // Don't trigger row click
							onRowDelete?.(row.id)
						}}
					>
						Delete
					</button>
				</td>
			</tr>
		{/each}
	</tbody>
</table>

The parent receives exactly the information needed to handle each interaction:

<script>
	import DataTable from './DataTable.svelte'

	let data = $state([
		/* ... */
	])

	function handleRowClick(row) {
		selectedRow = row
		showDetails = true
	}

	function handleDelete(id) {
		data = data.filter((row) => row.id !== id)
	}

	function handleSort(column, direction) {
		data = [...data].sort((a, b) => {
			const cmp = a[column] > b[column] ? 1 : -1
			return direction === 'asc' ? cmp : -cmp
		})
	}
</script>

<DataTable rows={data} onRowClick={handleRowClick} onRowDelete={handleDelete} onSort={handleSort} />

Required vs Optional Callbacks

Make callbacks required when the parent MUST handle the result. Use TypeScript to enforce this:

<script lang="ts">
	interface Props {
		onConfirm: (result: boolean) => void // Required
		onCancel?: () => void // Optional
	}

	let { onConfirm, onCancel }: Props = $props()
</script>

<div class="dialog">
	<button onclick={() => onConfirm(true)}>Yes</button>
	<button onclick={() => onConfirm(false)}>No</button>
	<button onclick={() => onCancel?.()}>Cancel</button>
</div>

Event Forwarding Patterns

When building wrapper components, you often need to pass events from internal elements to consumers.

Pattern 1: Explicit Handler Props

Accept specific handlers and attach them to internal elements:

<!-- Button.svelte -->
<script>
	let { children, onclick, ondblclick, disabled = false, variant = 'primary' } = $props()
</script>

<button class="btn btn-{variant}" {disabled} {onclick} {ondblclick}>
	{@render children?.()}
</button>

This works but requires explicitly listing every event you want to forward.

Pattern 2: Rest Props for Full Forwarding

Collect extra props with rest syntax and spread them onto the element:

<!-- Button.svelte -->
<script>
	let { children, variant = 'primary', ...rest } = $props()
</script>

<button class="btn btn-{variant}" {...rest}>
	{@render children?.()}
</button>

Now consumers can pass ANY attribute or event handler, and it reaches the internal button:

<Button
	onclick={handleClick}
	onmouseenter={handleHover}
	disabled={isLoading}
	aria-label="Submit form"
>
	Submit
</Button>

Pattern 3: Intercept Then Forward

Sometimes your component needs to do something with an event AND let the consumer handle it:

<!-- Button.svelte -->
<script>
	let { children, onclick, variant = 'primary', ...rest } = $props()

	function handleClick(event) {
		// Component's internal logic
		trackAnalytics('button_click', { variant })

		// Add visual feedback
		event.target.classList.add('clicked')
		setTimeout(() => event.target.classList.remove('clicked'), 200)

		// Forward to consumer's handler
		onclick?.(event)
	}
</script>

<button class="btn btn-{variant}" {...rest} onclick={handleClick}>
	{@render children?.()}
</button>

Note the order: {...rest} comes before onclick={handleClick}. This is a good practice to ensure your explicit handler takes precedence over any spread props. Although we destructured onclick (removing it from rest), this pattern protects you if you later change the props definition. Inside your handler, you call the original onclick prop after your logic runs.

This pattern is powerful for adding analytics, animations, validation, or logging to interactions without consumers needing to implement it themselves.

Keyboard Events and Shortcuts

Keyboard handling is essential for accessible, power-user-friendly applications.

The key Property

Modern keyboard handling uses the key property, which gives you the key value as a string:

<script>
	function handleKeyDown(event) {
		switch (event.key) {
			case 'Enter':
				submit()
				break
			case 'Escape':
				cancel()
				break
			case 'ArrowDown':
				event.preventDefault() // Prevent scroll
				selectNext()
				break
			case 'ArrowUp':
				event.preventDefault()
				selectPrevious()
				break
		}
	}
</script>

<div tabindex="0" onkeydown={handleKeyDown}>
	<!-- Interactive content -->
</div>

Common key values: Enter, Escape, Tab, Backspace, Delete, ArrowUp, ArrowDown, ArrowLeft, ArrowRight, Home, End, PageUp, PageDown, (space), and letters/numbers as their character.

Keyboard Shortcuts with Modifiers

Check modifier key properties for keyboard shortcuts:

<script>
	function handleGlobalKeyDown(event) {
		const isMac = navigator.platform.includes('Mac')
		const modifier = isMac ? event.metaKey : event.ctrlKey

		if (modifier && event.key === 's') {
			event.preventDefault()
			saveDocument()
		}

		if (modifier && event.key === 'k') {
			event.preventDefault()
			openCommandPalette()
		}

		if (modifier && event.shiftKey && event.key === 'z') {
			event.preventDefault()
			redo()
		}

		if (modifier && !event.shiftKey && event.key === 'z') {
			event.preventDefault()
			undo()
		}
	}
</script>

<svelte:window onkeydown={handleGlobalKeyDown} />

Using <svelte:window> attaches the handler to the window object, catching keyboard events regardless of which element has focus. This is ideal for application-wide shortcuts.

Making Custom Elements Keyboard Accessible

Custom interactive elements must support keyboard interaction for accessibility:

<script>
	let { onActivate, label } = $props()

	function handleKeyDown(event) {
		if (event.key === 'Enter' || event.key === ' ') {
			event.preventDefault() // Prevent space from scrolling
			onActivate?.()
		}
	}
</script>

<div role="button" tabindex="0" onclick={onActivate} onkeydown={handleKeyDown} aria-label={label}>
	{@render children?.()}
</div>

Key accessibility requirements:

  • tabindex="0" makes the element focusable via Tab
  • role="button" tells screen readers this behaves like a button
  • Enter and Space should trigger activation (matching native button behavior)
  • Prevent Space’s default scrolling behavior

Touch and Pointer Events

Touch screens require special handling. Svelte 5 has specific behavior around touch and wheel events that you need to understand.

Touch Events Basics

Touch events provide information about finger contact:

<script>
	let touchStart = $state(null)
	let offset = $state({ x: 0, y: 0 })

	function handleTouchStart(event) {
		const touch = event.touches[0]
		touchStart = { x: touch.clientX, y: touch.clientY }
	}

	function handleTouchMove(event) {
		if (!touchStart) return
		const touch = event.touches[0]
		offset = {
			x: touch.clientX - touchStart.x,
			y: touch.clientY - touchStart.y
		}
	}

	function handleTouchEnd() {
		touchStart = null
	}
</script>

<div
	class="draggable"
	style="transform: translate({offset.x}px, {offset.y}px)"
	ontouchstart={handleTouchStart}
	ontouchmove={handleTouchMove}
	ontouchend={handleTouchEnd}
>
	Drag me
</div>

Passive Touch Events

Here’s something critical: in Svelte 5, touch and wheel events are passive by default. This is a performance optimization—the browser doesn’t wait for your JavaScript before scrolling, making touch interaction feel instant.

The implication: event.preventDefault() is silently ignored in passive handlers:

<!-- preventDefault() does NOTHING here - event is passive -->
<div ontouchmove={(e) => {
  e.preventDefault(); // Silently ignored!
  handleDrag(e);
}}>

For most cases this is fine. But if you’re building a drawing canvas, custom gestures, or anything where you need to prevent scrolling, you need the on function from svelte/events (covered in the next section).

Pointer Events: Unified Input Handling

Pointer events unify mouse, touch, and stylus input. Instead of writing separate handlers for each input type, write one handler that works for all:

<script>
	let isDrawing = $state(false)
	let points = $state([])

	function handlePointerDown(event) {
		isDrawing = true
		points = [{ x: event.clientX, y: event.clientY }]

		// Capture ensures we get events even if pointer leaves the element
		event.target.setPointerCapture(event.pointerId)
	}

	function handlePointerMove(event) {
		if (!isDrawing) return
		points = [...points, { x: event.clientX, y: event.clientY }]
	}

	function handlePointerUp() {
		isDrawing = false
	}
</script>

<svg
	onpointerdown={handlePointerDown}
	onpointermove={handlePointerMove}
	onpointerup={handlePointerUp}
>
	{#if points.length > 1}
		<polyline
			points={points.map((p) => `${p.x},${p.y}`).join(' ')}
			fill="none"
			stroke="black"
			stroke-width="2"
		/>
	{/if}
</svg>

setPointerCapture() is particularly useful—it ensures your element continues receiving pointer events even if the pointer moves outside the element boundaries, which is essential for drag operations.

The on Function from svelte/events

When you need to attach event listeners programmatically (rather than through attributes), Svelte provides an on function that preserves correct event ordering relative to declarative handlers.

Svelte uses event delegation for performance—declarative handlers like onclick are actually handled by a single listener at the document root. If you use plain addEventListener, your handler might fire in a different order than you’d expect relative to declarative handlers.

The on function ensures proper ordering and returns a cleanup function:

<script>
	import { on } from 'svelte/events'

	let element

	$effect(() => {
		if (!element) return

		// Returns cleanup function automatically
		return on(element, 'click', (event) => {
			console.log('Clicked!')
		})
	})
</script>

<button bind:this={element}>Click me</button>

Window and Document Events

<script>
	import { on } from 'svelte/events'

	let scrollY = $state(0)

	$effect(() => {
		return on(window, 'scroll', () => {
			scrollY = window.scrollY
		})
	})
</script>

Non-Passive Touch Handlers

The on function accepts the same options as addEventListener, including passive. This is useful for touch events where you need to call preventDefault():

<script>
	import { on } from 'svelte/events'

	let canvas
	let ctx
	let isDrawing = $state(false)

	$effect(() => {
		if (!canvas) return
		ctx = canvas.getContext('2d')

		const cleanupStart = on(canvas, 'touchstart', handleTouchStart, { passive: false })
		const cleanupMove = on(canvas, 'touchmove', handleTouchMove, { passive: false })
		const cleanupEnd = on(canvas, 'touchend', handleTouchEnd)

		return () => {
			cleanupStart()
			cleanupMove()
			cleanupEnd()
		}
	})

	function handleTouchStart(event) {
		event.preventDefault() // NOW this works!
		isDrawing = true

		const touch = event.touches[0]
		const rect = canvas.getBoundingClientRect()
		ctx.beginPath()
		ctx.moveTo(touch.clientX - rect.left, touch.clientY - rect.top)
	}

	function handleTouchMove(event) {
		if (!isDrawing) return
		event.preventDefault() // Prevents page scrolling while drawing

		const touch = event.touches[0]
		const rect = canvas.getBoundingClientRect()
		ctx.lineTo(touch.clientX - rect.left, touch.clientY - rect.top)
		ctx.stroke()
	}

	function handleTouchEnd() {
		isDrawing = false
	}
</script>

<canvas bind:this={canvas} width="400" height="300"></canvas>

The { passive: false } option tells the browser to wait for your handler before taking default action, allowing preventDefault() to work.

Window Events with Options

<script>
	import { on } from 'svelte/events'

	let scrollY = $state(0)
	let lastScrollY = 0
	let scrollDirection = $state('none')

	$effect(() => {
		const cleanup = on(
			window,
			'scroll',
			() => {
				scrollY = window.scrollY
				scrollDirection = scrollY > lastScrollY ? 'down' : 'up'
				lastScrollY = scrollY
			},
			{ passive: true }
		) // Passive is good here - we don't need to prevent

		return cleanup
	})
</script>

<header class:hidden={scrollDirection === 'down' && scrollY > 100}>
	<!-- Hide header when scrolling down -->
</header>

Real-World Patterns

Let’s build some common interactive patterns that combine multiple event handling concepts.

Click Outside to Close

Close dropdowns, modals, or popovers when clicking outside:

<script>
  let { open = $bindable(false) } = $props();
  let containerRef;

  function handleWindowClick(event) {
    if (containerRef && !containerRef.contains(event.target)) {
      open = false;
    }
  }
</script>

{#if open}
  <svelte:window onclick={handleWindowClick} />
{/if}

<div class="dropdown-wrapper">
  <button onclick={() => open = !open}>
    {open ? 'Close' : 'Open'} Menu
  </button>

  {#if open}
    <div bind:this={containerRef} class="dropdown-menu">
      <button onclick={() => console.log('Action 1')}>Action 1</button>
      <button onclick={() => console.log('Action 2')}>Action 2</button>
      <button onclick={() => open = false}>Close</button>
    </div>
  {/if}
</div>

The contains() method checks if the clicked element is inside our container. If not, we close the dropdown. The window listener only exists when the dropdown is open, keeping things efficient.

Debounced Search Input

Wait for the user to stop typing before triggering a search:

<script>
	let { onSearch } = $props()
	let query = $state('')
	let timeoutId

	function handleInput(event) {
		query = event.target.value

		clearTimeout(timeoutId)
		timeoutId = setTimeout(() => {
			if (query.trim()) {
				onSearch?.(query.trim())
			}
		}, 300)
	}
</script>

<input type="search" value={query} oninput={handleInput} placeholder="Search..." />

Each keystroke resets the timer. The search only fires after 300ms of no typing—preventing excessive API calls while the user is still entering their query.

Keyboard-Navigable List

A list that supports arrow key navigation, Enter to select, and Home/End:

<script>
	let { items, onSelect } = $props()
	let selectedIndex = $state(0)
	let listRef

	function handleKeyDown(event) {
		switch (event.key) {
			case 'ArrowDown':
				event.preventDefault()
				selectedIndex = Math.min(selectedIndex + 1, items.length - 1)
				scrollIntoView()
				break

			case 'ArrowUp':
				event.preventDefault()
				selectedIndex = Math.max(selectedIndex - 1, 0)
				scrollIntoView()
				break

			case 'Enter':
				event.preventDefault()
				onSelect?.(items[selectedIndex])
				break

			case 'Home':
				event.preventDefault()
				selectedIndex = 0
				scrollIntoView()
				break

			case 'End':
				event.preventDefault()
				selectedIndex = items.length - 1
				scrollIntoView()
				break
		}
	}

	function scrollIntoView() {
		listRef?.children[selectedIndex]?.scrollIntoView({ block: 'nearest' })
	}
</script>

<ul bind:this={listRef} role="listbox" tabindex="0" onkeydown={handleKeyDown}>
	{#each items as item, i}
		<li
			role="option"
			class:selected={i === selectedIndex}
			aria-selected={i === selectedIndex}
			onclick={() => {
				selectedIndex = i
				onSelect?.(item)
			}}
		>
			{item.label}
		</li>
	{/each}
</ul>

<style>
	ul {
		list-style: none;
		padding: 0;
		margin: 0;
		max-height: 200px;
		overflow-y: auto;
		border: 1px solid #ddd;
		border-radius: 4px;
	}

	ul:focus {
		outline: 2px solid #3498db;
		outline-offset: 2px;
	}

	li {
		padding: 8px 12px;
		cursor: pointer;
	}

	li:hover {
		background: #f5f5f5;
	}

	li.selected {
		background: #3498db;
		color: white;
	}
</style>

This combines keyboard events, ARIA attributes for accessibility, mouse interaction, and scroll management into a polished, accessible component.

Modals should trap focus inside—Tab shouldn’t escape to the page behind:

<script>
	let { open = $bindable(false), children } = $props()
	let modalRef
	let previousFocus

	$effect(() => {
		if (open) {
			previousFocus = document.activeElement

			// Focus first focusable element
			setTimeout(() => {
				const focusable = getFocusableElements()
				focusable[0]?.focus()
			}, 0)
		} else if (previousFocus) {
			previousFocus.focus()
		}
	})

	function getFocusableElements() {
		if (!modalRef) return []
		return [
			...modalRef.querySelectorAll(
				'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
			)
		]
	}

	function handleKeyDown(event) {
		if (event.key === 'Escape') {
			open = false
			return
		}

		if (event.key === 'Tab') {
			const focusable = getFocusableElements()
			if (focusable.length === 0) return

			const first = focusable[0]
			const last = focusable[focusable.length - 1]

			if (event.shiftKey && document.activeElement === first) {
				event.preventDefault()
				last.focus()
			} else if (!event.shiftKey && document.activeElement === last) {
				event.preventDefault()
				first.focus()
			}
		}
	}

	function handleBackdropClick(event) {
		if (event.target === event.currentTarget) {
			open = false
		}
	}
</script>

{#if open}
	<div
		class="modal-backdrop"
		onclick={handleBackdropClick}
		onkeydown={handleKeyDown}
		role="dialog"
		aria-modal="true"
	>
		<div bind:this={modalRef} class="modal-content">
			<button class="close-btn" onclick={() => (open = false)}>×</button>
			{@render children?.()}
		</div>
	</div>
{/if}

<style>
	.modal-backdrop {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.modal-content {
		background: white;
		padding: 24px;
		border-radius: 8px;
		max-width: 500px;
		width: 90%;
		position: relative;
	}

	.close-btn {
		position: absolute;
		top: 8px;
		right: 8px;
		border: none;
		background: none;
		font-size: 24px;
		cursor: pointer;
	}
</style>

This modal implements proper focus management: saving previous focus, moving focus into the modal, trapping Tab/Shift+Tab to cycle through modal elements, closing on Escape, and restoring focus when closing.

Sortable Drag and Drop

A list that can be reordered by dragging:

<script>
	let { items = $bindable([]) } = $props()
	let draggedIndex = $state(null)
	let dragOverIndex = $state(null)

	function handleDragStart(event, index) {
		draggedIndex = index
		event.dataTransfer.effectAllowed = 'move'
		// Required for Firefox
		event.dataTransfer.setData('text/plain', index.toString())
	}

	function handleDragOver(event, index) {
		event.preventDefault()
		event.dataTransfer.dropEffect = 'move'
		dragOverIndex = index
	}

	function handleDragLeave() {
		dragOverIndex = null
	}

	function handleDrop(event, dropIndex) {
		event.preventDefault()

		if (draggedIndex === null || draggedIndex === dropIndex) return

		const newItems = [...items]
		const [removed] = newItems.splice(draggedIndex, 1)
		newItems.splice(dropIndex, 0, removed)
		items = newItems

		draggedIndex = null
		dragOverIndex = null
	}

	function handleDragEnd() {
		draggedIndex = null
		dragOverIndex = null
	}
</script>

<ul class="sortable-list">
	{#each items as item, index (item.id)}
		<li
			draggable="true"
			class:dragging={draggedIndex === index}
			class:drag-over={dragOverIndex === index}
			ondragstart={(e) => handleDragStart(e, index)}
			ondragover={(e) => handleDragOver(e, index)}
			ondragleave={handleDragLeave}
			ondrop={(e) => handleDrop(e, index)}
			ondragend={handleDragEnd}
		>
			<span class="drag-handle">⋮⋮</span>
			{item.label}
		</li>
	{/each}
</ul>

<style>
	.sortable-list {
		list-style: none;
		padding: 0;
		margin: 0;
	}

	li {
		display: flex;
		align-items: center;
		gap: 8px;
		padding: 12px;
		background: white;
		border: 1px solid #ddd;
		margin-bottom: -1px;
		cursor: grab;
		transition: background-color 0.2s;
	}

	li:active {
		cursor: grabbing;
	}

	li.dragging {
		opacity: 0.5;
		background: #f0f0f0;
	}

	li.drag-over {
		border-top: 2px solid #3498db;
		margin-top: -1px;
	}

	.drag-handle {
		color: #999;
		user-select: none;
	}
</style>

This uses the HTML5 Drag and Drop API with visual feedback for the dragging state and drop target indication.

Common Pitfalls

Forgetting Case Sensitivity

<!-- WRONG: onClick listens for custom "Click" event, not standard click -->
<button onClick={handleClick}>Click me</button>

<!-- CORRECT: onclick for standard click event -->
<button onclick={handleClick}>Click me</button>

Stopping Propagation Breaking Delegation

If you add manual event listeners with stopPropagation(), Svelte’s delegated handlers won’t fire:

// This breaks Svelte's event handling!
element.addEventListener('click', (e) => {
	e.stopPropagation()
	// Svelte's onclick handlers on this or parent elements won't fire
})

Use the on function from svelte/events if you need manual listeners that integrate properly.

Expecting preventDefault on Touch Events

<!-- preventDefault is IGNORED - touch events are passive by default -->
<div ontouchmove={(e) => {
  e.preventDefault(); // Does nothing!
  handleDrag(e);
}}>

Use the on function with { passive: false } when you need to prevent touch defaults.

Handler Order with Spreads

When spreading props onto an element, the last attribute wins. If rest contains an onclick handler (because you didn’t destructure it), the order matters:

<!-- Your handler overrides onclick from rest props -->
<button {...rest} onclick={handleClick}>

<!-- onclick from rest props will override your handler -->
<button onclick={handleClick} {...rest}>

If you want to intercept AND forward, extract the handler first:

<script>
  let { onclick, ...rest } = $props();

  function handleClick(event) {
    // Your logic first
    doSomething();
    // Then forward
    onclick?.(event);
  }
</script>

<button {...rest} onclick={handleClick}>

Forgetting the Event Parameter in Arrow Functions

<!-- WRONG: event is undefined -->
<button onclick={() => handleClick(event)}>

<!-- CORRECT: capture event from arrow function parameter -->
<button onclick={(event) => handleClick(event)}>

<!-- Or just pass the handler directly if you don't need extra data -->
<button onclick={handleClick}>

Conclusion

Event handling in Svelte 5 is straightforward yet powerful. The key concepts:

Event attributes (onclick, onkeydown, etc.) make events consistent with other element properties. They’re case-sensitive—use lowercase for standard DOM events.

Event delegation happens automatically for common events, improving performance. Custom events need bubbles: true to participate.

Component callbacks replace event dispatching. Pass functions as props, call them when things happen. Use ?.() for optional callbacks.

The on function handles edge cases: non-passive touch events, specific addEventListener options, and window/document events beyond what <svelte:window> provides.

Real-world patterns combine these concepts: click outside to close, focus trapping, keyboard navigation, drag and drop. Understanding propagation, capture, and the event object lets you build sophisticated interactions.

With these foundations, you can create applications that feel responsive and natural to use.