Understanding the Document Object Model Context

Before diving into the mechanics of svelte:document, it’s essential to understand why this special element exists and what problem space it occupies within Svelte’s architecture. The browser’s Document Object Model presents a hierarchical structure where the document object sits as the root node containing all page content, distinct from the window object (which represents the browser window itself) and the body element (the visible container for page content).

Many browser events bubble up through this hierarchy, but certain events fire specifically on the document object and never reach the window. The visibilitychange event represents the most common example—this event fires exclusively on document when users switch tabs, minimize browsers, or navigate away. Similarly, properties like activeElement (tracking which element currently has focus) exist only on document, making direct document access necessary for certain application behaviors.

Svelte’s <svelte:document> special element provides declarative access to this document-level functionality while maintaining Svelte’s principles of automatic cleanup, server-side rendering safety, and reactive binding support. Without this abstraction, developers would need to manually wire up event listeners in lifecycle hooks, remember to clean them up on component destruction, and handle SSR environments where document doesn’t exist.

Basic Syntax and Placement Requirements

The <svelte:document> element follows a strict placement rule shared with its sibling special elements <svelte:window> and <svelte:body>: it must appear at the top level of your component, never nested inside blocks, elements, or conditional structures.

<script>
	let visibilityState = $state('visible')

	function handleVisibilityChange() {
		visibilityState = document.visibilityState
		console.log(`Page visibility changed to: ${visibilityState}`)
	}
</script>

<!-- Correct: top-level placement -->
<svelte:document onvisibilitychange={handleVisibilityChange} />

<main>
	<p>Current visibility: {visibilityState}</p>
</main>

Attempting to place <svelte:document> inside conditional blocks or elements will trigger a compiler error. This restriction exists because Svelte needs to register document-level listeners during component initialization, not conditionally during rendering. The compiler enforces this to prevent confusing runtime behavior where listeners might or might not exist based on render conditions.

<script>
	let showDocumentHandler = $state(true);
</script>

