The Evolution of DOM Interaction in Svelte

Svelte has always championed the philosophy of writing less code while achieving more. From its inception, the framework provided the use: directive (actions) as the primary mechanism for imperatively interacting with DOM elements—attaching third-party libraries, managing focus, implementing drag-and-drop, and countless other scenarios where declarative templates fall short.

However, as Svelte evolved toward its reactive runes-based architecture in version 5, a fundamental tension emerged. Actions, while powerful, operated somewhat outside Svelte’s reactive paradigm. They received updates through an explicit update callback, creating a mental model disconnect with the rest of the framework’s push-pull reactivity system.


Attachments

Svelte 5.29 introduces attachments—a reimagined approach to imperative DOM interaction that integrates seamlessly with Svelte’s effect system. Attachments run within effects, automatically tracking reactive dependencies and re-running when those dependencies change.

This seemingly simple change has profound implications for how we architect component libraries, manage complex DOM interactions, and reason about reactivity across component boundaries.

This tutorial assumes familiarity with Svelte 5’s runes ($state, $derived, $effect, $props) and a solid understanding of JavaScript fundamentals including Proxies, Symbols, and the event loop. We will progressively build from fundamental concepts to advanced architectural patterns, examining not just how to use attachments, but why they represent a significant advancement in Svelte’s component model.

1. The Conceptual Level

1.1 What Is an Attachment?

At its core, an attachment is a function that receives a DOM element when that element is mounted to the DOM. The function executes within an effect context, meaning any reactive state read during execution becomes a dependency—when that state changes, the attachment automatically re-runs.

interface Attachment<T extends EventTarget = Element> {
	(element: T): void | (() => void)
}

This type signature reveals the essential contract:

  1. The attachment receives an element
  2. It optionally returns a cleanup function
  3. The cleanup runs before re-execution or when the element unmounts

1.2 The Effect-Based Execution Model

**Understanding that attachments execute within effects is crucial** for mastering their behavior. Consider the implications:

<script>
	let color = $state('#ff3e00')
	let size = $state(100)

	function paintCanvas(canvas) {
		// This runs in an effect context
		const ctx = canvas.getContext('2d')

		// Both `color` and `size` are read synchronously
		// They become dependencies of this attachment
		ctx.fillStyle = color
		ctx.fillRect(0, 0, size, size)

		return () => {
			// Cleanup: runs before re-execution or unmount
			ctx.clearRect(0, 0, canvas.width, canvas.height)
		}
	}
</script>

<canvas {@attach paintCanvas} width={200} height={200}></canvas>

When either color or size changes, Svelte’s reactive system:

  1. Marks the attachment as dirty
  2. Invokes the cleanup function (if provided)
  3. Re-executes the attachment function
  4. Tracks any new dependencies read during execution

This mirrors exactly how $effect works elsewhere in Svelte 5, creating a unified mental model for reactive side effects.

1.3 Attachments vs. Actions: A Philosophical Shift

In Svelte’s evolution, the use: directive (actions) represented the original approach to imperative DOM interactions. With the introduction of runes in Svelte 5, attachments ({@attach ...}) offer a more integrated way to handle such interactions. This section explores the philosophical shift from actions to attachments, detailing what each is, when to use them, and how to apply them effectively.

What Were Actions?

Actions were Svelte’s initial mechanism for attaching imperative behavior to DOM elements. They followed an imperative update pattern where developers manually managed state changes through a separate update callback:

<!-- Traditional action pattern -->
<script>
	function myAction(node, params) {
		// Initial setup
		return {
			update(newParams) {
				// Called when params change
			},
			destroy() {
				// Cleanup
			}
		}
	}
</script>

<div use:myAction={someValue}>...</div>

This pattern required developers to:

  1. Manually handle updates in a separate callback
  2. Track which parameters changed
  3. Implement differential updates to avoid redundant work

Actions operated somewhat outside Svelte’s reactive paradigm, creating a disconnect with the framework’s push-pull reactivity system.

What Are Attachments?

Attachments are the modern, runes-integrated approach to DOM interactions. They run within Svelte’s effect system, automatically tracking reactive dependencies and re-running when those dependencies change:

<!-- Attachment pattern -->
<script>
	function myAttachment(node) {
		// Reads `someValue` reactively
		doSomethingWith(someValue)

		return () => {
			// Automatic cleanup before re-run
		}
	}
</script>

<div {@attach myAttachment}>...</div>

The attachment approach is declarative about what happens (the function body) while Svelte handles when it happens (dependency tracking and scheduling).

When to Use Actions vs. Attachments

While @attach is the preferred approach for new Svelte 5 code, use: actions are still supported and can be useful for:

  • Legacy codebases migrating gradually.
  • Actions that need to work across different Svelte versions or even other frameworks (though rare).
  • Complex scenarios where manual control over updates is beneficial.

For new imperative DOM interactions in Svelte 5, prefer @attach to align with the framework’s reactive philosophy and reduce cognitive load.

How to Use Attachments

Attachments are functions that receive a DOM element and optionally return a cleanup function. They integrate seamlessly with Svelte’s reactivity:

Basic usage: {@attach myFunction} attaches a simple function.

<script>
	function logMount(el) {
		console.log('Element mounted')
	}
</script>

<div {@attach logMount}>Basic example</div>

Parameterized: {@attach factory(param)} uses a factory for dynamic behavior.

<script>
	function tooltip(text) {
		return (el) => {
			// Setup tooltip with text
			return () => {
				/* cleanup */
			}
		}
	}
</script>

<button {@attach tooltip('Hover me')}>Button</button>

Inline: {@attach (el) => { /* setup */ return () => { /* cleanup */ } }} for one-off logic.

<div
	{@attach (el) => {
		el.style.color = 'red'
		return () => (el.style.color = '')
	}}
>
	Inline example
</div>

Multiple: Apply several attachments to the same element for combined behaviors.

<script>
	function attach1(el) {
		/* ... */
	}
	function attach2(el) {
		/* ... */
	}
</script>

<div {@attach attach1} {@attach attach2}>Multiple attachments</div>

Components: Attachments pass through components that spread {...props} (rest props).

<!-- MyComponent.svelte -->
<script>
	let { ...props } = $props()
</script>

<!-- Usage -->
<MyComponent {@attach someAttach} />
<div {...props}>Component content</div>

Passing Options to Attachments

While attachments themselves only receive the element, use factories to inject options or parameters:

<script>
	function attachWithOptions(options) {
		return (element) => {
			// Use options here
			element.style.color = options.color
			element.textContent = options.text
			return () => {
				element.style.color = ''
				element.textContent = ''
			}
		}
	}
