Global Event Handling and Beyond

When building sophisticated web applications, there comes a moment when the neat boundaries of component-scoped event handling simply aren’t enough. You need to respond to a keyboard shortcut pressed anywhere on the page.

You need to detect when users click outside a dropdown menu. You need to coordinate drag-and-drop operations that span the entire viewport. These scenarios demand access to events at the document body level, and Svelte 5 provides an elegant, declarative solution through the <svelte:body> special element.

This tutorial explores the depths of <svelte:body>, examining not just how to use it, but why it exists, when to reach for it, and how to architect robust solutions around body-level event handling. We’ll work through progressively complex examples, uncover subtle pitfalls that can trip up even experienced developers, and establish patterns that scale gracefully in real-world applications.

Understanding the Problem Space: Why Body-Level Events Matter

Before diving into syntax and implementation, let’s establish a clear understanding of why body-level event handling represents a distinct category of problems in frontend development.

Consider a typical component-based architecture. Each component encapsulates its own DOM structure, styles, and behavior. Event handlers are attached to specific elements within that component’s template. This model works beautifully for interactions that occur entirely within a component’s boundaries—clicking a button, typing in an input, hovering over a card.

But web applications routinely require interactions that transcend these boundaries. Imagine implementing a modal dialog. The modal itself is a component, perhaps rendered conditionally somewhere in your component tree. But the expected user behavior—pressing Escape to close the modal, or clicking the backdrop outside the modal content—involves events that may originate from elements far outside the modal component’s DOM subtree.

The naive solution might involve attaching event listeners directly to document.body within your component’s lifecycle:

// This works, but it's imperative and error-prone
import { onMount, onDestroy } from 'svelte'

let cleanup

onMount(() => {
	const handler = (event) => {
		if (event.key === 'Escape') {
			closeModal()
		}
	}
	document.body.addEventListener('keydown', handler)
	cleanup = () => document.body.removeEventListener('keydown', handler)
})

onDestroy(() => {
	cleanup?.()
})

This approach has several drawbacks. It’s imperative rather than declarative, mixing lifecycle management with event handling logic. It requires explicit cleanup to prevent memory leaks. It doesn’t integrate naturally with Svelte’s reactivity system. And it scatters the event handling logic across multiple locations in your code.

The <svelte:body> special element addresses all of these concerns, providing a declarative syntax that integrates seamlessly with Svelte’s component model and, in Svelte 5, with the runes-based reactivity system.

The Anatomy of <svelte:body>

The <svelte:body> element is one of several special elements that Svelte provides for interacting with parts of the DOM that exist outside your component’s template. Unlike regular elements, <svelte:body> doesn’t render anything to the DOM—it serves purely as a declarative attachment point for event listeners and actions on the document.body element. In addition to event handlers, you can also use the {@attach} directive (Svelte 5.29+) to attach behaviors directly to the body element.

In Svelte 5, event handlers use the new on* attribute syntax rather than the on:* directive syntax from earlier versions. Here’s the fundamental structure:

<script>
	function handleKeydown(event) {
		console.log('Key pressed:', event.key)
	}
</script>

<svelte:body onkeydown={handleKeydown} />

When this component mounts, Svelte automatically attaches the keydown event listener to document.body. When the component unmounts, Svelte automatically removes the listener. This automatic lifecycle management eliminates an entire category of bugs related to forgotten cleanup.

The element accepts any valid DOM event handler as an attribute. The naming convention follows the standard HTML attribute pattern: the event name prefixed with on, all in lowercase. So keydown becomes onkeydown, mouseup becomes onmouseup, dragover becomes ondragover, and so forth.

Inline Handlers and Arrow Functions

You can define handlers inline for simple operations:

<script>
	let lastKey = $state('')
</script>

<svelte:body onkeydown={(e) => (lastKey = e.key)} />

<p>Last key pressed: {lastKey || 'None yet'}</p>

This pattern works well for straightforward state updates, though more complex logic typically benefits from being extracted into named functions for readability and testability.

Multiple Event Handlers

A single <svelte:body> element can attach multiple event handlers:

<script>
	let mousePosition = $state({ x: 0, y: 0 })
	let isMouseDown = $state(false)

	function trackMouse(event) {
		mousePosition = { x: event.clientX, y: event.clientY }
	}
</script>

<svelte:body
	onmousemove={trackMouse}
	onmousedown={() => (isMouseDown = true)}
	onmouseup={() => (isMouseDown = false)}
/>