<!-- NCORRECT: This will cause a compiler error -->
{#if showDocumentHandler}
	<svelte:document onvisibilitychange={handleChange} />
{/if}

<!--INCORRECT: Cannot nest inside elements -->
<div>
	<svelte:document onvisibilitychange={handleChange} />
</div>

The rationale behind this constraint connects to Svelte’s compilation strategy. Special elements like <svelte:document> compile to direct DOM API calls during component initialization. Placing them inside conditional blocks would require dynamic listener management with complex lifecycle coordination, defeating the simplicity that makes these elements valuable.

Event Handling on the Document

The primary use case for <svelte:document> involves listening to document-specific events. Svelte 5 uses the modern oneventname syntax rather than the legacy on:eventname directive, aligning with native DOM patterns and improving performance through event delegation.

The visibilitychange Event

The visibilitychange event represents the canonical example for <svelte:document> usage. This event fires whenever the page’s visibility state changes—when users switch browser tabs, minimize windows, or navigate to different applications.

<script>
	let isPageVisible = $state(true)
	let lastVisibilityChange = $state(null)
	let hiddenDuration = $state(0)
	let hiddenStartTime = $state(null)

	function handleVisibilityChange(event) {
		const now = new Date()
		lastVisibilityChange = now.toLocaleTimeString()

		if (document.visibilityState === 'hidden') {
			isPageVisible = false
			hiddenStartTime = Date.now()
		} else {
			isPageVisible = true
			if (hiddenStartTime) {
				hiddenDuration += Date.now() - hiddenStartTime
				hiddenStartTime = null
			}
		}
	}
</script>

<svelte:document onvisibilitychange={handleVisibilityChange} />

<div class="visibility-tracker">
	<div class={{ status: true, visible: isPageVisible, hidden: !isPageVisible }}>
		{isPageVisible ? '👁️ Page Visible' : '🙈 Page Hidden'}
	</div>

	{#if lastVisibilityChange}
		<p>Last change: {lastVisibilityChange}</p>
	{/if}

	<p>Total time hidden: {Math.round(hiddenDuration / 1000)} seconds</p>
</div>

<style>
	.status {
		padding: 1rem;
		border-radius: 0.5rem;
		font-weight: bold;
		transition: all 0.3s ease;
	}

	.visible {
		background: #d4edda;
		color: #155724;
	}

	.hidden {
		background: #f8d7da;
		color: #721c24;
	}
</style>

This pattern proves invaluable for applications that need to pause expensive operations when users navigate away. Video players can pause playback, analytics systems can track engagement time accurately, and real-time applications can reduce server polling when the page isn’t visible.

Selection and Clipboard Events

The document object serves as the target for several selection and clipboard-related events that don’t bubble to window:

<script>
	let selectedText = $state('')
	let selectionCount = $state(0)
	let clipboardHistory = $state([])

	function handleSelectionChange() {
		const selection = document.getSelection()
		const text = selection?.toString() || ''

		if (text !== selectedText) {
			selectedText = text
			if (text) {
				selectionCount++
			}
		}
	}

	function handleCopy(event) {
		const copiedText = document.getSelection()?.toString()
		if (copiedText) {
			clipboardHistory = [
				{ text: copiedText, timestamp: new Date() },
				...clipboardHistory.slice(0, 9)
			]
		}
	}

	function handlePaste(event) {
		const pastedData = event.clipboardData?.getData('text')
		console.log('User pasted:', pastedData)
	}
</script>

<svelte:document
	onselectionchange={handleSelectionChange}
	oncopy={handleCopy}
	onpaste={handlePaste}
/>

<article>
	<h2>Text Selection Tracker</h2>
	<p>
		Select any text on this page to see it tracked below. The selection change event fires on the
		document whenever the user's text selection changes, whether by mouse, keyboard, or programmatic
		manipulation.
	</p>

	<p>
		Lorem ipsum dolor sit amet, consectetur adipiscing elit. Vestibulum ante ipsum primis in
		faucibus orci luctus et ultrices posuere cubilia curae; Praesent euismod neque non eros
		efficitur, at consequat lorem facilisis.
	</p>
</article>

<aside class="selection-info">
	<h3>Current Selection</h3>
	<pre>{selectedText || '(nothing selected)'}</pre>
	<p>Total selections made: {selectionCount}</p>

	{#if clipboardHistory.length > 0}
		<h3>Copy History</h3>
		<ul>
			{#each clipboardHistory as entry}
				<li>
					<span class="timestamp">{entry.timestamp.toLocaleTimeString()}</span>
					<code>{entry.text.slice(0, 50)}{entry.text.length > 50 ? '...' : ''}</code>
				</li>
			{/each}
		</ul>
	{/if}
</aside>

Fullscreen Events

Managing fullscreen state requires document-level event handling since the fullscreenchange and fullscreenerror events fire on document:

<script>
	let isFullscreen = $state(false)
	let fullscreenElement = $state(null)
	let fullscreenError = $state(null)

	function handleFullscreenChange() {
		isFullscreen = !!document.fullscreenElement
		fullscreenElement = document.fullscreenElement?.tagName || null

		if (!isFullscreen) {
			console.log('Exited fullscreen mode')
		}
	}

	function handleFullscreenError(event) {
		fullscreenError = 'Failed to enter fullscreen mode. The browser may have blocked the request.'
		setTimeout(() => (fullscreenError = null), 5000)
	}

	async function toggleFullscreen(element) {
		try {
			if (!document.fullscreenElement) {
				await element.requestFullscreen()
			} else {
				await document.exitFullscreen()
			}
		} catch (err) {
			fullscreenError = err.message
		}
	}

	let videoContainer
</script>

<svelte:document
	onfullscreenchange={handleFullscreenChange}
	onfullscreenerror={handleFullscreenError}
/>

<div class="fullscreen-demo">
	<div bind:this={videoContainer} class={['video-container', isFullscreen && 'fullscreen']}>
		<div class="video-placeholder">
			<p>Video Content Area</p>
			<p class="status">
				{isFullscreen ? 'Currently in fullscreen' : 'Normal view'}
			</p>
		</div>

		<button onclick={() => toggleFullscreen(videoContainer)}>
			{isFullscreen ? 'Exit Fullscreen' : 'Enter Fullscreen'}
		</button>
	</div>

	{#if fullscreenError}
		<div class="error-message" role="alert">
			{fullscreenError}
		</div>
	{/if}

	{#if fullscreenElement}
		<p>Fullscreen element: <code>&lt;{fullscreenElement.toLowerCase()}&gt;</code></p>
	{/if}
</div>

Reactive Bindings: Reading Document State

Beyond event handling, <svelte:document> provides reactive bindings to four document properties. Unlike many element bindings in Svelte, all document bindings are readonly—you can observe their values but cannot programmatically set them through binding.

activeElement Binding

The activeElement property tracks which DOM element currently has focus. This binding updates reactively as users tab through form fields, click interactive elements, or when focus is programmatically moved:

<script>
	let activeElement = $state(null)
	let focusHistory = $state([])

	$effect(() => {
		if (activeElement) {
			const elementDescription = describeElement(activeElement)

			if (focusHistory[0]?.description !== elementDescription) {
				focusHistory = [
					{ description: elementDescription, time: new Date() },
					...focusHistory.slice(0, 19)
				]
			}
		}
	})

	function describeElement(el) {
		if (!el) return 'None'

		const tag = el.tagName.toLowerCase()
		const id = el.id ? `#${el.id}` : ''
		const className = el.className ? `.${el.className.split(' ')[0]}` : ''
		const name = el.name ? `[name="${el.name}"]` : ''
		const type = el.type ? `[type="${el.type}"]` : ''

		return `${tag}${id}${className}${name}${type}`
	}
</script>

<svelte:document bind:activeElement />

<div class="focus-tracker">
	<div class="current-focus">
		<h3>Currently Focused Element</h3>
		<code class="element-display">
			{describeElement(activeElement)}
		</code>
	</div>

	<form class="demo-form">
		<h3>Test Form</h3>

		<div class="field">
			<label for="username">Username</label>
			<input type="text" id="username" name="username" placeholder="Enter username" />
		</div>

		<div class="field">
			<label for="email">Email</label>
			<input type="email" id="email" name="email" placeholder="Enter email" />
		</div>

		<div class="field">
			<label for="password">Password</label>
			<input type="password" id="password" name="password" placeholder="Enter password" />
		</div>

		<div class="field">
			<label for="role">Role</label>
			<select id="role" name="role">
				<option>Developer</option>
				<option>Designer</option>
				<option>Manager</option>
			</select>
		</div>

		<div class="actions">
			<button type="button">Cancel</button>
			<button type="submit">Submit</button>
		</div>
	</form>

	<div class="focus-history">
		<h3>Focus History</h3>
		<ol reversed>
			{#each focusHistory as entry}
				<li>
					<code>{entry.description}</code>
					<small>{entry.time.toLocaleTimeString()}</small>
				</li>
			{/each}
		</ol>
	</div>
</div>

The activeElement binding enables sophisticated focus management patterns, accessibility enhancements, and keyboard navigation systems. It’s particularly valuable for modal dialogs that need to trap focus, skip-link implementations, and complex form validation that responds to field focus.

visibilityState Binding

While the visibilitychange event tells you when visibility changes, the visibilityState binding gives you reactive access to the current state:

<script>
	let visibilityState = $state('visible')
	let pollInterval = $state(null)
	let dataFetchCount = $state(0)

	$effect(() => {
		if (visibilityState === 'visible') {
			pollInterval = setInterval(() => {
				fetchLatestData()
			}, 5000)

			fetchLatestData()

			return () => {
				clearInterval(pollInterval)
			}
		} else {
			console.log('Polling paused - page hidden')
		}
	})

	async function fetchLatestData() {
		dataFetchCount++
		console.log(`Fetch #${dataFetchCount} - Page is ${visibilityState}`)
	}
</script>

<svelte:document bind:visibilityState />

<div class="polling-demo">
	<h2>Visibility-Aware Polling</h2>

	<div class="status-card" data-visibility={visibilityState}>
		<span class="indicator"></span>
		<span>Page is {visibilityState}</span>
	</div>

	<p>Data fetches performed: <strong>{dataFetchCount}</strong></p>

	<p class="explanation">
		This component automatically pauses API polling when you switch to another tab and resumes when
		you return. Try switching tabs and watch the fetch count!
	</p>
</div>

<style>
	.status-card {
		display: flex;
		align-items: center;
		gap: 0.5rem;
		padding: 1rem;
		border-radius: 0.5rem;
		background: #f0f0f0;
	}

	.indicator {
		width: 12px;
		height: 12px;
		border-radius: 50%;
		background: #28a745;
	}

	[data-visibility='hidden'] .indicator {
		background: #dc3545;
	}
</style>

fullscreenElement and pointerLockElement Bindings

These bindings provide reactive access to the current fullscreen element and any element that has captured pointer lock:

<script>
	let fullscreenElement = $state(null)
	let pointerLockElement = $state(null)

	let gameCanvas

	$effect(() => {
		if (pointerLockElement === gameCanvas) {
			console.log('Game has pointer lock - mouse movement now controls the game')
		}
	})

	function startGame() {
		gameCanvas?.requestPointerLock()
	}

	function handleMouseMove(event) {
		if (pointerLockElement === gameCanvas) {
			console.log('Mouse delta:', event.movementX, event.movementY)
		}
	}
</script>

<svelte:document bind:fullscreenElement bind:pointerLockElement />

<div class="game-container">
	<canvas
		bind:this={gameCanvas}
		width="800"
		height="600"
		onmousemove={handleMouseMove}
		onclick={startGame}
	></canvas>

	<div class="game-status">
		{#if pointerLockElement}
			<p>🎮 Pointer locked to: {pointerLockElement.tagName}</p>
			<p><kbd>Esc</kbd> to release</p>
		{:else}
			<p>Click canvas to capture pointer</p>
		{/if}

		{#if fullscreenElement}
			<p>📺 Fullscreen active</p>
		{/if}
	</div>
</div>

Integrating Attachments with <svelte:document>

Svelte 5.29 introduced attachments via the {@attach ...} directive as the modern, fully reactive way to attach behaviors to elements. Attachments are functions that run in an effect when an element mounts and can optionally return a cleanup function. Unlike the older use: directive (actions), attachments are fully reactive—they automatically re-run when any state read inside them changes.

The <svelte:document> special element supports both the legacy use: directive and the modern {@attach ...} syntax, allowing you to encapsulate reusable document-level behaviors.

Understanding Attachments vs Actions

Before diving into document-level usage, let’s clarify the distinction between these two approaches:

Actions (use:): Run once when the element mounts. They don’t automatically re-run when arguments change (though they can use internal $effect blocks for reactivity). Actions are the legacy approach but remain supported.

Attachments ({@attach ...}): Run in an effect context, making them fully reactive. When state read inside the attachment changes, the entire attachment re-runs (cleanup function is called first, then the attachment runs again). This is the recommended approach in Svelte 5.29+.

<script>
	let count = $state(0)

	// Action approach (legacy) - does NOT re-run when count changes
	function actionExample(node, initialCount) {
		console.log('Action ran with:', initialCount)
		// Would need internal $effect to react to changes
	}

	// Attachment approach (modern) - automatically re-runs when count changes
	function attachmentExample(node) {
		console.log('Attachment ran with:', count)
		return () => console.log('Attachment cleanup')
	}
</script>

<!-- Legacy action syntax -->
<div use:actionExample={count}></div>

<!-- Modern attachment syntax - fully reactive -->
<div {@attach attachmentExample}></div>

Document-Level Attachments: Idle Detection

One powerful use case for document-level attachments is implementing idle detection that reacts to configuration changes:

<script>
	let userIsIdle = $state(false)
	let idleTimeout = $state(10000)
	let lastActivity = $state(new Date())

	/**
	 * Attachment factory for idle detection
	 * @param {number} timeout - Milliseconds before considering user idle
	 * @returns {import('svelte/attachments').Attachment<Document>}
	 */
	function idleTracker(timeout) {
		return (node) => {
			let idleTimer

			function resetTimer() {
				clearTimeout(idleTimer)

				if (userIsIdle) {
					userIsIdle = false
				}
				lastActivity = new Date()

				idleTimer = setTimeout(() => {
					userIsIdle = true
				}, timeout)
			}

			const activityEvents = ['mousedown', 'mousemove', 'keydown', 'scroll', 'touchstart']

			activityEvents.forEach((event) => {
				node.addEventListener(event, resetTimer, { passive: true })
			})

			resetTimer()

			// Cleanup function - runs before re-run or on unmount
			return () => {
				clearTimeout(idleTimer)
				activityEvents.forEach((event) => {
					node.removeEventListener(event, resetTimer)
				})
			}
		}
	}
</script>

<!--
	Because idleTracker(idleTimeout) runs in an effect context,
	changing idleTimeout will automatically tear down and recreate
	the idle tracker with the new timeout value!
-->
<svelte:document {@attach idleTracker(idleTimeout)} />

<div class="idle-demo">
	<header>
		<h2>Reactive Idle Detection</h2>

		<div class={['user-status', userIsIdle && 'idle']}>
			{userIsIdle ? '💤 User Idle' : '✨ User Active'}
		</div>
	</header>

	<div class="controls">
		<label>
			Idle timeout (seconds):
			<input
				type="range"
				min="5"
				max="60"
				bind:value={() => idleTimeout / 1000, (v) => (idleTimeout = v * 1000)}
			/>
			<span>{idleTimeout / 1000}s</span>
		</label>
	</div>

	<p class="last-activity">
		Last activity: {lastActivity.toLocaleTimeString()}
	</p>

	<p class="explanation">
		Try adjusting the timeout slider—the idle tracker automatically reconfigures itself with the new
		value thanks to attachment reactivity!
	</p>
</div>

<style>
	.user-status {
		padding: 0.5rem 1rem;
		border-radius: 0.5rem;
		font-weight: bold;
		background: #d4edda;
		color: #155724;
		transition: all 0.3s ease;
	}

	.user-status.idle {
		background: #fff3cd;
		color: #856404;
	}
</style>

Keyboard Shortcuts with Reactive Configuration

Another powerful pattern involves keyboard shortcuts that can be dynamically reconfigured:

<script>
	let lastShortcut = $state('')
	let showCommandPalette = $state(false)
	let shortcutsEnabled = $state(true)

	/**
	 * Creates an attachment for keyboard shortcut handling
	 * @param {Record<string, (event: KeyboardEvent) => void>} shortcuts
	 * @returns {import('svelte/attachments').Attachment<Document>}
	 */
	function keyboardShortcuts(shortcuts) {
		return (node) => {
			function handleKeydown(event) {
				if (!shortcutsEnabled) return

				const key = []
				if (event.ctrlKey || event.metaKey) key.push('ctrl')
				if (event.shiftKey) key.push('shift')
				if (event.altKey) key.push('alt')
				key.push(event.key.toLowerCase())

				const combo = key.join('+')

				if (shortcuts[combo]) {
					event.preventDefault()
					shortcuts[combo](event)
					lastShortcut = combo
				}
			}

			node.addEventListener('keydown', handleKeydown)

			return () => {
				node.removeEventListener('keydown', handleKeydown)
			}
		}
	}

	// Define shortcuts - changing this object will trigger attachment re-run
	let shortcuts = $derived({
		'ctrl+k': () => (showCommandPalette = !showCommandPalette),
		'ctrl+s': () => console.log('Save triggered'),
		'ctrl+shift+p': () => console.log('Quick actions'),
		escape: () => (showCommandPalette = false)
	})
</script>

<svelte:document {@attach keyboardShortcuts(shortcuts)} />

<div class="shortcuts-demo">
	<header>
		<h2>Keyboard Shortcuts</h2>

		<label class="toggle">
			<input type="checkbox" bind:checked={shortcutsEnabled} />
			Shortcuts {shortcutsEnabled ? 'enabled' : 'disabled'}
		</label>
	</header>

	<section class="shortcuts-list">
		<h3>Available Shortcuts</h3>
		<ul>
			<li><kbd>Ctrl</kbd> + <kbd>K</kbd> — Toggle Command Palette</li>
			<li><kbd>Ctrl</kbd> + <kbd>S</kbd> — Save</li>
			<li><kbd>Ctrl</kbd> + <kbd>Shift</kbd> + <kbd>P</kbd> — Quick Actions</li>
			<li><kbd>Escape</kbd> — Close dialogs</li>
		</ul>

		{#if lastShortcut}
			<p class="last-shortcut">Last used: <code>{lastShortcut}</code></p>
		{/if}
	</section>

	{#if showCommandPalette}
		<div class="command-palette" role="dialog" aria-label="Command Palette">
			<input type="text" placeholder="Type a command..." autofocus />
			<p class="hint">Press <kbd>Escape</kbd> to close</p>
		</div>
	{/if}
</div>

<style>
	.command-palette {
		position: fixed;
		top: 20%;
		left: 50%;
		transform: translateX(-50%);
		background: white;
		padding: 1.5rem;
		border-radius: 0.5rem;
		box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
		z-index: 1000;
	}

	.command-palette input {
		width: 300px;
		padding: 0.75rem;
		font-size: 1rem;
		border: 1px solid #ddd;
		border-radius: 0.25rem;
	}

	kbd {
		background: #eee;
		padding: 0.2rem 0.4rem;
		border-radius: 0.25rem;
		font-family: monospace;
		font-size: 0.9em;
	}
</style>

Inline Attachments for Document-Level Behavior

For simpler cases, you can define attachments inline directly on <svelte:document>:

<script>
	let documentClickCount = $state(0)
	let lastClickTarget = $state('')
</script>

<svelte:document
	{@attach (doc) => {
		function handleClick(event) {
			documentClickCount++
			lastClickTarget = event.target?.tagName || 'unknown'
		}

		doc.addEventListener('click', handleClick)

		return () => {
			doc.removeEventListener('click', handleClick)
		}
	}}
/>

<div class="click-tracker">
	<p>Document clicks: {documentClickCount}</p>
	<p>Last clicked element: <code>{lastClickTarget || 'none'}</code></p>
</div>

Controlling Attachment Re-runs

Because attachments are fully reactive, they re-run whenever any state read inside them changes. For expensive setup operations, you may want to isolate the reactive parts into a nested effect:

<script>
	let config = $state({ threshold: 100, debug: false })

	/**
	 * Attachment with expensive setup that shouldn't re-run on every config change
	 * @returns {import('svelte/attachments').Attachment<Document>}
	 */
	function expensiveDocumentBehavior() {
		return (node) => {
			// Expensive setup - only runs once per mount
			console.log('Expensive setup work...')
			const observer = new IntersectionObserver(() => {})

			// Reactive part - runs when config changes
			$effect(() => {
				console.log('Config changed:', config.threshold, config.debug)
				// React to config changes without re-running expensive setup
			})

			return () => {
				observer.disconnect()
			}
		}
	}
</script>

<svelte:document {@attach expensiveDocumentBehavior()} />

Converting Legacy Actions to Attachments

If you’re using third-party libraries that provide actions, you can convert them to attachments using the fromAction utility from svelte/attachments:

<script>
	import { fromAction } from 'svelte/attachments'
	import { someLibraryAction } from 'some-library'

	// Convert the action to an attachment
	const attachableVersion = fromAction(someLibraryAction)
</script>

<!-- Now usable with {@attach ...} syntax -->
<svelte:document {@attach attachableVersion(options)} />

Coordinating Multiple Special Elements

Real-world applications often need to coordinate document, window, and body events together. Understanding how these three special elements differ helps you choose the right tool for each situation:

<script>
	// Window-specific: scroll position, resize, online/offline
	let scrollY = $state(0)
	let innerWidth = $state(0)
	let online = $state(true)

	// Document-specific: visibility, active element, fullscreen
	let visibilityState = $state('visible')
	let activeElement = $state(null)

	// Body-specific: mouse enter/leave the viewport
	let mouseInViewport = $state(true)

	// Derived state combining multiple sources
	let userEngagement = $derived.by(() => {
		if (visibilityState === 'hidden') return 'away'
		if (!mouseInViewport) return 'distracted'
		if (!activeElement || activeElement === document.body) return 'passive'
		return 'engaged'
	})

	$effect(() => {
		console.log(`User engagement level: ${userEngagement}`)

		if (userEngagement === 'away') {
			// Pause expensive operations
		} else if (userEngagement === 'engaged') {
			// Full operation mode
		}
	})
</script>

<!-- Window bindings for viewport and network state -->
<svelte:window bind:scrollY bind:innerWidth bind:online />

<!-- Document bindings for page-level state -->
<svelte:document bind:visibilityState bind:activeElement />

<!-- Body events for mouse presence detection -->
<svelte:body
	onmouseenter={() => (mouseInViewport = true)}
	onmouseleave={() => (mouseInViewport = false)}
/>

<div class="engagement-dashboard">
	<h2>User Engagement Monitor</h2>

	<div class="metrics-grid">
		<div class="metric">
			<span class="label">Engagement</span>
			<span class="value" data-level={userEngagement}>{userEngagement}</span>
		</div>

		<div class="metric">
			<span class="label">Page Visible</span>
			<span class="value">{visibilityState === 'visible' ? '' : ''}</span>
		</div>

		<div class="metric">
			<span class="label">Mouse in Viewport</span>
			<span class="value">{mouseInViewport ? '' : ''}</span>
		</div>

		<div class="metric">
			<span class="label">Network</span>
			<span class="value">{online ? 'Online' : 'Offline'}</span>
		</div>

		<div class="metric">
			<span class="label">Scroll Position</span>
			<span class="value">{Math.round(scrollY)}px</span>
		</div>

		<div class="metric">
			<span class="label">Viewport Width</span>
			<span class="value">{innerWidth}px</span>
		</div>
	</div>
</div>

Server-Side Rendering Considerations

Svelte’s special elements are designed with SSR safety in mind. The <svelte:document> element compiles to code that only executes in browser environments, preventing “document is not defined” errors during server rendering.

However, you must still be careful with code in your <script> block that references browser globals:

<script>
	// DANGEROUS: This runs during SSR and will crash
	// const state = document.visibilityState;

	// SAFE: Initialize with a sensible default
	let visibilityState = $state('visible')

	// SAFE: Use $effect for browser-only code
	$effect(() => {
		// This only runs in the browser after mount
		visibilityState = document.visibilityState
	})

	// SAFE: Check for browser environment explicitly
	function checkDocumentState() {
		if (typeof document === 'undefined') return
		// Safe to access document here
	}
</script>

<svelte:document bind:visibilityState />

The binding itself handles the SSR case gracefully—bind:visibilityState won’t cause errors during server rendering. But any imperative code accessing document directly needs protection.

For SvelteKit applications, use the browser check from $app/environment:

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

	let activeElement = $state(null)
	let documentTitle = $state('')

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

		documentTitle = document.title

		const observer = new MutationObserver(() => {
			documentTitle = document.title
		})

		const titleElement = document.querySelector('title')
		if (titleElement) {
			observer.observe(titleElement, { childList: true })
		}

		return () => observer.disconnect()
	})
</script>

<svelte:document bind:activeElement />

<div>
	<p>Current page title: {documentTitle}</p>
	<p>Active element: {activeElement?.tagName || 'None'}</p>
</div>

Advanced Pattern: Building a Focus Trap

Focus trapping is essential for accessible modal dialogs. Using <svelte:document> combined with the activeElement binding, we can build a robust focus trap:

<script>
	let activeElement = $state(null)
	let trapContainer = $state(null)
	let isTrapping = $state(false)
	let previousActiveElement = null

	export function activate(container) {
		trapContainer = container
		isTrapping = true
		previousActiveElement = activeElement

		const firstFocusable = getFirstFocusable(container)
		firstFocusable?.focus()
	}

	export function deactivate() {
		isTrapping = false
		trapContainer = null

		previousActiveElement?.focus()
		previousActiveElement = null
	}

	function getFirstFocusable(container) {
		return container?.querySelector(
			'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
		)
	}

	function getAllFocusable(container) {
		return Array.from(
			container?.querySelectorAll(
				'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
			) || []
		)
	}

	function handleKeydown(event) {
		if (!isTrapping || !trapContainer) return

		if (event.key === 'Tab') {
			const focusable = getAllFocusable(trapContainer)
			const firstFocusable = focusable[0]
			const lastFocusable = focusable[focusable.length - 1]

			if (event.shiftKey && activeElement === firstFocusable) {
				event.preventDefault()
				lastFocusable?.focus()
			} else if (!event.shiftKey && activeElement === lastFocusable) {
				event.preventDefault()
				firstFocusable?.focus()
			}
		}

		if (event.key === 'Escape') {
			deactivate()
		}
	}

	// Guard against focus leaving the trap
	$effect(() => {
		if (!isTrapping || !trapContainer || !activeElement) return

		if (!trapContainer.contains(activeElement)) {
			const firstFocusable = getFirstFocusable(trapContainer)
			firstFocusable?.focus()
		}
	})
</script>

<svelte:document bind:activeElement onkeydown={handleKeydown} />

Common Pitfalls and How to Avoid Them

1. Forgetting That Bindings Are Readonly

All <svelte:document> bindings are readonly. Attempting to set them programmatically won’t work:

<script>
	let activeElement = $state(null)

	function focusSpecificElement() {
		// THIS DOESN'T WORK - activeElement binding is readonly
		// activeElement = document.getElementById('target');

		// PREFERRED:Instead, focus the element directly
		document.getElementById('target')?.focus()
		// The binding will automatically update to reflect the new focus
	}
</script>

<svelte:document bind:activeElement />

2. Creating Memory Leaks with Effects

When using $effect to respond to document state changes, always return cleanup functions for any resources you create:

<script>
	let visibilityState = $state('visible')

	// AVOID: MEMORY LEAK: Interval never cleaned up
	$effect(() => {
		if (visibilityState === 'visible') {
			setInterval(() => console.log('tick'), 1000)
		}
	})

	// PREFERRED: Return cleanup function
	$effect(() => {
		if (visibilityState === 'visible') {
			const interval = setInterval(() => console.log('tick'), 1000)
			return () => clearInterval(interval)
		}
	})
</script>

<svelte:document bind:visibilityState />

3. Attachment Re-running Too Often

Attachments re-run whenever any state read inside them changes. Be mindful of what state you access:

<script>
	let config = $state({ timeout: 5000, label: 'test' });

	// AVOID PROBLEM: Re-runs on ANY config property change, even label
	function problematicAttachment() {
		return (node) => {
			console.log('Setup with timeout:', config.timeout);
			// Reading config.timeout makes attachment dependent on entire config object
			return () => console.log('Cleanup');
		};
	}

	// PREFERRED: Pass only what you need
	function betterAttachment(timeout) {
		return (node) => {
			console.log('Setup with timeout:', timeout);
			return () => console.log('Cleanup');
		};
	}
</script>

<!-- Re-runs on any config change -->
<svelte:document {@attach problematicAttachment()} />

<!-- Only re-runs when timeout changes -->
<svelte:document {@attach betterAttachment(config.timeout)} />

4. Over-relying on Document Events

Some events you might expect to need <svelte:document> for actually work fine on <svelte:window> or regular elements. Use the most specific target possible:

<!-- AVOID: Overkill: keydown events bubble, use svelte:window or element instead -->
<svelte:document onkeydown={handleKeydown} />

<!-- PREFERRED: svelte:window for global keyboard shortcuts -->
<svelte:window onkeydown={handleKeydown} />

<!-- BEST: Element-level for scoped keyboard handling -->
<div onkeydown={handleKeydown} tabindex="0">
	<!-- content -->
</div>

Reserve <svelte:document> for events that truly only fire on the document object: visibilitychange, selectionchange, fullscreenchange, and similar document-specific events.

5. Not Handling Initial State

Document bindings may not have their expected values immediately on component mount. Design your logic to handle undefined or initial states gracefully:

<script>
	let activeElement = $state(null)

	// AVOID FRAGILE: Assumes activeElement is always defined
	$effect(() => {
		console.log(activeElement.tagName) // May throw!
	})

	// PREFERRED ROBUST: Guard against null/undefined
	$effect(() => {
		if (activeElement) {
			console.log(activeElement.tagName)
		}
	})
</script>

<svelte:document bind:activeElement />

Best Practices Summary

When working with <svelte:document> in Svelte 5, keep these principles in mind:

Use <svelte:document> specifically for document-level concerns. Events like visibilitychange, selectionchange, and fullscreenchange fire only on the document. Properties like activeElement and visibilityState exist only on the document. Don’t use it for events that could be handled at a more specific level.

Prefer attachments ({@attach ...}) over actions (use:) for new code. Attachments provide full reactivity out of the box and integrate better with Svelte 5’s reactive model. Use fromAction from svelte/attachments to convert legacy actions when needed.

Combine declarative bindings with $effect for powerful reactive patterns. The binding gives you reactive access to document state; effects let you respond to that state with side effects, cleanup, and complex logic.

Remember SSR safety. While <svelte:document> itself handles SSR gracefully, any imperative code in your script block that accesses document directly needs protection via effects or environment checks.

Be mindful of attachment reactivity. Attachments re-run when any state read inside them changes. Pass only the specific values needed, or use nested effects for expensive setup operations that shouldn’t re-run.

Coordinate with <svelte:window> and <svelte:body> thoughtfully. Each special element serves a distinct purpose in the DOM hierarchy. Understanding which events and properties belong where helps you build cleaner, more maintainable components.

Conclusion

The <svelte:document> special element represents Svelte’s commitment to making DOM APIs accessible in a reactive, declarative way while handling the complexity of lifecycle management, SSR compatibility, and automatic cleanup. By bridging the gap between imperative browser APIs and Svelte’s reactive component model, it enables sophisticated interactions—from visibility-aware performance optimization to collaborative editing features—without sacrificing developer experience or code maintainability.

Mastering <svelte:document> requires understanding not just its syntax, but the broader context of how documents, windows, and bodies interact in the browser’s event model. The introduction of attachments in Svelte 5.29 further enhances this element’s power, providing full reactivity and composability for document-level interactions.

As you build increasingly sophisticated applications, the patterns explored here—visibility tracking, focus management, fullscreen coordination, and attachment-based reactivity—will prove invaluable for creating applications that respond intelligently to user presence and document state changes.

Key Takeaways

  • <svelte:document> provides declarative access to document-level events and properties with automatic SSR safety, handling events like visibilitychange, selectionchange, fullscreenchange, and properties like activeElement, visibilityState
  • Reactive bindings enable two-way data flow with readonly properties (activeElement, pointerLockElement, fullscreenElement, visibilityState, pictureInPictureElement) providing current document state
  • Attachments ({@attach}) are preferred over actions in Svelte 5.29+, offering full reactivity, composability, inline support, and better TypeScript integration compared to the legacy use: syntax
  • Visibility tracking optimizes performance and UX by pausing animations, deferring updates, and managing resource-intensive operations when users switch tabs or minimize windows
  • Selection change detection enables collaborative editing features like cursor position synchronization, text formatting toolbars, and real-time comment threading
  • Fullscreen and pointer lock APIs require user gestures, meaning programmatic requestFullscreen() or requestPointerLock() must be triggered by button clicks or other user interactions
  • Combining $derived with document bindings creates reactive patterns that automatically respond to state changes without manual effect management or event handler updates
  • SSR safety is built-in for <svelte:document> itself, but imperative document access in script blocks requires $effect wrapping or environment checks (typeof document !== 'undefined')

See Also