</script>

<div {@attach attachWithOptions({ color: 'red', text: 'Hello' })}>Styled element</div>

This pattern allows attachments to be parameterized without changing the core (element) signature.

How to Migrate from Actions

For existing actions, use fromAction to convert them into attachments:

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

	let actionParam = $state('value')
</script>

<!-- Original action -->
<div use:someLibraryAction={actionParam}>...</div>

<!-- Equivalent attachment -->
<div {@attach fromAction(someLibraryAction, () => actionParam)}>...</div>

For custom actions, rewrite them as native attachments by removing the update callback and leveraging reactive reads directly in the function body.

The Philosophical Shift in Detail

This shift represents a move from imperative programming to declarative reactivity. In the action model, developers had to anticipate and handle parameter changes manually, often leading to complex state management and potential bugs from missed updates. Attachments, by contrast, embrace Svelte’s reactive paradigm: you describe the desired behavior, and the framework ensures it stays synchronized with the data.

Benefits of Attachments Over Actions

  • Reduced Boilerplate: No need for separate update and destroy methods; everything is in one function.
  • Automatic Dependency Tracking: Svelte automatically knows when to re-run the attachment based on reactive reads.
  • Unified Effect System: Attachments behave like $effect, making the codebase more consistent.
  • Easier Testing: Since attachments are just functions, they can be unit-tested in isolation.
  • Better Performance: Less overhead from manual update checks; Svelte’s scheduler optimizes re-runs.

2. Fundamental Patterns and Syntax

2.1 Basic Attachment Declaration

The simplest attachment is a function that performs setup when an element mounts:

<script>
	/** @type {import('svelte/attachments').Attachment} */
	function logMount(element) {
		console.log(`Mounted: ${element.tagName}`)
	}
</script>

<div {@attach logMount}>This div logs when it mounts</div>

TypeScript users benefit from the Attachment type exported from svelte/attachments, which provides proper typing for the element parameter and return type.

2.2 Cleanup Functions: The Return Value Contract

Returning a cleanup function is optional but essential for:

  • Removing event listeners
  • Canceling timers or animations
  • Disposing of third-party library instances
  • Releasing resources
<script>
	function trackMouse(element) {
		function handleMove(event) {
			element.style.setProperty('--mouse-x', `${event.clientX}px`)
			element.style.setProperty('--mouse-y', `${event.clientY}px`)
		}

		document.addEventListener('mousemove', handleMove)

		return () => {
			document.removeEventListener('mousemove', handleMove)
		}
	}
</script>

<div {@attach trackMouse} class="mouse-tracker">Tracks mouse position</div>

<style>
	.mouse-tracker::before {
		content: '';
		position: fixed;
		left: var(--mouse-x, 0);
		top: var(--mouse-y, 0);
		/* ... */
	}
</style>

2.3 Multiple Attachments on a Single Element

Elements can receive any number of attachments, each operating independently:

<script>
	function trackVisibility(el) {
		const observer = new IntersectionObserver((entries) => {
			entries.forEach((entry) => {
				console.log(`Visible: ${entry.isIntersecting}`)
			})
		})
		observer.observe(el)
		return () => observer.disconnect()
	}

	function trackResize(el) {
		const observer = new ResizeObserver((entries) => {
			entries.forEach((entry) => {
				console.log(`Size: ${entry.contentRect.width}x${entry.contentRect.height}`)
			})
		})
		observer.observe(el)
		return () => observer.disconnect()
	}

	function applyStyles(el) {
		el.style.border = '2px solid blue'
	}
</script>

<div {@attach trackVisibility} {@attach trackResize} {@attach applyStyles}>
	This element has three independent attachments
</div>

Each attachment:

  • Has its own effect context
  • Tracks its own dependencies
  • Manages its own cleanup lifecycle

3. Attachment Factories—Parameterized DOM Interaction

3.1 The Factory Pattern Explained

Raw attachments work well for static behavior, but real applications require parameterization. An attachment factory is a function that returns an attachment, allowing you to pass configuration:

<script>
	/**
	 * Creates a tooltip attachment
	 * @param {string} content - Tooltip text
	 * @returns {import('svelte/attachments').Attachment}
	 */
	function tooltip(content) {
		return (element) => {
			// Setup tooltip with `content`
			const tip = createTooltip(element, content)
			return () => tip.destroy()
		}
	}

	let message = $state('Hello, world!')
</script>

<button {@attach tooltip(message)}> Hover for tooltip </button>

3.2 Reactivity in Factories: Understanding Re-execution

When using factory patterns with reactive values, the entire expression re-evaluates when dependencies change:

<script>
	import { computePosition, autoUpdate, offset, shift } from '@floating-ui/dom'

	let content = $state('Initial tooltip')

	function tooltip(content) {
		return (element) => {
			const tooltipEl = document.createElement('div')
			tooltipEl.textContent = content
			tooltipEl.style.position = 'absolute'
			tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)'
			tooltipEl.style.color = 'white'
			tooltipEl.style.padding = '4px 8px'
			tooltipEl.style.borderRadius = '4px'
			tooltipEl.style.fontSize = '14px'
			tooltipEl.style.pointerEvents = 'none'
			tooltipEl.style.display = 'none'
			document.body.appendChild(tooltipEl)

			const updatePosition = () => {
				computePosition(element, tooltipEl, {
					placement: 'top',
					middleware: [offset(6), shift()]
				}).then(({ x, y }) => {
					Object.assign(tooltipEl.style, {
						left: `${x}px`,
						top: `${y}px`
					})
				})
			}

			const cleanup = autoUpdate(element, tooltipEl, updatePosition)

			const show = () => {
				tooltipEl.style.display = 'block'
				updatePosition()
			}

			const hide = () => {
				tooltipEl.style.display = 'none'
			}

			element.addEventListener('mouseenter', show)
			element.addEventListener('mouseleave', hide)

			return () => {
				cleanup()
				element.removeEventListener('mouseenter', show)
				element.removeEventListener('mouseleave', hide)
				document.body.removeChild(tooltipEl)
			}
		}
	}
</script>

<input bind:value={content} />

<!-- When `content` changes:
     1. tooltip(content) is called with new value
     2. Previous attachment's cleanup runs
     3. New attachment runs (new tooltip element created)
-->
<button {@attach tooltip(content)}> Dynamic tooltip </button>

This automatic re-creation is the default behavior. For many use cases—especially lightweight operations—this is perfectly acceptable. However, for expensive setup operations, we need finer control.

3.3 Real-World Factory Example: Floating UI Integration