<div class="tracker">
	Position: ({mousePosition.x}, {mousePosition.y})
	{#if isMouseDown}
		<span class="indicator">🖱️ Mouse button held</span>
	{/if}
</div>

You can also have multiple <svelte:body> elements in a single component if that improves code organization, though consolidating related handlers on a single element is typically cleaner.

Deep Dive

Building a Keyboard Shortcut System

Let’s construct a realistic example that demonstrates the power and nuance of <svelte:body>. We’ll build a keyboard shortcut system that supports modifier keys, prevents conflicts with form inputs, and provides visual feedback to users.

<script>
	// Reactive state using Svelte 5 runes
	let shortcuts = $state([
		{ keys: ['Control', 's'], action: 'save', description: 'Save document' },
		{ keys: ['Control', 'Shift', 'p'], action: 'preview', description: 'Toggle preview' },
		{ keys: ['Escape'], action: 'close', description: 'Close dialog' },
		{ keys: ['Control', 'k'], action: 'search', description: 'Open search' }
	])

	let activeKeys = $state(new Set())
	let lastTriggeredAction = $state(null)
	let showShortcutHint = $state(false)

	// Derived state: compute which shortcut matches current key combination
	let matchingShortcut = $derived.by(() => {
		if (activeKeys.size === 0) return null

		return shortcuts.find((shortcut) => {
			if (shortcut.keys.length !== activeKeys.size) return false
			return shortcut.keys.every((key) => activeKeys.has(key))
		})
	})

	function shouldIgnoreEvent(event) {
		// Don't capture shortcuts when user is typing in form elements
		const target = event.target
		const tagName = target.tagName.toLowerCase()
		const isEditable = target.isContentEditable
		const isFormElement = ['input', 'textarea', 'select'].includes(tagName)

		// Exception: always allow Escape key
		if (event.key === 'Escape') return false

		return isEditable || isFormElement
	}

	function normalizeKey(event) {
		// Normalize modifier key names for cross-platform consistency
		if (event.key === 'Meta') return 'Control' // Treat Cmd as Ctrl on Mac
		return event.key
	}

	function handleKeydown(event) {
		if (shouldIgnoreEvent(event)) return

		const key = normalizeKey(event)

		// Create new Set to trigger reactivity
		activeKeys = new Set([...activeKeys, key])

		// Check if current combination matches a shortcut
		const matched = shortcuts.find((shortcut) => {
			if (shortcut.keys.length !== activeKeys.size) return false
			return shortcut.keys.every((k) => activeKeys.has(k))
		})

		if (matched) {
			event.preventDefault() // Prevent browser default behavior
			lastTriggeredAction = matched.action
			executeAction(matched.action)

			// Clear after short delay for visual feedback
			setTimeout(() => {
				lastTriggeredAction = null
			}, 1000)
		}
	}

	function handleKeyup(event) {
		const key = normalizeKey(event)

		// Create new Set without the released key
		const newKeys = new Set(activeKeys)
		newKeys.delete(key)
		activeKeys = newKeys
	}

	function executeAction(action) {
		// In a real application, these would trigger actual functionality
		console.log(`Executing action: ${action}`)

		switch (action) {
			case 'save':
				alert('Document saved!')
				break
			case 'preview':
				alert('Preview toggled!')
				break
			case 'close':
				alert('Dialog closed!')
				break
			case 'search':
				alert('Search opened!')
				break
		}
	}

	// Toggle shortcut hint overlay with ? key
	function handleQuestionMark(event) {
		if (event.key === '?' && !shouldIgnoreEvent(event)) {
			showShortcutHint = !showShortcutHint
		}
	}
</script>

<svelte:body onkeydown={handleKeydown} onkeyup={handleKeyup} onkeypress={handleQuestionMark} />

<div class="app-container">
	<header>
		<h1>Keyboard Shortcut Demo</h1>
		<p class="hint">Press <kbd>?</kbd> to view available shortcuts</p>
	</header>

	<main>
		<div class="status-panel">
			<h2>Current State</h2>
			<div class="active-keys">
				<strong>Active keys:</strong>
				{#if activeKeys.size > 0}
					{#each [...activeKeys] as key}
						<kbd>{key}</kbd>
					{/each}
				{:else}
					<span class="none">None</span>
				{/if}
			</div>

			{#if lastTriggeredAction}
				<div class="triggered-action">
					✓ Triggered: <strong>{lastTriggeredAction}</strong>
				</div>
			{/if}
		</div>

		<div class="test-area">
			<h2>Test Input (shortcuts disabled here)</h2>
			<input type="text" placeholder="Type here - shortcuts won't trigger" />
			<textarea placeholder="Or here - shortcuts won't trigger either"></textarea>
		</div>
	</main>

	{#if showShortcutHint}
		<div class="shortcut-overlay" role="dialog" aria-label="Keyboard shortcuts">
			<div class="shortcut-panel">
				<h2>Keyboard Shortcuts</h2>
				<ul>
					{#each shortcuts as shortcut}
						<li>
							<span class="keys">
								{#each shortcut.keys as key, i}
									<kbd>{key}</kbd>{#if i < shortcut.keys.length - 1}
										+
									{/if}
								{/each}
							</span>
							<span class="description">{shortcut.description}</span>
						</li>
					{/each}
				</ul>
				<button onclick={() => (showShortcutHint = false)}>Close (or press ?)</button>
			</div>
		</div>
	{/if}
</div>

<style>
	.app-container {
		font-family:
			system-ui,
			-apple-system,
			sans-serif;
		max-width: 800px;
		margin: 0 auto;
		padding: 2rem;
	}

	kbd {
		background: #e0e0e0;
		border: 1px solid #ccc;
		border-radius: 4px;
		padding: 0.2em 0.5em;
		font-family: monospace;
		font-size: 0.9em;
		margin: 0 0.2em;
	}

	.status-panel {
		background: #f5f5f5;
		padding: 1rem;
		border-radius: 8px;
		margin: 1rem 0;
	}

	.triggered-action {
		color: green;
		font-weight: bold;
		margin-top: 0.5rem;
	}

	.test-area input,
	.test-area textarea {
		display: block;
		width: 100%;
		padding: 0.5rem;
		margin: 0.5rem 0;
		border: 1px solid #ccc;
		border-radius: 4px;
	}

	.shortcut-overlay {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
	}

	.shortcut-panel {
		background: white;
		padding: 2rem;
		border-radius: 12px;
		max-width: 400px;
	}

	.shortcut-panel ul {
		list-style: none;
		padding: 0;
	}

	.shortcut-panel li {
		display: flex;
		justify-content: space-between;
		padding: 0.5rem 0;
		border-bottom: 1px solid #eee;
	}
</style>

This example demonstrates several important patterns. First, notice how the keyboard state is managed reactively using $state. The activeKeys Set tracks which keys are currently held down, updated on both keydown and keyup events. This enables detection of key combinations, not just individual key presses.

The shouldIgnoreEvent function implements a critical UX pattern: disabling shortcuts when the user is interacting with form elements. Without this guard, users couldn’t type the letter ‘s’ in an input field without triggering a save action. The exception for the Escape key reflects common expectations—users typically expect Escape to work everywhere.

The key normalization handles cross-platform differences. On macOS, the Command key reports as ‘Meta’, but many applications treat Command on Mac equivalently to Control on Windows/Linux. The normalization allows a single shortcut definition to work across platforms.

The Click-Outside Pattern: A Classic Use Case

One of the most common reasons to reach for <svelte:body> is implementing “click outside to close” behavior. This pattern appears in dropdown menus, modal dialogs, tooltip dismissal, and countless other UI interactions.

The naive implementation has a subtle but significant bug:

<!-- AVOID: Don't do this -->
<script>
	let isOpen = $state(false)
</script>

<svelte:body onclick={() => (isOpen = false)} />

<div class="dropdown">
	<button onclick={() => (isOpen = !isOpen)}>Toggle Menu</button>
	{#if isOpen}
		<div class="menu">
			<a href="/profile">Profile</a>
			<a href="/settings">Settings</a>
			<a href="/logout">Logout</a>
		</div>
	{/if}
</div>

The bug: clicking the toggle button fires both the button’s click handler (which tries to open the menu) and the body’s click handler (which closes it). Due to event bubbling, clicking anywhere inside the component also triggers the body handler.

Here’s the corrected implementation with proper event handling:

<script>
	let isOpen = $state(false)
	let dropdownElement = $state(null)

	function handleBodyClick(event) {
		// Only close if click originated outside the dropdown
		if (dropdownElement && !dropdownElement.contains(event.target)) {
			isOpen = false
		}
	}

	function toggleDropdown(event) {
		// Stop propagation to prevent immediate close
		event.stopPropagation()
		isOpen = !isOpen
	}

	function handleMenuClick(event) {
		// Optional: close menu after selecting an option
		// Remove this if you want the menu to stay open
		isOpen = false
	}
</script>

<svelte:body onclick={handleBodyClick} />

<div class="dropdown" bind:this={dropdownElement}>
	<button onclick={toggleDropdown} aria-expanded={isOpen}>
		Toggle Menu
		<span class="arrow">{isOpen ? '' : ''}</span>
	</button>

	{#if isOpen}
		<nav class="menu" role="menu">
			<a href="/profile" role="menuitem" onclick={handleMenuClick}>Profile</a>
			<a href="/settings" role="menuitem" onclick={handleMenuClick}>Settings</a>
			<a href="/logout" role="menuitem" onclick={handleMenuClick}>Logout</a>
		</nav>
	{/if}
</div>

<style>
	.dropdown {
		position: relative;
		display: inline-block;
	}

	.menu {
		position: absolute;
		top: 100%;
		left: 0;
		background: white;
		border: 1px solid #ddd;
		border-radius: 4px;
		box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
		min-width: 150px;
		z-index: 100;
	}

	.menu a {
		display: block;
		padding: 0.75rem 1rem;
		color: inherit;
		text-decoration: none;
	}

	.menu a:hover {
		background: #f5f5f5;
	}
</style>

The key insight is using element.contains(event.target) to determine whether the click occurred inside or outside the dropdown. This approach is more robust than stopping propagation everywhere, which can interfere with other event handling in your application.

A More Sophisticated Click-Outside Implementation

For production applications, consider a reusable action or a more sophisticated detection system:

<script>
	let isOpen = $state(false)
	let menuEl = $state(null)
	let buttonEl = $state(null)

	// Track the last interaction source to handle edge cases
	let lastInteractionSource = $state(null)

	function handleBodyClick(event) {
		// Ignore if the dropdown isn't open
		if (!isOpen) return

		// Check if click was inside the menu or button
		const clickedInside = menuEl?.contains(event.target) || buttonEl?.contains(event.target)

		if (!clickedInside) {
			isOpen = false
			lastInteractionSource = 'outside-click'
		}
	}

	function handleBodyKeydown(event) {
		if (!isOpen) return

		switch (event.key) {
			case 'Escape':
				isOpen = false
				lastInteractionSource = 'escape-key'
				// Return focus to the trigger button
				buttonEl?.focus()
				break

			case 'Tab':
				// Close dropdown when tabbing away
				// Slight delay to allow focus to move first
				requestAnimationFrame(() => {
					const focusInDropdown =
						menuEl?.contains(document.activeElement) || buttonEl?.contains(document.activeElement)

					if (!focusInDropdown) {
						isOpen = false
						lastInteractionSource = 'tab-away'
					}
				})
				break
		}
	}

	function toggleDropdown() {
		isOpen = !isOpen
		lastInteractionSource = isOpen ? 'button-click' : 'button-toggle'

		// Focus first menu item when opening
		if (isOpen) {
			requestAnimationFrame(() => {
				menuEl?.querySelector('a')?.focus()
			})
		}
	}
</script>

<svelte:body onclick={handleBodyClick} onkeydown={handleBodyKeydown} />

<div class="dropdown-container">
	<button bind:this={buttonEl} onclick={toggleDropdown} aria-expanded={isOpen} aria-haspopup="menu">
		Options
	</button>

	{#if isOpen}
		<nav bind:this={menuEl} class="dropdown-menu" role="menu" aria-label="Options menu">
			<a href="/edit" role="menuitem">Edit</a>
			<a href="/duplicate" role="menuitem">Duplicate</a>
			<a href="/archive" role="menuitem">Archive</a>
			<hr />
			<a href="/delete" role="menuitem" class="danger">Delete</a>
		</nav>
	{/if}
</div>

This implementation handles keyboard navigation properly, returns focus to the trigger when closing via Escape, and tracks the source of interactions for debugging or analytics purposes.

Event Modifiers and Capture Phase

In earlier versions of Svelte, you could apply modifiers like capture, once, and passive using the pipe syntax: on:click|capture|once. In Svelte 5, the approach differs—you work with event handler options more directly.

For capture-phase event handling (where you intercept events during the capture phase rather than the bubble phase), you can use the onclickcapture attribute pattern:

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

	function addToLog(message) {
		log = [...log, { time: Date.now(), message }]
	}

	// Capture phase - fires first, before any target handlers
	function handleCaptureClick(event) {
		addToLog(`Capture phase: ${event.target.tagName}`)
	}

	// Bubble phase - fires after target handlers, during bubbling
	function handleBubbleClick(event) {
		addToLog(`Bubble phase: ${event.target.tagName}`)
	}
</script>

<!-- Capture phase handler - note the 'capture' suffix -->
<svelte:body onclickcapture={handleCaptureClick} onclick={handleBubbleClick} />

<div class="demo">
	<button onclick={() => addToLog('Button clicked directly')}>
		Click me to see event phases
	</button>

	<div class="log">
		{#each log as entry}
			<div class="log-entry">{entry.message}</div>
		{/each}
	</div>

	<button onclick={() => (log = [])}>Clear Log</button>
</div>

Capture-phase handling is particularly useful when you need to intercept events before they reach their intended targets. A common use case is implementing a global “are you sure you want to navigate away?” prompt:

<script>
	let hasUnsavedChanges = $state(false)

	function interceptNavigation(event) {
		// Only intercept anchor clicks
		const anchor = event.target.closest('a')
		if (!anchor) return

		// Only intercept internal navigation
		if (anchor.hostname !== window.location.hostname) return

		if (hasUnsavedChanges) {
			event.preventDefault()
			event.stopPropagation()

			const confirmed = window.confirm('You have unsaved changes. Are you sure you want to leave?')

			if (confirmed) {
				hasUnsavedChanges = false
				// Programmatically navigate after confirmation
				window.location.href = anchor.href
			}
		}
	}
</script>

<svelte:body onclickcapture={interceptNavigation} />

Integrating with Svelte 5 Reactivity: The $effect Connection

The real power of <svelte:body> emerges when combined with Svelte 5’s reactivity system. You can create event handlers that respond to reactive state, use $effect to manage side effects triggered by body-level events, and build sophisticated interaction patterns.

Consider this example of a “presentation mode” feature that responds to both keyboard input and external state:

<script>
	// Core presentation state
	let slides = $state([
		{ id: 1, title: 'Introduction', content: 'Welcome to the presentation' },
		{ id: 2, title: 'Main Points', content: 'Here are the key ideas' },
		{ id: 3, title: 'Deep Dive', content: 'Let us explore further' },
		{ id: 4, title: 'Conclusion', content: 'Thank you for attending' }
	])

	let currentSlideIndex = $state(0)
	let isPresentationMode = $state(false)
	let isPointerVisible = $state(true)
	let pointerHideTimeout = $state(null)

	// Derived values
	let currentSlide = $derived(slides[currentSlideIndex])
	let progress = $derived(((currentSlideIndex + 1) / slides.length) * 100)
	let canGoBack = $derived(currentSlideIndex > 0)
	let canGoForward = $derived(currentSlideIndex < slides.length - 1)

	// Navigation functions
	function nextSlide() {
		if (canGoForward) {
			currentSlideIndex++
		}
	}

	function previousSlide() {
		if (canGoBack) {
			currentSlideIndex--
		}
	}

	function goToSlide(index) {
		if (index >= 0 && index < slides.length) {
			currentSlideIndex = index
		}
	}

	// Keyboard navigation for presentation mode
	function handlePresentationKeydown(event) {
		if (!isPresentationMode) return

		switch (event.key) {
			case 'ArrowRight':
			case 'ArrowDown':
			case ' ':
			case 'PageDown':
				event.preventDefault()
				nextSlide()
				break

			case 'ArrowLeft':
			case 'ArrowUp':
			case 'PageUp':
				event.preventDefault()
				previousSlide()
				break

			case 'Home':
				event.preventDefault()
				goToSlide(0)
				break

			case 'End':
				event.preventDefault()
				goToSlide(slides.length - 1)
				break

			case 'Escape':
				event.preventDefault()
				isPresentationMode = false
				break

			case 'f':
			case 'F':
				event.preventDefault()
				toggleFullscreen()
				break
		}
	}

	// Hide pointer after inactivity
	function handleMouseMove() {
		if (!isPresentationMode) return

		isPointerVisible = true

		// Clear existing timeout
		if (pointerHideTimeout) {
			clearTimeout(pointerHideTimeout)
		}

		// Set new timeout to hide pointer
		pointerHideTimeout = setTimeout(() => {
			isPointerVisible = false
		}, 3000)
	}

	function toggleFullscreen() {
		if (!document.fullscreenElement) {
			document.documentElement.requestFullscreen()
		} else {
			document.exitFullscreen()
		}
	}

	// Effect to manage body class for presentation mode styling
	$effect(() => {
		if (isPresentationMode) {
			document.body.classList.add('presentation-active')
		} else {
			document.body.classList.remove('presentation-active')
		}

		// Cleanup when effect re-runs or component unmounts
		return () => {
			document.body.classList.remove('presentation-active')
		}
	})

	// Effect to announce slide changes for screen readers
	$effect(() => {
		if (isPresentationMode && currentSlide) {
			// Create an aria-live announcement
			const announcement = document.createElement('div')
			announcement.setAttribute('aria-live', 'polite')
			announcement.setAttribute('aria-atomic', 'true')
			announcement.className = 'sr-only'
			announcement.textContent = `Slide ${currentSlideIndex + 1} of ${slides.length}: ${currentSlide.title}`

			document.body.appendChild(announcement)

			// Remove after announcement is made
			setTimeout(() => {
				announcement.remove()
			}, 1000)
		}
	})
</script>

<svelte:body onkeydown={handlePresentationKeydown} onmousemove={handleMouseMove} />

{#if !isPresentationMode}
	<div class="editor-view">
		<header>
			<h1>Presentation Editor</h1>
			<button onclick={() => (isPresentationMode = true)}> Start Presentation </button>
		</header>

		<div class="slide-list">
			{#each slides as slide, index}
				<div
					class={['slide-thumbnail', index === currentSlideIndex && 'active']}
					onclick={() => (currentSlideIndex = index)}
				>
					<span class="slide-number">{index + 1}</span>
					<span class="slide-title">{slide.title}</span>
				</div>
			{/each}
		</div>

		<div class="slide-editor">
			<h2>{currentSlide.title}</h2>
			<p>{currentSlide.content}</p>
		</div>
	</div>
{:else}
	<div class={['presentation-view', !isPointerVisible && 'pointer-hidden']}>
		<div class="slide">
			<h1>{currentSlide.title}</h1>
			<p>{currentSlide.content}</p>
		</div>

		<div class="presentation-controls">
			<div class="progress-bar">
				<div class="progress-fill" style="width: {progress}%"></div>
			</div>

			<div class="slide-counter">
				{currentSlideIndex + 1} / {slides.length}
			</div>
		</div>

		<nav class="presentation-nav" aria-label="Slide navigation">
			<button onclick={previousSlide} disabled={!canGoBack} aria-label="Previous slide"></button>
			<button onclick={nextSlide} disabled={!canGoForward} aria-label="Next slide"></button>
		</nav>
	</div>
{/if}

<style>
	.presentation-view {
		position: fixed;
		inset: 0;
		background: #1a1a2e;
		color: white;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
	}

	.presentation-view.pointer-hidden {
		cursor: none;
	}

	.slide {
		text-align: center;
		padding: 2rem;
	}

	.slide h1 {
		font-size: 3rem;
		margin-bottom: 1rem;
	}

	.progress-bar {
		position: fixed;
		bottom: 0;
		left: 0;
		right: 0;
		height: 4px;
		background: rgba(255, 255, 255, 0.2);
	}

	.progress-fill {
		height: 100%;
		background: #4ade80;
		transition: width 0.3s ease;
	}

	.presentation-nav {
		position: fixed;
		bottom: 2rem;
		display: flex;
		gap: 1rem;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		padding: 0;
		margin: -1px;
		overflow: hidden;
		clip: rect(0, 0, 0, 0);
		border: 0;
	}
</style>

This example showcases several advanced patterns. The $effect blocks manage side effects that extend beyond the component’s template—adding a class to the document body and creating accessibility announcements. The mouse movement handler creates a “hide cursor when idle” behavior common in presentation software. And the keyboard handler demonstrates how <svelte:body> event handling can be conditionally active based on application state.

Server-Side Rendering Considerations

When building applications that use server-side rendering (SSR), you must consider how <svelte:body> behaves during the server render phase. Since there’s no actual DOM on the server, <svelte:body> effectively becomes a no-op during SSR—no event listeners are attached.

This is usually the desired behavior. However, you need to be cautious about any logic in your event handlers that assumes a browser environment:

<script>
	import { browser } from '$app/environment' // SvelteKit-specific

	function handleKeydown(event) {
		// This check isn't strictly necessary for <svelte:body> handlers
		// (they only fire in the browser), but it's good practice
		// for any shared utilities that might be called from handlers
		if (!browser) return

		// Safe to use browser APIs here
		const isInputFocused = document.activeElement?.tagName === 'INPUT'

		if (!isInputFocused && event.key === '/') {
			event.preventDefault()
			document.querySelector('#search-input')?.focus()
		}
	}
</script>

<svelte:body onkeydown={handleKeydown} />

More subtly, if your component initializes state based on browser APIs during component creation, that code will run on the server where those APIs don’t exist:

<script>
	// AVOID:This will error during SSR
	// let windowWidth = $state(window.innerWidth);

	// PREFERRED: Safe - initialize with a default, update in effect
	let windowWidth = $state(0)

	$effect(() => {
		// This only runs in the browser
		windowWidth = window.innerWidth

		function handleResize() {
			windowWidth = window.innerWidth
		}

		window.addEventListener('resize', handleResize)

		return () => {
			window.removeEventListener('resize', handleResize)
		}
	})
</script>

<svelte:body onkeydown={handleKeydown} />

<div>Window width: {windowWidth || 'Loading...'}</div>

Note that for window resize events specifically, you’d typically use <svelte:window> rather than <svelte:body>, but the SSR consideration applies to any browser-dependent initialization.

Performance Considerations and Best Practices

While <svelte:body> provides a clean abstraction, it’s important to understand the performance implications of body-level event handling.

High-Frequency Events

Events like mousemove, scroll (on body), and pointermove can fire dozens of times per second. Attaching handlers for these events requires careful consideration:

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

	// AVOID: Potentially problematic: updates state on every mouse move
	function handleMouseMove(event) {
		mousePosition = { x: event.clientX, y: event.clientY }
	}

	// PREFERRED: throttle updates
	let lastUpdate = 0
	const THROTTLE_MS = 16 // ~60fps

	function handleMouseMoveThrottled(event) {
		const now = Date.now()
		if (now - lastUpdate < THROTTLE_MS) return

		lastUpdate = now
		mousePosition = { x: event.clientX, y: event.clientY }
	}

	// PREFERRED Alternative: use requestAnimationFrame for visual updates
	let rafId = null
	let pendingPosition = null

	function handleMouseMoveRAF(event) {
		pendingPosition = { x: event.clientX, y: event.clientY }

		if (rafId === null) {
			rafId = requestAnimationFrame(() => {
				mousePosition = pendingPosition
				rafId = null
			})
		}
	}
</script>

<svelte:body onmousemove={handleMouseMoveRAF} />

Conditional Handler Attachment

In some cases, you might want to avoid attaching event handlers entirely when they’re not needed. Unfortunately, <svelte:body> doesn’t support conditional rendering the way regular elements do—it’s always present. However, you can make handlers essentially no-ops:

<script>
	let featureEnabled = $state(false)

	// Handler returns early when feature is disabled
	function handleKeydown(event) {
		if (!featureEnabled) return

		// Actual handling logic
		if (event.key === 'Enter') {
			performAction()
		}
	}
</script>

<svelte:body onkeydown={handleKeydown} />

For truly optional event handling where you want to avoid the listener overhead entirely, you might need to fall back to the imperative approach with $effect:

<script>
	let featureEnabled = $state(false)

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

		function handleKeydown(event) {
			if (event.key === 'Enter') {
				performAction()
			}
		}

		document.body.addEventListener('keydown', handleKeydown)

		return () => {
			document.body.removeEventListener('keydown', handleKeydown)
		}
	})
</script>

Multiple Components with Body Handlers

When multiple components in your application use <svelte:body>, all their handlers will fire for each event. This is usually fine, but can lead to unexpected behavior if not considered:

<!-- ComponentA.svelte -->
<script>
  function handleClick(event) {
    console.log('Component A saw click');
    // This doesn't prevent ComponentB from also seeing the click
  }
</script>

<svelte:body onclick={handleClick} />

<!-- ComponentB.svelte -->
<script>
  function handleClick(event) {
    console.log('Component B saw click');
  }
</script>

<svelte:body onclick={handleClick} />

If you need to coordinate between multiple body-level handlers, consider using a centralized event management system:

<!-- eventBus.svelte.js -->
<script module>
	let handlers = $state(new Map())
	let handlerPriorities = $state(new Map())

	export function registerHandler(id, handler, priority = 0) {
		handlers.set(id, handler)
		handlerPriorities.set(id, priority)
	}

	export function unregisterHandler(id) {
		handlers.delete(id)
		handlerPriorities.delete(id)
	}

	export function dispatch(event) {
		// Sort handlers by priority (higher priority = runs first)
		const sortedHandlers = [...handlers.entries()].sort(
			(a, b) => handlerPriorities.get(b[0]) - handlerPriorities.get(a[0])
		)

		for (const [id, handler] of sortedHandlers) {
			const result = handler(event)

			// Allow handlers to stop propagation to other handlers
			if (result === false) break
		}
	}
</script>

Common Pitfalls and How to Avoid Them

Let’s catalog the most common mistakes developers make with <svelte:body> and establish clear patterns for avoiding them.

1. Forgetting Event Cleanup When Using Imperative Patterns

If you ever need to supplement <svelte:body> with imperative event handling, always ensure cleanup:

<script>
	$effect(() => {
		// Some imperative setup that complements <svelte:body>
		const handler = () => {
			/* ... */
		}
		document.body.addEventListener('custom-event', handler)

		// CRITICAL: Return cleanup function
		return () => {
			document.body.removeEventListener('custom-event', handler)
		}
	})
</script>

2. Blocking Default Browser Behavior Unexpectedly

Be careful with event.preventDefault() in body-level handlers. You might accidentally break expected browser behavior:

<script>
	function handleKeydown(event) {
		// AVOID:This prevents ALL keyboard shortcuts, including browser ones
		// event.preventDefault();

		// PREFERRED: only prevent default for specific keys you're handling
		if (event.key === 's' && (event.ctrlKey || event.metaKey)) {
			event.preventDefault()
			saveDocument()
		}
	}
</script>

<svelte:body onkeydown={handleKeydown} />

3. Not Considering Focus State

Keyboard handlers especially need to consider what element currently has focus:

<script>
	function handleKeydown(event) {
		// AVOID: This fires even when user is typing in a form
		// if (event.key === 'Delete') { deleteSelectedItem(); }

		// PREFERRED: Check if we're in an editable context
		const activeElement = document.activeElement
		const isEditing = activeElement?.matches('input, textarea, select, [contenteditable="true"]')

		if (!isEditing && event.key === 'Delete') {
			deleteSelectedItem()
		}
	}
</script>

<svelte:body onkeydown={handleKeydown} />

4. Event Handler Ordering Assumptions

Don’t assume a specific order of execution between <svelte:body> handlers in different components. If ordering matters, use explicit coordination mechanisms:

<script>
	import { getContext, setContext } from 'svelte'

	// Create a context for coordinating keyboard handlers
	const KEYBOARD_CONTEXT = Symbol('keyboard')

	// In a parent component
	setContext(KEYBOARD_CONTEXT, {
		layers: [],
		register(handler, priority) {
			this.layers.push({ handler, priority })
			this.layers.sort((a, b) => b.priority - a.priority)
		},
		unregister(handler) {
			this.layers = this.layers.filter((l) => l.handler !== handler)
		},
		dispatch(event) {
			for (const layer of this.layers) {
				if (layer.handler(event) === false) return
			}
		}
	})
</script>

5. Memory Leaks with Closures

Event handlers that close over reactive state can inadvertently create memory leaks or stale closure issues:

<script>
	let items = $state(['a', 'b', 'c'])

	// AVOID: This handler closes over `items` at definition time
	// In Svelte 5, this actually works correctly due to how $state
	// creates reactive references, but be aware of the pattern
	function handleKeydown(event) {
		console.log('Items:', items) // This will see current items
	}
</script>

<svelte:body onkeydown={handleKeydown} />

In Svelte 5 with runes, this is generally handled correctly because $state creates reactive references rather than static values. However, if you’re working with complex nested state or non-reactive values, be mindful of closure semantics.

It’s worth understanding when to use <svelte:body> versus alternative approaches.

<svelte:body> vs <svelte:window>

<svelte:window> attaches handlers to the window object, while <svelte:body> attaches to document.body. The practical differences:

  • resize and scroll (window-level) events should use <svelte:window>
  • Most keyboard and mouse events can use either, but <svelte:body> is more semantically appropriate for events that conceptually happen “within the page”
  • beforeunload and hashchange must use <svelte:window>
<svelte:window onresize={handleResize} onbeforeunload={handleBeforeUnload} />

<svelte:body onkeydown={handleKeydown} onclick={handleClick} />

Using Actions with <svelte:body>

The <svelte:body> element supports the use: directive, allowing you to attach Svelte actions directly to the body element. This is useful for behaviors that need to apply to the entire document body:

<script>
	function trackClicks(node) {
		function handleClick(event) {
			console.log('Body clicked at:', event.clientX, event.clientY)
		}

		node.addEventListener('click', handleClick)

		return {
			destroy() {
				node.removeEventListener('click', handleClick)
			}
		}
	}
</script>

<svelte:body use:trackClicks />

This pattern is particularly useful when integrating third-party libraries that need to attach behavior to the body element, or when you have reusable body-level behaviors across multiple components.

<svelte:body> vs <svelte:document>

Svelte also provides <svelte:document> for attaching handlers to the document object. Use this for document-level events like visibilitychange:

<script>
	let isVisible = $state(true)

	function handleVisibilityChange() {
		isVisible = !document.hidden
	}
</script>

<svelte:document onvisibilitychange={handleVisibilityChange} />

{#if !isVisible}
	<!-- Pause videos, animations, etc. when tab is hidden -->
{/if}

<svelte:body> vs Attachments

For reusable element-level behavior, Svelte 5.29+ provides attachments via the {@attach} directive. Attachments are the modern replacement for actions (use:), offering better reactivity and composability. However, for truly global events, <svelte:body> remains the right choice:

<script>
	// Attachment: reusable, element-specific behavior (Svelte 5.29+)
	function clickOutside(callback) {
		return (node) => {
			function handleClick(event) {
				if (!node.contains(event.target)) {
					callback()
				}
			}

			document.body.addEventListener('click', handleClick, true)

			// Cleanup function runs when element is removed
			return () => {
				document.body.removeEventListener('click', handleClick, true)
			}
		}
	}
</script>

<!-- Use attachments for element-specific click-outside -->
<div {@attach clickOutside(() => (isOpen = false))}>
	<!-- Dropdown content -->
</div>

<!-- Use <svelte:body> for global keyboard shortcuts -->
<svelte:body onkeydown={handleGlobalKeydown} />

Attachments have several advantages over the older use: action syntax:

  • Fully reactive: They re-run automatically when dependencies change
  • Composable: Can be spread onto components and passed through props
  • Inline support: Can be defined inline without separate function declarations
  • Better TypeScript support: Full generic support and improved type inference

Putting It All Together: A Complete Example

Let’s conclude with a comprehensive example that demonstrates multiple <svelte:body> patterns working in harmony—a drawing application with keyboard shortcuts, tool switching, and click-outside behavior:

<script>
	// Application state
	let currentTool = $state('brush')
	let brushSize = $state(10)
	let brushColor = $state('#000000')
	let isDrawing = $state(false)
	let showColorPicker = $state(false)
	let showHelp = $state(false)

	let canvasRef = $state(null)
	let colorPickerRef = $state(null)
	let lastPosition = $state(null)

	// Tool definitions
	const tools = [
		{ id: 'brush', key: 'b', name: 'Brush', icon: '🖌️' },
		{ id: 'eraser', key: 'e', name: 'Eraser', icon: '🧹' },
		{ id: 'fill', key: 'f', name: 'Fill', icon: '🪣' },
		{ id: 'picker', key: 'i', name: 'Color Picker', icon: '💉' }
	]

	// Keyboard shortcuts handler
	function handleKeydown(event) {
		// Don't intercept when typing in inputs
		if (event.target.matches('input, textarea')) return

		// Help overlay toggle
		if (event.key === '?' || (event.key === '/' && event.shiftKey)) {
			event.preventDefault()
			showHelp = !showHelp
			return
		}

		// Close any open panels with Escape
		if (event.key === 'Escape') {
			if (showHelp) showHelp = false
			if (showColorPicker) showColorPicker = false
			return
		}

		// Don't process other shortcuts if help is showing
		if (showHelp) return

		// Tool switching with single keys
		const tool = tools.find((t) => t.key === event.key.toLowerCase())
		if (tool) {
			currentTool = tool.id
			return
		}

		// Brush size adjustment
		if (event.key === '[') {
			brushSize = Math.max(1, brushSize - 5)
			return
		}
		if (event.key === ']') {
			brushSize = Math.min(100, brushSize + 5)
			return
		}

		// Color picker toggle
		if (event.key === 'c') {
			showColorPicker = !showColorPicker
			return
		}

		// Undo/Redo
		if ((event.ctrlKey || event.metaKey) && event.key === 'z') {
			event.preventDefault()
			if (event.shiftKey) {
				redo()
			} else {
				undo()
			}
			return
		}
	}

	// Click outside handler for color picker
	function handleClick(event) {
		if (showColorPicker && colorPickerRef && !colorPickerRef.contains(event.target)) {
			// Check if click was on the toggle button
			if (!event.target.closest('[data-color-toggle]')) {
				showColorPicker = false
			}
		}
	}

	// Canvas interaction handlers via body (for drag that might leave canvas)
	function handleMouseMove(event) {
		if (!isDrawing || !canvasRef) return

		const rect = canvasRef.getBoundingClientRect()
		const x = event.clientX - rect.left
		const y = event.clientY - rect.top

		if (x >= 0 && x <= rect.width && y >= 0 && y <= rect.height) {
			draw(x, y)
		}

		lastPosition = { x: event.clientX, y: event.clientY }
	}

	function handleMouseUp() {
		if (isDrawing) {
			isDrawing = false
			lastPosition = null
			saveToHistory()
		}
	}

	// Canvas-specific mouse down (not on body)
	function handleCanvasMouseDown(event) {
		isDrawing = true
		const rect = canvasRef.getBoundingClientRect()
		lastPosition = {
			x: event.clientX - rect.left,
			y: event.clientY - rect.top
		}
	}

	// Drawing implementation
	function draw(x, y) {
		if (!canvasRef || !lastPosition) return

		const ctx = canvasRef.getContext('2d')

		ctx.beginPath()
		ctx.moveTo(lastPosition.x, lastPosition.y)
		ctx.lineTo(x, y)
		ctx.strokeStyle = currentTool === 'eraser' ? '#ffffff' : brushColor
		ctx.lineWidth = brushSize
		ctx.lineCap = 'round'
		ctx.lineJoin = 'round'
		ctx.stroke()

		lastPosition = { x, y }
	}

	// History management (simplified)
	let history = $state([])
	let historyIndex = $state(-1)

	function saveToHistory() {
		// Save canvas state to history
		if (!canvasRef) return
		const imageData = canvasRef.toDataURL()
		history = [...history.slice(0, historyIndex + 1), imageData]
		historyIndex = history.length - 1
	}

	function undo() {
		if (historyIndex > 0) {
			historyIndex--
			restoreFromHistory()
		}
	}

	function redo() {
		if (historyIndex < history.length - 1) {
			historyIndex++
			restoreFromHistory()
		}
	}

	function restoreFromHistory() {
		if (!canvasRef || !history[historyIndex]) return

		const img = new Image()
		img.onload = () => {
			const ctx = canvasRef.getContext('2d')
			ctx.clearRect(0, 0, canvasRef.width, canvasRef.height)
			ctx.drawImage(img, 0, 0)
		}
		img.src = history[historyIndex]
	}

	// Initialize canvas
	$effect(() => {
		if (canvasRef) {
			const ctx = canvasRef.getContext('2d')
			ctx.fillStyle = '#ffffff'
			ctx.fillRect(0, 0, canvasRef.width, canvasRef.height)
			saveToHistory()
		}
	})
</script>

<!-- Body-level event handlers -->
<svelte:body
	onkeydown={handleKeydown}
	onclick={handleClick}
	onmousemove={handleMouseMove}
	onmouseup={handleMouseUp}
/>

<div class="drawing-app">
	<header class="toolbar">
		<div class="tool-group">
			{#each tools as tool}
				<button
					class="tool-button"
					class:active={currentTool === tool.id}
					onclick={() => (currentTool = tool.id)}
					title="{tool.name} ({tool.key})"
				>
					{tool.icon}
				</button>
			{/each}
		</div>

		<div class="tool-group">
			<label class="size-control">
				Size: {brushSize}px
				<input type="range" min="1" max="100" bind:value={brushSize} />
			</label>
		</div>

		<div class="tool-group">
			<button
				class="color-button"
				data-color-toggle
				onclick={() => (showColorPicker = !showColorPicker)}
				style="background-color: {brushColor}"
				title="Color (c)"
			>
				<span class="sr-only">Select color</span>
			</button>

			{#if showColorPicker}
				<div class="color-picker-popup" bind:this={colorPickerRef}>
					<input type="color" bind:value={brushColor} />
					<div class="color-presets">
						{#each ['#000000', '#ff0000', '#00ff00', '#0000ff', '#ffff00', '#ff00ff'] as color}
							<button
								class="preset"
								style="background-color: {color}"
								onclick={() => {
									brushColor = color
									showColorPicker = false
								}}
							></button>
						{/each}
					</div>
				</div>
			{/if}
		</div>

		<div class="tool-group">
			<button onclick={() => (showHelp = true)} title="Help (?)"></button>
		</div>
	</header>

	<main class="canvas-container">
		<canvas bind:this={canvasRef} width="800" height="600" onmousedown={handleCanvasMouseDown}
		></canvas>

		<div class="status-bar">
			Tool: {tools.find((t) => t.id === currentTool)?.name} | Size: {brushSize}px | Color: {brushColor}
		</div>
	</main>

	{#if showHelp}
		<div class="help-overlay" role="dialog" aria-label="Keyboard shortcuts">
			<div class="help-panel">
				<h2>Keyboard Shortcuts</h2>

				<section>
					<h3>Tools</h3>
					<ul>
						{#each tools as tool}
							<li><kbd>{tool.key}</kbd> {tool.name}</li>
						{/each}
					</ul>
				</section>

				<section>
					<h3>Brush Size</h3>
					<ul>
						<li><kbd>[</kbd> Decrease size</li>
						<li><kbd>]</kbd> Increase size</li>
					</ul>
				</section>

				<section>
					<h3>Other</h3>
					<ul>
						<li><kbd>c</kbd> Toggle color picker</li>
						<li><kbd>Ctrl+Z</kbd> Undo</li>
						<li><kbd>Ctrl+Shift+Z</kbd> Redo</li>
						<li><kbd>?</kbd> Toggle this help</li>
						<li><kbd>Esc</kbd> Close panels</li>
					</ul>
				</section>

				<button onclick={() => (showHelp = false)}>Close</button>
			</div>
		</div>
	{/if}
</div>

<style>
	.drawing-app {
		display: flex;
		flex-direction: column;
		height: 100vh;
		font-family: system-ui, sans-serif;
	}

	.toolbar {
		display: flex;
		gap: 1rem;
		padding: 0.5rem 1rem;
		background: #f5f5f5;
		border-bottom: 1px solid #ddd;
		align-items: center;
	}

	.tool-group {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		position: relative;
	}

	.tool-button {
		width: 40px;
		height: 40px;
		font-size: 1.25rem;
		border: 2px solid transparent;
		background: white;
		border-radius: 4px;
		cursor: pointer;
	}

	.tool-button.active {
		border-color: #007bff;
		background: #e7f1ff;
	}

	.color-button {
		width: 40px;
		height: 40px;
		border: 2px solid #ccc;
		border-radius: 4px;
		cursor: pointer;
	}

	.color-picker-popup {
		position: absolute;
		top: 100%;
		left: 0;
		background: white;
		padding: 1rem;
		border-radius: 8px;
		box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
		z-index: 100;
	}

	.color-presets {
		display: flex;
		gap: 0.5rem;
		margin-top: 0.5rem;
	}

	.preset {
		width: 24px;
		height: 24px;
		border: 1px solid #ccc;
		border-radius: 4px;
		cursor: pointer;
	}

	.canvas-container {
		flex: 1;
		display: flex;
		flex-direction: column;
		align-items: center;
		justify-content: center;
		padding: 1rem;
		background: #e0e0e0;
	}

	canvas {
		border: 1px solid #ccc;
		background: white;
		cursor: crosshair;
	}

	.status-bar {
		margin-top: 0.5rem;
		font-size: 0.875rem;
		color: #666;
	}

	.help-overlay {
		position: fixed;
		inset: 0;
		background: rgba(0, 0, 0, 0.5);
		display: flex;
		align-items: center;
		justify-content: center;
		z-index: 1000;
	}

	.help-panel {
		background: white;
		padding: 2rem;
		border-radius: 12px;
		max-width: 500px;
		max-height: 80vh;
		overflow-y: auto;
	}

	.help-panel h2 {
		margin-top: 0;
	}

	.help-panel section {
		margin: 1rem 0;
	}

	.help-panel ul {
		list-style: none;
		padding: 0;
	}

	.help-panel li {
		padding: 0.25rem 0;
	}

	kbd {
		background: #eee;
		border: 1px solid #ccc;
		border-radius: 3px;
		padding: 0.1em 0.4em;
		font-family: monospace;
		font-size: 0.9em;
	}

	.sr-only {
		position: absolute;
		width: 1px;
		height: 1px;
		padding: 0;
		margin: -1px;
		overflow: hidden;
		clip: rect(0, 0, 0, 0);
		border: 0;
	}
</style>

Conclusion

<svelte:body> represents one of Svelte’s most elegant solutions to a common web development challenge—managing global-level interactions without compromising the declarative, component-based architecture that makes modern frameworks valuable. From keyboard shortcuts to click-outside patterns, from drag-and-drop to presentation modes, the ability to declaratively attach event listeners to the document body opens up sophisticated interaction patterns that would otherwise require tedious imperative boilerplate.

The patterns and examples in this tutorial demonstrate that mastering <svelte:body> isn’t just about learning syntax—it’s about understanding event propagation, considering accessibility, managing performance, and architecting reusable solutions. As you build more complex applications, the techniques discussed here—conditional handler logic, coordinated state management, SSR-aware initialization—will prove invaluable.

Key Takeaways

  • <svelte:body> provides declarative, SSR-safe event handling at the document body level with automatic listener cleanup, eliminating memory leak risks and imperative lifecycle management
  • Event handlers use the on* attribute syntax in Svelte 5 (onkeydown, onclick, onmousemove) with optional capture phase handling via capture suffix (onclickcapture)
  • Click-outside detection requires element.contains(event.target) checks to avoid triggering on clicks within the component, combined with event.stopPropagation() on toggle buttons
  • Keyboard shortcut systems need input filtering using shouldIgnoreEvent() to prevent interference with form inputs while still allowing escape keys, and cross-platform key normalization for modifier keys
  • High-frequency events (mousemove, scroll) require throttling via requestAnimationFrame or time-based throttling to prevent performance degradation from dozens of state updates per second
  • Multiple components with body handlers execute all handlers for each event, requiring centralized coordination mechanisms or priority systems when execution order matters
  • The {@attach} directive (Svelte 5.29+) offers an alternative for element-specific behavior with better reactivity than use: actions, though <svelte:body> remains ideal for truly global events
  • SSR behavior makes <svelte:body> a no-op on the server with no event listeners attached, though handlers that reference browser APIs should still check for browser context in shared utilities

See Also