A Comprehensive Guide to Window Events and Reactive Bindings
The browser’s window object serves as the gateway between your application and the user’s viewport, input devices, and browsing session. In traditional JavaScript development, interacting with window requires careful lifecycle management—adding event listeners at the right moment, removing them to prevent memory leaks, and handling the peculiarities of server-side rendering where window simply doesn’t exist.
Svelte 5’s <svelte:window> special element elegantly abstracts these complexities, providing a declarative interface that feels natural within the component model while automatically handling cleanup and SSR safety.
This tutorial examines <svelte:window> from its foundational concepts through advanced architectural patterns. We’ll explore not just how to use this element, but when to reach for it versus alternatives like the svelte/reactivity/window module, and how to architect components that respond gracefully to window state changes without introducing performance bottlenecks or architectural anti-patterns.
Understanding the Problem Space
Why Special Window Handling Matters
Before diving into <svelte:window> itself, let’s understand the challenges it addresses. Consider what happens when you want to respond to keyboard shortcuts in a traditional JavaScript application:
// Traditional approach - fraught with potential issues
class KeyboardHandler {
constructor(callback) {
this.callback = callback
this.handleKeydown = this.handleKeydown.bind(this)
window.addEventListener('keydown', this.handleKeydown)
}
handleKeydown(event) {
if (event.key === 'Escape') {
this.callback('escape-pressed')
}
}
destroy() {
// Easy to forget this, leading to memory leaks
window.removeEventListener('keydown', this.handleKeydown)
}
} This pattern introduces several failure modes that developers frequently encounter. The destroy method must be called explicitly, and forgetting to do so creates memory leaks where event listeners persist even after the component conceptually no longer exists.
In server-side rendering contexts, this code throws errors because window is undefined. The binding pattern with this adds cognitive overhead, and managing multiple event types multiplies this complexity.
Svelte’s special elements transform this imperative complexity into declarative simplicity while maintaining the framework’s signature approach to automatic cleanup.
The Anatomy of <svelte:window>
Declarative Window Access
The <svelte:window> element exists purely at compile time—it produces no DOM output and cannot contain children. Its sole purpose is to provide a declarative surface for attaching event handlers and bindings to the window object:
<svelte:window onevent={handler} />
<svelte:window bind:property={value} /> The element must appear at the top level of your component, meaning it cannot be nested inside conditional blocks, loops, or other elements. This restriction exists because Svelte needs to set up and tear down these bindings during component initialization and destruction, respectively.
Let’s examine a foundational example that demonstrates the core mechanics:
<script>
let lastKeyPressed = $state('')
let keyPressCount = $state(0)
function handleKeydown(event) {
lastKeyPressed = event.key
keyPressCount += 1
}
</script>
<svelte:window onkeydown={handleKeydown} />
<div class="keyboard-monitor">
<p>Last key pressed: <code>{lastKeyPressed || 'None'}</code></p>
<p>Total key presses: {keyPressCount}</p>
</div> When Svelte compiles this component, it generates code that adds the event listener when the component mounts and removes it when the component unmounts. This happens automatically—there’s no manual cleanup required, no lifecycle method to remember, no opportunity for memory leaks from forgotten cleanup.
Event Handling Patterns
<svelte:window> supports all standard DOM events that the window object emits. This includes keyboard events (keydown, keyup), mouse events (scroll, resize), focus events (focus, blur), and many others.
Understanding Event Handler Signatures
Window event handlers receive the standard DOM event object, allowing you to access all native properties and methods. The Svelte 5 event naming convention uses the on prefix followed by the event name in lowercase:
<script>
let scrollY = $state(0)
let scrollDirection = $state('none')
let previousScrollY = 0
function handleScroll(event) {
// event.currentTarget is window in this context
const currentScrollY = window.scrollY
if (currentScrollY > previousScrollY) {
scrollDirection = 'down'
} else if (currentScrollY < previousScrollY) {
scrollDirection = 'up'
}
previousScrollY = currentScrollY
scrollY = currentScrollY
}
</script>
<svelte:window onscroll={handleScroll} />
<header class={{ scrolled: scrollY > 50, hide: scrollDirection === 'down' && scrollY > 100 }}>
<nav>Navigation content</nav>
</header>
<style>
header {
position: fixed;
top: 0;
transition: transform 0.3s ease;
}
header.scrolled {
background: rgba(255, 255, 255, 0.95);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
header.hide {
transform: translateY(-100%);
}
</style> This pattern implements a “smart header” that hides when scrolling down and reveals when scrolling up—a common UX pattern that requires tracking not just scroll position but scroll direction.
Multiple Event Handlers and Event Composition
You can attach multiple handlers to the same element by listing them separately or combining them. Svelte 5 allows multiple handlers for the same event type:
<script>
import { analyticsTracker } from './analytics.js';
let windowFocused = $state(true);
let lastActivity = $state(Date.now());
function handleFocus() {
windowFocused = true;
lastActivity = Date.now();
}
function handleBlur() {
windowFocused = false;
}
function handleActivity() {
lastActivity = Date.now();
}
function trackInteraction(event) {
analyticsTracker.recordInteraction({
type: event.type,
timestamp: Date.now(),
windowFocused
});
}
</script>
<svelte:window
onfocus={handleFocus}
onblur={handleBlur}
onmousemove={handleActivity}
onkeydown={handleActivity}
onclick={trackInteraction}
onkeydown={trackInteraction}
/>
{#if !windowFocused}
<div class="pause-overlay">
<p>Application paused</p>
<p>Click to resume</p>
</div>
{/if} Notice that onkeydown appears twice—once for activity tracking and once for analytics. Svelte 5 handles this by calling both handlers when the event fires. This composition pattern allows you to separate concerns without creating handler functions that do too many things.
Event Modifiers and Capture Phase Handling
Svelte’s event modifiers work with <svelte:window> just as they do with regular elements. The capture modifier is particularly useful for window events when you need to intercept events before they reach their targets:
<script>
let interceptedKeys = $state([])
function interceptKeydown(event) {
// Capture phase fires before the target receives the event
if (event.key === 'Tab' && event.shiftKey) {
// Implement custom focus management
event.preventDefault()
handleReverseFocusNavigation()
}
interceptedKeys = [...interceptedKeys.slice(-9), event.key]
}
function handleReverseFocusNavigation() {
// Custom focus logic here
}
</script>
<svelte:window onkeydowncapture={interceptKeydown} />
<div class="key-log">
<h3>Recent keys (captured):</h3>
<ul>
{#each interceptedKeys as key}
<li><kbd>{key}</kbd></li>
{/each}
</ul>
</div> The capture phase proves essential when building accessible components that need to manage focus before default browser behavior occurs. By using onkeydowncapture instead of onkeydown, the handler fires during the capture phase, giving you first access to the event.
Reactive Window Bindings:
Synchronizing Component State with Browser State
Beyond event handling, <svelte:window> provides bindings to specific window properties. These bindings fall into two categories: readonly bindings that reflect browser state, and two-way bindings that both read and can modify browser state.
Understanding the Bindable Properties
The following properties can be bound to <svelte:window>:
Readonly bindings (reflecting current window state):
innerWidth- The interior width of the window in pixelsinnerHeight- The interior height of the window in pixelsouterWidth- The width of the whole browser window including UI elementsouterHeight- The height of the whole browser window including UI elementsonline- An alias fornavigator.onLine, indicating network connectivitydevicePixelRatio- The ratio between physical and CSS pixels
Two-way bindings (can be read and modified):
scrollX- Horizontal scroll position (can be set to scroll programmatically)scrollY- Vertical scroll position (can be set to scroll programmatically)
The readonly nature of most bindings reflects a fundamental truth: your component cannot change the window’s dimensions or the user’s network status. You can only observe these values and respond accordingly.
Building Responsive Components with Dimension Bindings
Dimension bindings enable responsive behavior that goes beyond CSS media queries. Consider a scenario where you need to render different component structures based on viewport size:
<script>
let innerWidth = $state(0)
let innerHeight = $state(0)
// Derived breakpoint logic
let breakpoint = $derived(
innerWidth < 640
? 'mobile'
: innerWidth < 1024
? 'tablet'
: innerWidth < 1440
? 'desktop'
: 'wide'
)
let orientation = $derived(innerHeight > innerWidth ? 'portrait' : 'landscape')
// Calculate optimal grid columns based on actual width
let optimalColumns = $derived(Math.max(1, Math.floor(innerWidth / 300)))
</script>
<svelte:window bind:innerWidth bind:innerHeight />
<div class="dashboard" style:--columns={optimalColumns}>
{#if breakpoint === 'mobile'}
<MobileNavigation />
{:else}
<DesktopSidebar collapsed={breakpoint === 'tablet'} />
{/if}
<main class="content">
<header class="metrics">
<span>Viewport: {innerWidth}×{innerHeight}</span>
<span>Breakpoint: {breakpoint}</span>
<span>Orientation: {orientation}</span>
</header>
<div class="grid">
{#each items as item}
<GridItem {item} compact={breakpoint === 'mobile'} />
{/each}
</div>
</main>
</div>
<style>
.grid {
display: grid;
grid-template-columns: repeat(var(--columns), 1fr);
gap: 1rem;
}
</style> This pattern provides several advantages over pure CSS approaches. First, you can conditionally render entirely different component trees rather than just hiding elements. Second, you can pass breakpoint information to child components as props, enabling coordinated responsive behavior. Third, you can compute derived values like optimal column counts that depend on exact pixel dimensions.
Scroll Position Bindings: Implementing Complex Scroll Behaviors
The scrollX and scrollY bindings deserve special attention because they’re two-way—you can both read the current scroll position and set it to scroll programmatically:
<script>
let scrollY = $state(0)
let targetSection = $state(null)
// Section positions would typically be calculated dynamically
const sections = [
{ id: 'intro', name: 'Introduction', offset: 0 },
{ id: 'features', name: 'Features', offset: 800 },
{ id: 'pricing', name: 'Pricing', offset: 1600 },
{ id: 'contact', name: 'Contact', offset: 2400 }
]
// Determine active section based on scroll position
let activeSection = $derived(
sections.reduce((active, section) => {
if (scrollY >= section.offset - 100) {
return section
}
return active
}, sections[0])
)
function scrollToSection(section) {
// Setting scrollY programmatically scrolls the window
targetSection = section
scrollY = section.offset
}
// Smooth scroll effect using $effect
$effect(() => {
if (targetSection) {
// For smooth scrolling, you might use window.scrollTo instead
window.scrollTo({
top: targetSection.offset,
behavior: 'smooth'
})
targetSection = null
}
})
</script>
<svelte:window bind:scrollY />
<nav class="section-nav">
{#each sections as section}
<button
class={{ active: activeSection?.id === section.id }}
onclick={() => scrollToSection(section)}
>
{section.name}
</button>
{/each}
</nav>
<div class="scroll-indicator">
<div class="progress" style:height="{(scrollY / 2400) * 100}%"></div>
</div> The bidirectional nature of scroll bindings creates interesting possibilities but also potential pitfalls. Directly setting scrollY causes an immediate jump to that position—for smooth scrolling, you should use window.scrollTo with the behavior: 'smooth' option instead.
Network Status Monitoring with the online Binding
The online binding reflects navigator.onLine, providing a reactive way to respond to network connectivity changes:
<script>
let online = $state(true)
let offlineQueue = $state([])
let reconnectAttempts = $state(0)
// React to connectivity changes
$effect(() => {
if (online && offlineQueue.length > 0) {
processOfflineQueue()
}
})
async function processOfflineQueue() {
const queue = [...offlineQueue]
offlineQueue = []
for (const action of queue) {
try {
await action.execute()
} catch (error) {
// Re-queue failed actions
offlineQueue = [...offlineQueue, action]
}
}
}
function queueAction(action) {
if (online) {
action.execute()
} else {
offlineQueue = [...offlineQueue, action]
}
}
</script>
<svelte:window bind:online />
{#if !online}
<div class="offline-banner" role="alert">
<span class="icon">📡</span>
<span>You're offline. Changes will sync when connected.</span>
{#if offlineQueue.length > 0}
<span class="badge">{offlineQueue.length} pending</span>
{/if}
</div>
{/if} The online binding provides a convenient abstraction, but remember that navigator.onLine has limitations. It indicates whether the browser believes it has network access, not whether that network can actually reach your servers. For robust offline handling, combine this binding with actual request success/failure detection.
High-DPI Display Support with devicePixelRatio
The devicePixelRatio binding enables adaptive rendering for high-DPI displays:
<script>
let devicePixelRatio = $state(1)
let canvas
// Reactive canvas sizing based on DPR
$effect(() => {
if (!canvas) return
const rect = canvas.getBoundingClientRect()
const ctx = canvas.getContext('2d')
// Scale canvas for high-DPI displays
canvas.width = rect.width * devicePixelRatio
canvas.height = rect.height * devicePixelRatio
ctx.scale(devicePixelRatio, devicePixelRatio)
// Redraw content at appropriate resolution
drawVisualization(ctx, rect.width, rect.height)
})
function drawVisualization(ctx, width, height) {
// Drawing code here renders at native resolution
ctx.fillStyle = '#3b82f6'
ctx.fillRect(10, 10, width - 20, height - 20)
}
</script>
<svelte:window bind:devicePixelRatio />
<div class="canvas-container">
<canvas bind:this={canvas}></canvas>
<p class="dpr-info">Device Pixel Ratio: {devicePixelRatio}</p>
</div>
<style>
canvas {
width: 100%;
height: 300px;
}
</style> Browser Variations in `devicePixelRatio`Note that
devicePixelRatiobehavior varies across browsers. Chrome updates this value in response to zoom level changes, while Firefox and Safari typically only reflect the display’s native pixel density.
The svelte/reactivity/window Module
An Alternative Approach
Svelte 5.11.0 introduced svelte/reactivity/window, a module providing reactive versions of window values without requiring the <svelte:window> element. This alternative approach offers different trade-offs worth understanding.
Understanding the Reactive Window Values
The module exports objects with a reactive current property:
<script>
import {
innerWidth,
innerHeight,
outerWidth,
outerHeight,
scrollX,
scrollY,
online,
devicePixelRatio,
screenLeft,
screenTop
} from 'svelte/reactivity/window'
</script>
<div class="viewport-info">
<p>Window size: {innerWidth.current}×{innerHeight.current}</p>
<p>Outer size: {outerWidth.current}×{outerHeight.current}</p>
<p>Scroll position: ({scrollX.current}, {scrollY.current})</p>
<p>Screen position: ({screenLeft.current}, {screenTop.current})</p>
<p>Network status: {online.current ? 'Online' : 'Offline'}</p>
<p>Pixel ratio: {devicePixelRatio.current}</p>
</div> SSR Behavior of `svelte/reactivity/window`
screenLeftandscreenToptrack the browser window’s position on the screen and are updated inside arequestAnimationFramecallback. All values areundefinedduring SSR.
The .current property is reactive, meaning it automatically updates when the underlying window value changes. These reactive values work seamlessly in templates, derived values, and effects.
When to Use svelte/reactivity/window vs <svelte:window>
Choose svelte/reactivity/window when:
You only need to read values, not handle events. The module provides reactive values but no event handling capability.
You’re working in a
.svelte.jsor.svelte.tsfile. Since these files can’t contain template syntax, you can’t use<svelte:window>, but you can import from the module.You want to share reactive window state across multiple components. The module’s exports are singletons, making them ideal for shared state scenarios.
// lib/window-state.svelte.js
import { innerWidth, innerHeight } from 'svelte/reactivity/window'
export function useResponsive() {
let breakpoint = $derived(
innerWidth.current < 640 ? 'mobile' : innerWidth.current < 1024 ? 'tablet' : 'desktop'
)
let isCompact = $derived(innerWidth.current < 640 || innerHeight.current < 480)
return {
get breakpoint() {
return breakpoint
},
get isCompact() {
return isCompact
},
get width() {
return innerWidth.current
},
get height() {
return innerHeight.current
}
}
} Choose <svelte:window> when:
You need to handle window events. The module doesn’t support event handling—only
<svelte:window>provides that capability.You need two-way scroll bindings. While the module provides
scrollXandscrollY, these are readonly. Only<svelte:window>bindings allow programmatic scrolling through assignment.You prefer keeping window interactions explicit in your template. The element makes window dependencies visible in the component’s markup.
Combining Both Approaches
Sometimes the optimal solution combines both approaches:
<script>
import { innerWidth, innerHeight } from 'svelte/reactivity/window'
let lastScrollDirection = $state('none')
let previousScrollY = 0
// Use module for reactive values
let isMobile = $derived(innerWidth.current < 640)
// Use element for event handling
function handleScroll() {
const currentY = window.scrollY
lastScrollDirection = currentY > previousScrollY ? 'down' : 'up'
previousScrollY = currentY
}
</script>
<!-- Event handling requires the element -->
<svelte:window onscroll={handleScroll} />
<header class={{ mobile: isMobile, scrolled: lastScrollDirection === 'down' }}>
<!-- Header content -->
</header> Server-Side Rendering Considerations
One of <svelte:window>’s most valuable features is its SSR safety. The element produces no output during server rendering, and its bindings resolve to undefined on the server:
<script>
let innerWidth = $state(undefined)
let innerHeight = $state(undefined)
// These will be undefined during SSR
let isClient = $derived(innerWidth !== undefined)
</script>
<svelte:window bind:innerWidth bind:innerHeight />
{#if isClient}
<div class="client-only-ui">
Viewport: {innerWidth}×{innerHeight}
</div>
{:else}
<div class="placeholder">Loading viewport information...</div>
{/if} The svelte/reactivity/window module follows the same pattern—all .current values are undefined on the server:
<script>
import { innerWidth } from 'svelte/reactivity/window'
// innerWidth.current is undefined during SSR
let greeting = $derived(
innerWidth.current === undefined
? 'Welcome!'
: innerWidth.current < 640
? 'Hello, mobile user!'
: 'Hello, desktop user!'
)
</script>
<h1>{greeting}</h1> Handling Hydration Mismatches
A common pitfall occurs when server-rendered content doesn’t match the initial client render. Consider this problematic pattern:
<script>
import { innerWidth } from 'svelte/reactivity/window'
// PROBLEM: Server renders default, client renders actual
let columns = $derived(
innerWidth.current === undefined
? 3 // Server default
: Math.floor(innerWidth.current / 300)
)
</script>
<div class="grid" style:--columns={columns}>
<!-- Grid items -->
</div> If the server renders with 3 columns but the client’s viewport results in 4 columns, hydration will detect a mismatch. Solutions include:
- Accept the initial mismatch with a loading state:
<script>
import { innerWidth } from 'svelte/reactivity/window'
let mounted = $state(false)
$effect(() => {
mounted = true
})
let columns = $derived(!mounted ? 3 : Math.floor((innerWidth.current ?? 900) / 300))
</script> - Use CSS for initial layout, JavaScript for enhancement:
<script>
import { innerWidth } from 'svelte/reactivity/window'
let calculatedColumns = $derived(innerWidth.current ? Math.floor(innerWidth.current / 300) : null)
</script>
<div class="grid" style:--columns={calculatedColumns}>
<!-- Grid items -->
</div>
<style>
.grid {
display: grid;
/* CSS default, overridden by JS when available */
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid[style*='--columns'] {
grid-template-columns: repeat(var(--columns), 1fr);
}
</style> Advanced Patterns and Architectural Considerations
Building a Window State Manager
For complex applications, centralizing window state management provides consistency and reduces redundant listeners:
// lib/window-manager.svelte.js
import { innerWidth, innerHeight, online } from 'svelte/reactivity/window'
class WindowManager {
// Breakpoint definitions
breakpoints = {
mobile: 640,
tablet: 1024,
desktop: 1440
}
get width() {
return innerWidth.current
}
get height() {
return innerHeight.current
}
get isOnline() {
return online.current
}
get currentBreakpoint() {
const width = this.width
if (width === undefined) return 'unknown'
if (width < this.breakpoints.mobile) return 'mobile'
if (width < this.breakpoints.tablet) return 'tablet'
if (width < this.breakpoints.desktop) return 'desktop'
return 'wide'
}
get isMobile() {
return this.currentBreakpoint === 'mobile'
}
get isTablet() {
return this.currentBreakpoint === 'tablet'
}
get isDesktop() {
return ['desktop', 'wide'].includes(this.currentBreakpoint)
}
get orientation() {
if (this.width === undefined || this.height === undefined) {
return 'unknown'
}
return this.height > this.width ? 'portrait' : 'landscape'
}
get aspectRatio() {
if (this.width === undefined || this.height === undefined) {
return undefined
}
return this.width / this.height
}
}
export const windowManager = new WindowManager() Usage in components becomes remarkably clean:
<script>
import { windowManager } from '$lib/window-manager.svelte.js'
</script>
{#if windowManager.isMobile}
<MobileLayout />
{:else if windowManager.isTablet}
<TabletLayout />
{:else}
<DesktopLayout />
{/if}
<footer>
{windowManager.currentBreakpoint} | {windowManager.orientation}
</footer> Implementing Keyboard Shortcut Systems
Window-level keyboard handling enables application-wide shortcuts. A well-architected system prevents conflicts and supports discoverability:
<script>
import { getContext, setContext } from 'svelte'
// Shortcut registry
let shortcuts = $state(new Map())
function registerShortcut(key, modifiers, handler, description) {
const id = `${modifiers.sort().join('+')}+${key}`
shortcuts.set(id, { key, modifiers, handler, description })
return () => shortcuts.delete(id)
}
function handleKeydown(event) {
const modifiers = []
if (event.ctrlKey || event.metaKey) modifiers.push('ctrl')
if (event.shiftKey) modifiers.push('shift')
if (event.altKey) modifiers.push('alt')
const id = `${modifiers.sort().join('+')}+${event.key.toLowerCase()}`
const shortcut = shortcuts.get(id)
if (shortcut) {
event.preventDefault()
shortcut.handler(event)
}
}
// Provide context for child components
setContext('shortcuts', { register: registerShortcut, shortcuts })
// Built-in shortcuts
registerShortcut('k', ['ctrl'], () => openCommandPalette(), 'Open command palette')
registerShortcut('/', [], () => focusSearch(), 'Focus search')
registerShortcut('escape', [], () => closeModals(), 'Close modal')
</script>
<svelte:window onkeydown={handleKeydown} />
<slot />
<!-- Optional: Shortcut help modal -->
{#if showShortcutHelp}
<div class="shortcut-help">
<h2>Keyboard Shortcuts</h2>
<dl>
{#each [...shortcuts.values()] as shortcut}
<dt>
{#each shortcut.modifiers as mod}
<kbd>{mod}</kbd> +
{/each}
<kbd>{shortcut.key}</kbd>
</dt>
<dd>{shortcut.description}</dd>
{/each}
</dl>
</div>
{/if} Child components can then register their own shortcuts:
<script>
import { getContext, onDestroy } from 'svelte'
const { register } = getContext('shortcuts')
// Register component-specific shortcuts
const unregisterSave = register('s', ['ctrl'], handleSave, 'Save document')
const unregisterUndo = register('z', ['ctrl'], handleUndo, 'Undo')
onDestroy(() => {
unregisterSave()
unregisterUndo()
})
</script> Debouncing and Throttling Window Events
Some window events fire with high frequency, particularly scroll and resize. Without rate limiting, handlers can cause performance issues:
<script>
let scrollY = $state(0)
let innerWidth = $state(0)
// Throttle scroll handler (fires at most once per frame)
let scrollTicking = false
function handleScroll() {
if (!scrollTicking) {
requestAnimationFrame(() => {
scrollY = window.scrollY
scrollTicking = false
})
scrollTicking = true
}
}
// Debounce resize handler (fires after resizing stops)
let resizeTimeout
function handleResize() {
clearTimeout(resizeTimeout)
resizeTimeout = setTimeout(() => {
innerWidth = window.innerWidth
recalculateLayout()
}, 150)
}
function recalculateLayout() {
// Expensive layout calculations here
}
</script>
<svelte:window onscroll={handleScroll} onresize={handleResize} /> For the svelte:window bindings (as opposed to event handlers), Svelte already handles updates efficiently. The bindings use the browser’s native event system and update reactively—you don’t need to manually debounce them.
Coordinating Multiple Window-Aware Components
When multiple components need window state, avoid duplicating <svelte:window> elements. Instead, lift the window interaction to a shared parent or use the svelte/reactivity/window module:
<!-- Layout.svelte - Single source of window state -->
<script>
let { children } = $props()
let innerWidth = $state(0)
let innerHeight = $state(0)
let scrollY = $state(0)
// Provide via context
import { setContext } from 'svelte'
setContext('viewport', {
get width() {
return innerWidth
},
get height() {
return innerHeight
},
get scrollY() {
return scrollY
}
})
</script>
<svelte:window bind:innerWidth bind:innerHeight bind:scrollY />
{@render children()} <!-- ChildComponent.svelte - Consumes via context -->
<script>
import { getContext } from 'svelte'
const viewport = getContext('viewport')
// Use viewport.width, viewport.height, viewport.scrollY
</script> Comparison with Related Special Elements
Svelte provides a family of special elements for different DOM targets. Understanding their distinctions helps you choose the right tool:
<svelte:window> vs <svelte:document>
<svelte:document> binds to the document rather than the window:
<svelte:document
onvisibilitychange={handleVisibilityChange}
bind:activeElement
bind:fullscreenElement
bind:visibilityState
/> The document provides different properties—activeElement for focus tracking, visibilityState for tab visibility, and fullscreenElement for fullscreen detection. Some events like visibilitychange fire on document rather than window.
<svelte:window> vs <svelte:body>
<svelte:body> attaches to document.body:
<svelte:body onmouseenter={handleMouseEnter} onmouseleave={handleMouseLeave} /> Events like mouseenter and mouseleave don’t bubble to window, making <svelte:body> necessary for detecting when the mouse enters or leaves the viewport area.
Common Pitfalls and How to Avoid Them
1. Accessing Window in the Wrong Lifecycle Phase
Problem: Accessing the global window object directly in the top-level script scope or during component initialization.
Consequences: The window object is a browser-specific API. SvelteKit applications typically run on the server first (SSR) where window does not exist. Accessing it directly causes the application to crash with “window is not defined” errors during the server render.
How to Avoid: Use <svelte:window> bindings which are automatically SSR-safe, or wrap manual window access inside $effect (which only runs in the browser).
<script>
// WRONG: window doesn't exist during SSR
let currentWidth = window.innerWidth
// CORRECT: use binding or check for existence
let innerWidth = $state(0)
// Or check manually
$effect(() => {
if (typeof window !== 'undefined') {
// Safe to access window
}
})
</script>
<svelte:window bind:innerWidth /> 2. Creating Multiple Listeners for the Same Event
Problem: Placing <svelte:window> inside a component that is instantiated multiple times, such as within an {#each} block.
Consequences: Every instance of the component attaches its own event listener to the global window object. If you render a list of 100 items, you inadvertently create 100 separate scroll listeners. This wastes memory and degrades performance as the browser must execute 100 functions for every single event.
How to Avoid: Lift the window interaction to a single parent component (like a layout or list container) and pass the relevant data down to children via props or context.
<!-- WRONG: Each instance creates redundant listeners -->
{#each items as item}
<ItemComponent />
{/each}
<!-- Where ItemComponent has: -->
<svelte:window onscroll={handleScroll} /> 3. Expensive Operations in High-Frequency Handlers
Problem: Performing heavy calculations, DOM queries (document.querySelector), or layout-triggering reads (offsetWidth) inside handlers for high-frequency events like scroll, resize, or mousemove.
Consequences: These events can fire dozens of times per second (up to the screen refresh rate). Heavy operations block the main thread, causing “jank” (dropped frames), unresponsive UI, and high CPU usage.
How to Avoid: Use throttling to limit execution frequency, or requestAnimationFrame to synchronize updates with the browser’s render cycle.
<script>
// WRONG: DOM measurement on every scroll event
function handleScroll() {
const elements = document.querySelectorAll('.item')
elements.forEach((el) => {
// Expensive operations
})
}
// CORRECT: Throttle and cache
let items = []
let ticking = false
function handleScroll() {
if (!ticking) {
requestAnimationFrame(() => {
// Process cached items
ticking = false
})
ticking = true
}
}
</script>
<svelte:window onscroll={handleScroll} /> 4. Forgetting SSR Implications in Derived Values
Problem: Creating derived state that performs calculations on window bindings without handling the initial undefined state during SSR.
Consequences: During Server-Side Rendering, window bindings like innerWidth are undefined. Mathematical operations on undefined (like undefined / 200) result in NaN or throw errors. This can cause the server render to fail or produce invalid HTML with NaN values.
How to Avoid: Always provide a fallback value or explicitly check for undefined in your derived logic to ensure a valid default state for the server-rendered HTML.
<script>
let innerWidth = $state(undefined)
// WRONG: Fails when innerWidth is undefined
let columnCount = $derived(Math.floor(innerWidth / 200))
// CORRECT: Handle undefined case
let columnCount = $derived(innerWidth !== undefined ? Math.floor(innerWidth / 200) : 3)
</script>
<svelte:window bind:innerWidth /> Performance Best Practices
1. Minimize Reactive Bindings
Every property you bind to <svelte:window> creates an internal event listener and a reactive state synchronization. Binding to properties you don’t use adds unnecessary overhead.
Best Practice: Only bind the specific properties your logic requires.
<!-- WRONG: Over-binding creates unnecessary listeners and updates -->
<svelte:window
bind:innerWidth
bind:innerHeight
bind:scrollX
bind:scrollY
/>
<!-- CORRECT: Bind only what is needed -->
<svelte:window bind:scrollY /> 2. Prefer CSS for Visual Responsiveness
JavaScript-based responsiveness (using innerWidth) forces the browser to execute code, recalculate styles, and repaint on every resize event. This is significantly slower than native CSS media queries, which are optimized by the browser engine.
Best Practice: Use CSS media queries for layout changes. Reserve <svelte:window> bindings for logic that cannot be expressed in CSS (like conditional rendering of heavy components or canvas sizing).
<style>
/* FAST: Handled by the browser's layout engine */
.sidebar {
width: 100%;
}
@media (min-width: 768px) {
.sidebar {
width: 300px;
}
}
</style> 3. Use Derived State for Window Calculations
Avoid using $effect to manually update state based on window changes. This triggers a second render pass. Instead, use $derived to compute values derived from window bindings. Svelte’s fine-grained reactivity ensures these are only recalculated when the specific dependencies change.
Best Practice: Compute window-dependent values using $derived.
<script>
let innerWidth = $state(0)
let innerHeight = $state(0)
// EFFICIENT: Calculates lazily and only when dependencies change
let viewport = $derived({
width: innerWidth,
height: innerHeight,
aspectRatio: innerWidth / innerHeight,
isLandscape: innerWidth > innerHeight,
breakpoint: innerWidth < 640 ? 'mobile' : 'desktop'
})
</script>
<svelte:window bind:innerWidth bind:innerHeight /> This approach is more efficient than using $effect because it leverages Svelte’s reactivity system to compute values only when dependencies change, without triggering additional render cycles.
Conclusion
The <svelte:window> special element exemplifies Svelte’s philosophy of making common tasks trivially easy while keeping advanced use cases possible. By abstracting away the complexity of event listener lifecycle management and SSR safety, it lets you focus on what matters: building responsive, interactive applications that respond gracefully to the user’s environment.
The introduction of svelte/reactivity/window in Svelte 5.11 provides a complementary approach for scenarios where you need reactive window values without the template-based binding syntax.
As you build window-aware components, remember these architectural principles: centralize window state when multiple components need it, choose the appropriate rate-limiting strategy for high-frequency events, design for SSR from the start, and prefer CSS for purely visual responsiveness.
The patterns explored here—centralized window managers, keyboard shortcut systems, and coordinated state management—demonstrate that window interaction is not just about technical mechanics but about thoughtful architecture.
Together, these tools and patterns enable building applications that are not just functional but optimized for the diverse environments where users experience them. The browser window is your application’s frame—<svelte:window> gives you the tools to make that frame an active participant in your user experience.
Key Takeaways
<svelte:window>provides declarative, SSR-safe window event handling with automatic cleanup, eliminating the need for manual listener management and preventing memory leaks- Event handlers support standard DOM events (
onkeydown,onscroll,onresize) with optional capture phase handling using thecapturesuffix (e.g.,onkeydowncapture) - Reactive bindings enable two-way data flow with readonly properties (
innerWidth,innerHeight,outerWidth,outerHeight,online,devicePixelRatio) and writable scroll properties (scrollX,scrollY) - The
svelte/reactivity/windowmodule offers an alternative for reading window values in.svelte.jsfiles or sharing state across components, but lacks event handling and programmatic scrolling capabilities - SSR safety is built-in - all bindings resolve to
undefinedon the server, requiring fallback values in derived logic to avoid hydration mismatches - Performance optimization requires throttling high-frequency events (
scroll,resize,mousemove) usingrequestAnimationFrameor debouncing, and preferring CSS media queries over JavaScript for visual responsiveness - Architectural patterns include centralized state management through context or reactive modules, hierarchical window managers for breakpoint logic, and keyboard shortcut registries with priority handling
- Common pitfalls involve SSR access, creating redundant listeners in loops, expensive operations in event handlers, and forgetting to handle
undefinedin derived calculations
See Also
- Official Svelte 5 Documentation -
<svelte:window> - MDN Web Docs - Window Interface
<svelte:document>- Similar special element for document-level events<svelte:body>- Body-level event handling for different event bubbling scenarios- Svelte 5 Runes -
$derived- Reactive computations based on window state - Server-Side Rendering (SSR) in SvelteKit - Understanding SSR constraints with browser APIs
- Responsive Design with CSS Media Queries - When to prefer CSS over JavaScript responsiveness