Let’s build a production-ready tooltip factory using Floating UI for positioning:

<script>
	import { computePosition, autoUpdate, offset, shift } from '@floating-ui/dom'

	/**
	 * Creates a fully-featured tooltip attachment
	 *
	 * @param {string} content - Tooltip content
	 * @param {Object} [options={}] - Tooltip options
	 * @param {string} [options.placement='top'] - Placement of the tooltip
	 * @returns {import('svelte/attachments').Attachment<HTMLElement>}
	 */
	function tooltip(content, options = {}) {
		return (element) => {
			const tooltipEl = document.createElement('div')
			tooltipEl.textContent = content
			tooltipEl.style.position = 'absolute'
			tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)'
			tooltipEl.style.color = 'white'
			tooltipEl.style.padding = '8px 12px'
			tooltipEl.style.borderRadius = '6px'
			tooltipEl.style.fontSize = '14px'
			tooltipEl.style.pointerEvents = 'none'
			tooltipEl.style.zIndex = '9999'
			tooltipEl.style.display = 'none'
			document.body.appendChild(tooltipEl)

			const updatePosition = () => {
				computePosition(element, tooltipEl, {
					placement: options.placement || 'top',
					middleware: [offset(8), shift({ padding: 5 })]
				}).then(({ x, y }) => {
					Object.assign(tooltipEl.style, {
						left: `${x}px`,
						top: `${y}px`
					})
				})
			}

			const cleanup = autoUpdate(element, tooltipEl, updatePosition)

			const show = () => {
				tooltipEl.style.display = 'block'
				updatePosition()
			}

			const hide = () => {
				tooltipEl.style.display = 'none'
			}

			element.addEventListener('mouseenter', show)
			element.addEventListener('mouseleave', hide)

			return () => {
				cleanup()
				element.removeEventListener('mouseenter', show)
				element.removeEventListener('mouseleave', hide)
				document.body.removeChild(tooltipEl)
			}
		}
	}

	let tooltipText = $state('Hover over me!')
	let placement = $state('top')
</script>

<div class="demo">
	<input type="text" bind:value={tooltipText} placeholder="Edit tooltip text" />

	<select bind:value={placement}>
		<option value="top">Top</option>
		<option value="bottom">Bottom</option>
		<option value="left">Left</option>
		<option value="right">Right</option>
	</select>

	<button {@attach tooltip(tooltipText, { placement })}> Hover Me </button>
</div>

4. Inline Attachments—When Brevity Wins

4.1 Inline Syntax for Simple Cases

For simple, one-off attachments, inline arrow functions eliminate boilerplate:

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

<canvas
	width={200}
	height={200}
	{@attach (canvas) => {
		const ctx = canvas.getContext('2d')
		ctx.fillStyle = color
		ctx.fillRect(0, 0, 200, 200)
	}}
></canvas>

<input type="color" bind:value={color} />

Advantages of Inline Attachments

  • No separate function definition: Keeps logic close to the template.
  • Immediate context: Access to local variables without passing parameters.
  • Quick prototyping: Ideal for simple, non-reusable behaviors.

When to Use Named Attachments Instead

  • Reusability: If the logic is used in multiple places.
  • Complexity: For attachments with significant setup or cleanup.
  • Testing: Named functions are easier to unit-test.
  • Readability: For longer logic, named functions improve code organization.

Inline attachments shine for lightweight, component-specific interactions, while named attachments are better for shared or complex behaviors.

4.2 Nested Effects Within Inline Attachments

Inline attachments can leverage nested $effect calls for fine-grained reactivity control:

<script>
	let backgroundColor = $state('#ffffff')
	let foregroundColor = $state('#000000')
	let text = $state('Hello')
</script>

<canvas
	width={400}
	height={100}
	{@attach (canvas) => {
		const ctx = canvas.getContext('2d')

		// Outer effect runs once (no reactive reads yet)
		// This is where we'd do expensive setup
		ctx.font = '24px sans-serif'
		ctx.textAlign = 'center'
		ctx.textBaseline = 'middle'

		// Nested effect for background (runs when backgroundColor changes)
		$effect(() => {
			ctx.fillStyle = backgroundColor
			ctx.fillRect(0, 0, canvas.width, canvas.height)
		})

		// Another nested effect for text (runs when text or foregroundColor changes)
		$effect(() => {
			// Must redraw background first since we clear nothing
			ctx.fillStyle = backgroundColor
			ctx.fillRect(0, 0, canvas.width, canvas.height)

			ctx.fillStyle = foregroundColor
			ctx.fillText(text, canvas.width / 2, canvas.height / 2)
		})
	}}
></canvas>

This pattern demonstrates a crucial optimization technique: the outer attachment runs once for expensive setup, while nested effects handle granular reactive updates.


5. Passing Attachments to Components

5.1 The Symbol-Based Prop Mechanism

One of attachments’ most powerful features is their ability to pass through component boundaries via prop spreading. When you use {@attach ...} on a component, Svelte creates a prop with a unique Symbol key:

<!-- Parent.svelte -->
<script>
	import Button from './Button.svelte'

	function focusOnMount(element) {
		element.focus()
	}
</script>

<Button {@attach focusOnMount}>I'll be focused on mount</Button>
<!-- Button.svelte -->
<script>
	/** @type {import('svelte/elements').HTMLButtonAttributes} */
	let { children, ...props } = $props()
</script>

<!-- Spreading `props` includes the attachment Symbol -->
<button {...props}>
	{@render children?.()}
</button>

5.2 How This Works Under the Hood

When {@attach fn} is used on a component:

  1. Svelte generates a unique Symbol
  2. The attachment function is assigned to that Symbol key in the props object
  3. When props are spread onto an element, Svelte recognizes the Symbol and attaches the function

This mechanism means wrapper components “just work” without explicit attachment forwarding:

<!-- IconButton.svelte -->
<script>
	import Icon from './Icon.svelte'

	let { icon, children, ...props } = $props()
</script>

<button {...props}>
	<Icon name={icon} />
	{@render children?.()}
</button>
<!-- Usage -->
<script>
	import IconButton from './IconButton.svelte'

	function trackClick(element) {
		element.addEventListener('click', () => {
			analytics.track('button_clicked')
		})
		return () => {
			// cleanup...
		}
	}
</script>

<!-- The attachment reaches the inner <button> element -->
<IconButton icon="save" {@attach trackClick}>Save Document</IconButton>

5.3 Building Wrapper Components That Preserve Attachments

A critical pattern for component library authors is ensuring attachments flow through wrapper components:

<!-- Card.svelte -->
<script>
	/**
	 * @type {{
	 *   variant?: 'default' | 'elevated' | 'outlined',
	 *   children: import('svelte').Snippet,
	 *   [key: string]: any
	 * }}
	 */
	let { variant = 'default', children, ...props } = $props()
</script>

<article class="card card--{variant}" {...props}>
	{@render children()}
</article>

<style>
	.card {
		/* ... */
	}
	.card--elevated {
		box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
	}
	.card--outlined {
		border: 1px solid currentColor;
	}
</style>

By using rest props (...props) and spreading them onto the root element, any attachments passed to <Card> automatically apply to the <article> element.


6. Controlling Attachment Re-execution

6.1 The Re-execution Challenge

By default, attachments re-run whenever any dependency changes. For expensive operations, this can be problematic:

<script>
	function expensiveSetup(bar) {
		return (node) => {
			// This runs on EVERY change to `bar`
			veryExpensiveSetupWork(node) // 😬
			update(node, bar)
		}
	}

	let bar = $state('initial')
</script>

<div {@attach expensiveSetup(bar)}>...</div>

Every time bar changes, veryExpensiveSetupWork runs again—clearly undesirable.

Understanding Re-execution Triggers

Re-execution occurs when:

  • Reactive state read during the attachment’s execution changes.
  • The attachment factory is called with new parameters (recreating the attachment function).
  • The component re-mounts the element.

Signs That Re-execution is a Problem

  • Performance issues: Expensive setup running unnecessarily.
  • Visual glitches: Libraries reinitializing, causing flickering.
  • Resource leaks: Incomplete cleanup from rapid re-executions.
  • Unexpected behavior: State not preserved across re-runs.

When these issues arise, the getter pattern provides a solution.

6.2 The Getter Pattern for Deferred Reading

The solution is to pass a getter function instead of the value directly, deferring the reactive read to a nested effect:

<script>
	/**
	 * @param {() => string} getBar - Getter for the reactive value
	 * @returns {import('svelte/attachments').Attachment}
	 */
	function expensiveSetup(getBar) {
		return (node) => {
			// Runs once—no reactive reads here
			veryExpensiveSetupWork(node)

			// Nested effect reads the value reactively
			$effect(() => {
				update(node, getBar())
			})
		}
	}

	let bar = $state('initial')
</script>

<!-- Pass a getter, not the value --><div {@attach expensiveSetup(() => bar)}>...</div>

Now:

  • expensiveSetup(() => bar) creates the attachment once
  • veryExpensiveSetupWork runs once on mount
  • The nested $effect runs whenever bar changes

6.3 Practical Example: Chart Library Integration

Consider integrating a charting library where initialization is expensive:

<script>
	import { Chart, registerables } from 'chart.js'
	Chart.register(...registerables)

	/**
	 * Creates a reactive chart attachment
	 * @param {() => import('chart.js').ChartData} getData
	 * @param {import('chart.js').ChartOptions} options
	 */
	function createChart(getData, options = {}) {
		return (canvas) => {
			// Expensive: runs once
			const chart = new Chart(canvas, {
				type: 'line',
				data: getData(),
				options: {
					responsive: true,
					maintainAspectRatio: false,
					...options
				}
			})

			// Cheap: runs on data changes
			$effect(() => {
				const newData = getData()
				chart.data = newData
				chart.update('none') // Skip animations for updates
			})

			return () => chart.destroy()
		}
	}

	let salesData = $state({
		labels: ['Jan', 'Feb', 'Mar', 'Apr', 'May'],
		datasets: [
			{
				label: 'Sales',
				data: [12, 19, 3, 5, 2]
			}
		]
	})

	function addDataPoint() {
		const months = ['Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
		const nextMonth = months[salesData.labels.length - 5]
		if (nextMonth) {
			salesData.labels = [...salesData.labels, nextMonth]
			salesData.datasets[0].data = [...salesData.datasets[0].data, Math.floor(Math.random() * 20)]
		}
	}
</script>

<div class="chart-container">
	<canvas {@attach createChart(() => salesData)}></canvas>
</div>

<button onclick={addDataPoint}>Add Data Point</button>

7. Programmatic Attachment Creation

7.1 The createAttachmentKey Function

Sometimes you need to create attachments programmatically—especially in library code where you’re constructing prop objects dynamically. The createAttachmentKey function from svelte/attachments generates Symbol keys recognized by Svelte’s spreading mechanism:

<script>
	import { createAttachmentKey } from 'svelte/attachments'

	// Create a prop object with an attachment
	const props = {
		class: 'interactive-element',
		'data-testid': 'my-element',
		onclick: () => console.log('clicked'),
		[createAttachmentKey()]: (node) => {
			node.setAttribute('data-mounted', 'true')
			return () => node.removeAttribute('data-mounted')
		}
	}
</script>

<button {...props}> Click me </button>

7.2 Library Development Use Case

This pattern shines when building component libraries that need to attach behavior dynamically:

<script module>
	import { createAttachmentKey } from 'svelte/attachments'

	/**
	 * Creates a props object for draggable elements
	 * @param {Object} options
	 * @param {(position: {x: number, y: number}) => void} options.onDrag
	 * @param {() => void} [options.onDragStart]
	 * @param {() => void} [options.onDragEnd]
	 */
	export function createDraggable({ onDrag, onDragStart, onDragEnd }) {
		return {
			draggable: true,
			[createAttachmentKey()]: (element) => {
				let startX, startY, initialX, initialY

				function handleDragStart(e) {
					startX = e.clientX
					startY = e.clientY
					const rect = element.getBoundingClientRect()
					initialX = rect.left
					initialY = rect.top
					onDragStart?.()
				}

				function handleDrag(e) {
					if (e.clientX === 0 && e.clientY === 0) return
					const dx = e.clientX - startX
					const dy = e.clientY - startY
					onDrag({ x: initialX + dx, y: initialY + dy })
				}

				function handleDragEnd() {
					onDragEnd?.()
				}

				element.addEventListener('dragstart', handleDragStart)
				element.addEventListener('drag', handleDrag)
				element.addEventListener('dragend', handleDragEnd)

				return () => {
					element.removeEventListener('dragstart', handleDragStart)
					element.removeEventListener('drag', handleDrag)
					element.removeEventListener('dragend', handleDragEnd)
				}
			}
		}
	}
</script>

<script>
	let position = $state({ x: 100, y: 100 })

	const draggableProps = createDraggable({
		onDrag: (pos) => (position = pos),
		onDragStart: () => console.log('Started dragging'),
		onDragEnd: () => console.log('Finished dragging')
	})
</script>

<div {...draggableProps} style="position: absolute; left: {position.x}px; top: {position.y}px;">
	Drag me!
</div>

7.3 Multiple Programmatic Attachments

You can include multiple attachments in a single props object by calling createAttachmentKey() multiple times:

<script>
	import { createAttachmentKey } from 'svelte/attachments'

	const props = {
		class: 'multi-behavior',
		[createAttachmentKey()]: (el) => {
			// First attachment: logging
			console.log('Element mounted:', el)
			return () => console.log('Element unmounting:', el)
		},
		[createAttachmentKey()]: (el) => {
			// Second attachment: intersection observer
			const observer = new IntersectionObserver((entries) => {
				entries.forEach((entry) => {
					el.classList.toggle('visible', entry.isIntersecting)
				})
			})
			observer.observe(el)
			return () => observer.disconnect()
		},
		[createAttachmentKey()]: (el) => {
			// Third attachment: focus trap
			// ...implementation
		}
	}
</script>

<div {...props}>Multiple behaviors attached programmatically</div>

8. Migrating from Actions to Attachments

8.1 The fromAction Utility

Svelte provides fromAction to convert existing actions into attachments, enabling gradual migration and interoperability with action-based libraries:

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

	let actionParam = $state('value')
</script>

<!-- Original action syntax -->
<div use:someLibraryAction={actionParam}>...</div>

<!-- Equivalent attachment syntax -->
<div {@attach fromAction(someLibraryAction, () => actionParam)}>...</div>

Critical note: The second argument must be a function that returns the parameter, not the parameter itself. This ensures proper reactivity tracking.

8.2 Why Migrate? The Composability Advantage

While actions still work, attachments offer superior composability:

  1. Component passthrough: Attachments pass through component boundaries via spreading
  2. Unified reactivity: No separate update callback to maintain
  3. Effect integration: Can use $effect inside for fine-grained control
<!-- This doesn't work with actions -->
<MyComponent use:someAction={param}>Content</MyComponent>

<!-- This works with attachments -->
<MyComponent {@attach fromAction(someAction, () => param)}>Content</MyComponent>

8.3 Migration Strategy for Existing Codebases

For large codebases with many actions, migrate incrementally:

  1. Identify action usage patterns - catalog all actions
  2. Wrap critical library actions - use fromAction for third-party code
  3. Rewrite custom actions - convert your own actions to native attachments
  4. Update component interfaces - ensure wrapper components spread props
<!-- Before: Custom action -->
<script>
  export function tooltip(node, text) {
    const tip = document.createElement('div');
    tip.className = 'tooltip';
    tip.textContent = text;

    function show() { document.body.appendChild(tip); }
    function hide() { tip.remove(); }

    node.addEventListener('mouseenter', show);
    node.addEventListener('mouseleave', hide);

    return {
      update(newText) {
        tip.textContent = newText;
      },
      destroy() {
        node.removeEventListener('mouseenter', show);
        node.removeEventListener('mouseleave', hide);
        tip.remove();
      }
    };
  }
</script>

<!-- After: Native attachment -->
<script>
  export function tooltip(text) {
    return (node) => {
      const tip = document.createElement('div');
      tip.className = 'tooltip';

      // Text is read reactively through the factory closure
      tip.textContent = text;

      function show() { document.body.appendChild(tip); }
      function hide() { tip.remove(); }

      node.addEventListener('mouseenter', show);
      node.addEventListener('mouseleave', hide);

      return () => {
        node.removeEventListener('mouseenter', show);
        node.removeEventListener('mouseleave', hide);
        tip.remove();
      };
    };
  }
</script>

Common Migration Pitfalls

  • Forgetting the getter: Using fromAction(action, param) instead of fromAction(action, () => param).
  • Missing cleanup: Actions with manual cleanup need proper destroy handling.
  • Component boundaries: Actions don’t pass through components unless spread.
  • Reactivity differences: Attachments track dependencies automatically, unlike actions’ manual updates.

Testing Your Migration

  • Ensure attachments behave identically to actions.
  • Test reactivity: changes should trigger updates without manual update calls.
  • Verify cleanup: no memory leaks or lingering effects.
  • Check component passthrough: attachments should work on wrapper components.

9. Real-World Use Cases and Patterns

9.1 Focus Management System

Managing focus is critical for accessibility. Here’s a comprehensive focus trap attachment:

<script>
	/**
	 * Creates a focus trap attachment for modal dialogs
	 * @param {Object} options
	 * @param {boolean} [options.autoFocus=true] - Focus first focusable element on mount
	 * @param {boolean} [options.restoreFocus=true] - Restore focus on unmount
	 * @returns {import('svelte/attachments').Attachment}
	 */
	function focusTrap({ autoFocus = true, restoreFocus = true } = {}) {
		return (container) => {
			const previouslyFocused = document.activeElement

			const focusableSelectors = [
				'button:not([disabled])',
				'input:not([disabled])',
				'select:not([disabled])',
				'textarea:not([disabled])',
				'a[href]',
				'[tabindex]:not([tabindex="-1"])'
			].join(', ')

			function getFocusableElements() {
				return Array.from(container.querySelectorAll(focusableSelectors))
			}

			function handleKeyDown(event) {
				if (event.key !== 'Tab') return

				const focusable = getFocusableElements()
				if (focusable.length === 0) return

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

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

			container.addEventListener('keydown', handleKeyDown)

			if (autoFocus) {
				const focusable = getFocusableElements()
				if (focusable.length > 0) {
					focusable[0].focus()
				}
			}

			return () => {
				container.removeEventListener('keydown', handleKeyDown)
				if (restoreFocus && previouslyFocused instanceof HTMLElement) {
					previouslyFocused.focus()
				}
			}
		}
	}

	let showModal = $state(false)
</script>

{#if showModal}
	<div class="modal-backdrop">
		<div class="modal" role="dialog" aria-modal="true" {@attach focusTrap()}>
			<h2>Modal Title</h2>
			<p>Modal content goes here.</p>
			<button onclick={() => (showModal = false)}>Close</button>
		</div>
	</div>
{/if}

9.2 Intersection Observer for Lazy Loading

<script>
	/**
	 * Lazy loading attachment with placeholder support
	 * @param {Object} options
	 * @param {string} options.src - Image source to load
	 * @param {string} [options.placeholder] - Placeholder while loading
	 * @param {string} [options.rootMargin='100px'] - Load ahead margin
	 * @returns {import('svelte/attachments').Attachment<HTMLImageElement>}
	 */
	function lazyLoad({ src, placeholder = '', rootMargin = '100px' }) {
		return (img) => {
			// Set placeholder immediately
			if (placeholder) {
				img.src = placeholder
			}

			const observer = new IntersectionObserver(
				(entries) => {
					entries.forEach((entry) => {
						if (entry.isIntersecting) {
							// Load the real image
							img.src = src
							img.classList.add('loaded')
							observer.disconnect()
						}
					})
				},
				{ rootMargin }
			)

			observer.observe(img)

			return () => observer.disconnect()
		}
	}

	const images = [
		{ src: '/images/hero-1.jpg', alt: 'Hero image 1' },
		{ src: '/images/hero-2.jpg', alt: 'Hero image 2' },
		{ src: '/images/hero-3.jpg', alt: 'Hero image 3' }
	]
</script>

<div class="image-gallery">
	{#each images as image}
		<img
			alt={image.alt}
			{@attach lazyLoad({
				src: image.src,
				placeholder: '/images/placeholder.svg'
			})}
		/>
	{/each}
</div>

9.3 Real-Time Collaboration Cursor

<script>
	import { onMount } from 'svelte'

	/**
	 * Tracks cursor position and broadcasts to collaboration server
	 * @param {Object} options
	 * @param {WebSocket} options.socket - WebSocket connection
	 * @param {string} options.userId - User identifier
	 * @param {string} options.color - Cursor color
	 */
	function collaborativeCursor({ socket, userId, color }) {
		return (container) => {
			let throttleTimer = null

			function handleMouseMove(event) {
				if (throttleTimer) return

				throttleTimer = setTimeout(() => {
					throttleTimer = null
				}, 50) // Throttle to 20fps

				const rect = container.getBoundingClientRect()
				const x = (event.clientX - rect.left) / rect.width
				const y = (event.clientY - rect.top) / rect.height

				socket.send(
					JSON.stringify({
						type: 'cursor',
						userId,
						position: { x, y },
						color
					})
				)
			}

			function handleMouseLeave() {
				socket.send(
					JSON.stringify({
						type: 'cursor',
						userId,
						position: null
					})
				)
			}

			container.addEventListener('mousemove', handleMouseMove)
			container.addEventListener('mouseleave', handleMouseLeave)

			return () => {
				container.removeEventListener('mousemove', handleMouseMove)
				container.removeEventListener('mouseleave', handleMouseLeave)
				if (throttleTimer) clearTimeout(throttleTimer)
			}
		}
	}

	let socket = $state(null)
	const userId = crypto.randomUUID()
	const cursorColor = `hsl(${Math.random() * 360}, 70%, 50%)`

	onMount(() => {
		socket = new WebSocket('wss://collab.example.com')
		return () => socket.close()
	})
</script>

{#if socket}
	<div
		class="collaboration-area"
		{@attach collaborativeCursor({ socket, userId, color: cursorColor })}
	>
		<!-- Collaborative content -->
	</div>
{/if}

9.4 Form Validation with Visual Feedback

<script>
	/**
	 * Validates input and provides visual feedback
	 * @param {Object} options
	 * @param {(value: string) => string | null} options.validate - Returns error message or null
	 * @param {(isValid: boolean) => void} [options.onValidityChange] - Callback on validity change
	 * @returns {import('svelte/attachments').Attachment<HTMLInputElement>}
	 */
	function validateInput({ validate, onValidityChange }) {
		return (input) => {
			let currentValidity = true
			const errorElement = document.createElement('div')
			errorElement.className = 'validation-error'
			errorElement.setAttribute('role', 'alert')

			function checkValidity() {
				const error = validate(input.value)
				const isValid = error === null

				if (isValid !== currentValidity) {
					currentValidity = isValid
					onValidityChange?.(isValid)
				}

				input.classList.toggle('invalid', !isValid)
				input.setAttribute('aria-invalid', String(!isValid))

				if (error) {
					errorElement.textContent = error
					if (!errorElement.parentElement) {
						input.parentElement?.appendChild(errorElement)
					}
				} else {
					errorElement.remove()
				}
			}

			input.addEventListener('input', checkValidity)
			input.addEventListener('blur', checkValidity)

			// Initial check
			checkValidity()

			return () => {
				input.removeEventListener('input', checkValidity)
				input.removeEventListener('blur', checkValidity)
				errorElement.remove()
			}
		}
	}

	let email = $state('')
	let emailValid = $state(false)

	function validateEmail(value) {
		if (!value) return 'Email is required'
		if (!value.includes('@')) return 'Please enter a valid email'
		if (!value.includes('.')) return 'Please enter a valid email domain'
		return null
	}
</script>

<form>
	<label>
		Email:
		<input
			type="email"
			bind:value={email}
			{@attach validateInput({
				validate: validateEmail,
				onValidityChange: (valid) => (emailValid = valid)
			})}
		/>
	</label>

	<button type="submit" disabled={!emailValid}> Submit </button>
</form>

<style>
	.invalid {
		border-color: red;
	}
	.validation-error {
		color: red;
		font-size: 0.875rem;
		margin-top: 0.25rem;
	}
</style>

10. Advanced Patterns and Architectural Considerations

10.1 Composing Multiple Attachments

Create higher-order attachments that combine multiple behaviors:

<script>
	import { createAttachmentKey } from 'svelte/attachments'

	/**
	 * Composes multiple attachments into a single props object
	 * @param  {...import('svelte/attachments').Attachment} attachments
	 * @returns {Record<symbol, import('svelte/attachments').Attachment>}
	 */
	function compose(...attachments) {
		return attachments.reduce((props, attachment) => {
			props[createAttachmentKey()] = attachment
			return props
		}, {})
	}

	// Individual attachments
	function logMounts(el) {
		console.log('Mounted:', el)
		return () => console.log('Unmounted:', el)
	}

	function addRipple(el) {
		function createRipple(e) {
			const ripple = document.createElement('span')
			ripple.className = 'ripple'
			ripple.style.left = `${e.offsetX}px`
			ripple.style.top = `${e.offsetY}px`
			el.appendChild(ripple)
			setTimeout(() => ripple.remove(), 600)
		}
		el.addEventListener('click', createRipple)
		return () => el.removeEventListener('click', createRipple)
	}

	// Composed props object
	const interactiveProps = compose(logMounts, addRipple)
</script>

<button {...interactiveProps}> Interactive Button </button>

When to Use Composition

  • Reusable behavior bundles: Combine related functionalities into a single prop.
  • Programmatic attachment: When building props objects dynamically in libraries.
  • Complex components: Where multiple behaviors are always applied together.

Alternative: Multiple {@attach} Directives

For simple cases, multiple {@attach} on the same element is often clearer and doesn’t require a helper function:

<div {@attach logMounts} {@attach addRipple}>...</div>

Composition is powerful for advanced scenarios, but multiple directives suffice for most use cases.

10.2 Conditional Attachments

Attachments can be conditionally applied using ternary expressions:

<script>
	let enableTracking = $state(true)

	function analyticsTracker(el) {
		// Track interactions
		const handleClick = () => analytics.track('click', { element: el.id })
		el.addEventListener('click', handleClick)
		return () => el.removeEventListener('click', handleClick)
	}

	// No-op attachment for when tracking is disabled
	function noop() {
		return () => {}
	}
</script>

<button id="cta-button" {@attach enableTracking ? analyticsTracker : noop}> Call to Action </button>

10.3 Context-Aware Attachments

Leverage Svelte’s context API for attachments that need shared state:

<!-- FormContext.svelte -->
<script>
	import { setContext } from 'svelte'

	let { children } = $props()

	const formState = $state({
		fields: {},
		errors: {},
		isSubmitting: false
	})

	setContext('form', {
		registerField(name, validate) {
			formState.fields[name] = { validate, value: '' }
		},
		updateField(name, value) {
			formState.fields[name].value = value
			const error = formState.fields[name].validate?.(value)
			formState.errors[name] = error
		},
		getError(name) {
			return formState.errors[name]
		}
	})
</script>

{@render children()}
<!-- FormField.svelte -->
<script>
	import { getContext } from 'svelte'

	let { name, validate, ...props } = $props()

	const form = getContext('form')

	function formFieldAttachment(input) {
		form.registerField(name, validate)

		function handleInput(e) {
			form.updateField(name, e.target.value)
		}

		input.addEventListener('input', handleInput)

		$effect(() => {
			const error = form.getError(name)
			input.setCustomValidity(error || '')
		})

		return () => {
			input.removeEventListener('input', handleInput)
		}
	}
</script>

<input {name} {...props} {@attach formFieldAttachment} />

10.4 Testing Attachments

Attachments can be tested by extracting the core logic:

// tooltip.js
/**
 * @param {string} content
 * @returns {import('svelte/attachments').Attachment}
 */
export function tooltip(content) {
	return (element) => {
		const tip = createTooltipElement(content)

		const show = () => positionAndShow(tip, element)
		const hide = () => tip.remove()

		element.addEventListener('mouseenter', show)
		element.addEventListener('mouseleave', hide)

		return () => {
			element.removeEventListener('mouseenter', show)
			element.removeEventListener('mouseleave', hide)
			tip.remove()
		}
	}
}

// Internal functions exposed for testing
export function createTooltipElement(content) {
	const tip = document.createElement('div')
	tip.className = 'tooltip'
	tip.textContent = content
	tip.setAttribute('role', 'tooltip')
	return tip
}

export function positionAndShow(tip, anchor) {
	const rect = anchor.getBoundingClientRect()
	tip.style.left = `${rect.left + rect.width / 2}px`
	tip.style.top = `${rect.top - 8}px`
	document.body.appendChild(tip)
}
// tooltip.test.js
import { describe, it, expect, vi } from 'vitest'
import { createTooltipElement, tooltip } from './tooltip.js'

describe('tooltip', () => {
	describe('createTooltipElement', () => {
		it('creates element with correct content', () => {
			const tip = createTooltipElement('Hello')
			expect(tip.textContent).toBe('Hello')
			expect(tip.className).toBe('tooltip')
			expect(tip.getAttribute('role')).toBe('tooltip')
		})
	})

	describe('tooltip attachment', () => {
		it('adds and removes event listeners', () => {
			const element = document.createElement('button')
			const addSpy = vi.spyOn(element, 'addEventListener')
			const removeSpy = vi.spyOn(element, 'removeEventListener')

			const cleanup = tooltip('Test')(element)

			expect(addSpy).toHaveBeenCalledWith('mouseenter', expect.any(Function))
			expect(addSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function))

			cleanup()

			expect(removeSpy).toHaveBeenCalledWith('mouseenter', expect.any(Function))
			expect(removeSpy).toHaveBeenCalledWith('mouseleave', expect.any(Function))
		})
	})
})

11. Pitfalls, Edge Cases, and Anti-Patterns

11.1 Reactivity Traps

Problem: Unintended dependency tracking

<script>
	let unrelatedState = $state('hello')
	let targetState = $state('world')

	function myAttachment(el) {
		// BUG: This reads `unrelatedState` creating an unwanted dependency
		console.log('Current unrelated state:', unrelatedState)

		// This is the actual work we want to do
		el.textContent = targetState
	}
</script>

Solution: Use untrack for reads that shouldn’t create dependencies:

<script>
	import { untrack } from 'svelte'

	let unrelatedState = $state('hello')
	let targetState = $state('world')

	function myAttachment(el) {
		// Now this doesn't create a dependency
		untrack(() => {
			console.log('Current unrelated state:', unrelatedState)
		})

		el.textContent = targetState
	}
</script>

11.2 Memory Leaks from Missing Cleanup

Problem: Forgetting to clean up subscriptions

<script>
	function leakyAttachment(el) {
		const subscription = someObservable.subscribe((value) => {
			el.textContent = value
		})
		// BUG: No cleanup! Subscription persists after unmount
	}
</script>

Solution: Always return cleanup for subscriptions:

<script>
	function safeAttachment(el) {
		const subscription = someObservable.subscribe((value) => {
			el.textContent = value
		})

		return () => subscription.unsubscribe()
	}
</script>

11.3 Cross-Component Data Leakage

Problem: Mutating shared state in attachments

<script>
	// Shared mutable object
	const sharedConfig = { count: 0 }

	function counterAttachment(el) {
		// BUG: Mutating shared state affects all components using this config
		sharedConfig.count += 1
		el.textContent = sharedConfig.count
	}
</script>

Solution: Use component-local state or immutable patterns:

<script>
	function counterAttachment(el) {
		// Local state per attachment instance
		let count = 0

		function increment() {
			count += 1
			el.textContent = String(count)
		}

		el.addEventListener('click', increment)
		return () => el.removeEventListener('click', increment)
	}
</script>

11.4 Infinite Re-execution Loops

Problem: Writing to state that’s also read in the same attachment

<script>
	let counter = $state(0)

	function infiniteLoop(el) {
		// BUG: Reads `counter`, then writes to it, triggering re-run
		el.textContent = String(counter)
		counter += 1 // This triggers a re-run!
	}
</script>

Solution: Use nested effects or defer writes:

<script>
	let counter = $state(0)

	function safeAttachment(el) {
		// Read in outer attachment
		el.textContent = String(counter)

		// Write deferred to next tick
		queueMicrotask(() => {
			counter += 1
		})
	}
</script>

11.5 Server-Side Rendering Considerations

Attachments only run in the browser, not during SSR. This is usually desirable, but be aware:

<script>
	function browserOnlySetup(el) {
		// This never runs on the server
		// Initial content should be set via regular attributes/content
	}
</script>

<!-- Set initial state declaratively for SSR -->
<div class="widget" data-initialized="false" {@attach browserOnlySetup}>Loading...</div>

12. Performance Optimization Strategies

12.1 Debouncing and Throttling in Attachments

For high-frequency events, wrap handlers appropriately:

<script>
	function throttle(fn, delay) {
		let lastCall = 0
		return (...args) => {
			const now = Date.now()
			if (now - lastCall >= delay) {
				lastCall = now
				fn(...args)
			}
		}
	}

	function scrollTracker(el) {
		const handleScroll = throttle(() => {
			const scrollPercent = el.scrollTop / (el.scrollHeight - el.clientHeight)
			analytics.track('scroll_depth', { percent: Math.round(scrollPercent * 100) })
		}, 100)

		el.addEventListener('scroll', handleScroll, { passive: true })
		return () => el.removeEventListener('scroll', handleScroll)
	}
</script>

12.2 Lazy Initialization

Defer expensive operations until actually needed:

<script>
	function lazyChart(getData) {
		return (canvas) => {
			let chart = null

			const observer = new IntersectionObserver((entries) => {
				if (entries[0].isIntersecting && !chart) {
					// Only initialize when visible
					import('chart.js').then(({ Chart, registerables }) => {
						Chart.register(...registerables)
						chart = new Chart(canvas, {
							type: 'line',
							data: getData()
						})
					})
					observer.disconnect()
				}
			})

			observer.observe(canvas)

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

12.3 Batching DOM Operations

Minimize layout thrashing by batching reads and writes:

<script>
	function efficientLayout(el) {
		function updateLayout() {
			// Batch all reads first
			const width = el.offsetWidth
			const height = el.offsetHeight
			const parentWidth = el.parentElement?.offsetWidth ?? 0

			// Then batch all writes
			requestAnimationFrame(() => {
				el.style.transform = `scale(${parentWidth / width})`
				el.dataset.aspectRatio = String(width / height)
			})
		}

		const resizeObserver = new ResizeObserver(updateLayout)
		resizeObserver.observe(el)
		resizeObserver.observe(el.parentElement)

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

Conclusion: The Future of DOM Interaction in Svelte

Svelte 5’s {@attach ...} directive represents more than a syntactic improvement over actions—it embodies a philosophical shift toward unified reactivity. By executing within effects, attachments inherit all the benefits of Svelte’s reactive system: automatic dependency tracking, efficient batched updates, and intuitive cleanup semantics.

As you adopt attachments in your codebase, remember these key principles:

  1. Embrace the factory pattern for parameterized attachments
  2. Use nested effects for fine-grained reactivity control
  3. Always consider cleanup for subscriptions and event listeners
  4. Leverage prop spreading for component composition
  5. Migrate incrementally using fromAction for existing libraries

The transition from actions to attachments isn’t merely about new syntax—it’s about thinking in terms of reactive effects rather than imperative callbacks. This mental model shift, once internalized, makes complex DOM interactions feel natural and maintainable.

As the Svelte ecosystem continues to evolve, expect library authors to embrace attachments for their superior composability. The ability to pass DOM behaviors through component hierarchies via prop spreading opens new possibilities for building truly encapsulated, reusable component systems.


Reference: API Summary

Template Syntax

<!-- Basic attachment -->
<div {@attach myAttachment}>...</div>

<!-- Factory pattern -->
<div {@attach tooltip('Hello')}>...</div>

<!-- Inline attachment -->
<div
	{@attach (el) => {
		/* setup */ return () => {
			/* cleanup */
		}
	}}
>
	...
</div>

<!-- Multiple attachments -->
<div {@attach a} {@attach b} {@attach c}>...</div>

<!-- On components (spreads to inner element) -->
<Component {@attach myAttachment}>...</Component>

Module Exports (svelte/attachments)

// Create Symbol keys for programmatic attachments
function createAttachmentKey(): symbol

// Convert actions to attachments
function fromAction<E extends EventTarget, T>(action: Action<E, T>, fn: () => T): Attachment<E>

function fromAction<E extends EventTarget>(action: Action<E, void>): Attachment<E>

// Type definition
interface Attachment<T extends EventTarget = Element> {
	(element: T): void | (() => void)
}

Conclusion

The {@attach} directive represents Svelte 5’s modern approach to element-level behavior extension, combining the flexibility of the legacy use: action system with first-class support for Svelte’s reactive primitives. By enabling inline attachment definitions, full reactivity within attachment functions, and seamless composition of multiple attachments, it transforms element augmentation from an imperative pattern into a declarative, reactive paradigm that feels natural within Svelte’s component model.

Mastering {@attach} requires understanding both its capabilities and its appropriate use cases. It excels at element-specific behaviors—tooltips, click-outside detection, drag-and-drop, accessibility enhancements—while complementing rather than replacing lifecycle hooks, $effect, and event handlers for their respective domains. The fromAction migration path ensures backward compatibility with the ecosystem, while the attachment’s reactive nature and composition patterns open new architectural possibilities for building reusable, maintainable element behaviors.

Key Takeaways

  • {@attach} provides element-level behavior extension introduced in Svelte 5.29, binding functions to DOM elements with automatic lifecycle management (setup on mount, cleanup on unmount)
  • Full reactivity inside attachments - closures capture reactive state and re-run when dependencies change, unlike legacy use: actions which need manual update calls
  • Syntax supports inline functions and references - {@attach (el) => { ... }} for inline definitions or {@attach myAttachment} for reusable references
  • Return a cleanup function for teardown - return () => {} from attachments to handle event listener removal, timer cleanup, and resource disposal
  • Multiple attachments compose seamlessly with {@attach a} {@attach b} {@attach c} syntax, executing in order during mount and in reverse during unmount
  • Component usage spreads to inner element - <Component {@attach myAttachment}> applies the attachment to the component’s root DOM element, not the component instance
  • fromAction migration helper converts legacy use: actions to attachments with fromAction(action) for zero-parameter actions or fromAction(action, () => params) for parameterized ones
  • TypeScript support with Attachment<T> interface provides type safety for element types, with generic constraints ensuring attachments only apply to compatible targets

See Also

This article was written for Svelte 5.29+. Always consult the official Svelte documentation for the most current API details and best practices.