The New Svelte Attachments Feature Explained
Published May 16, 2025
Table of Contents
- Lifecycle Functions
- Svelte Actions (Element-Level Lifecycle Functions)
- Svelte Attachments Are The New Svelte Actions
Lifecycle Functions
Svelte released a new @attach feature which are functions that run when an element is created in the DOM, and you can return a cleanup function when theyโre removed.
I like to think of them as onMount
functions for elements, and in this post Iโm going to show you why you would use them.
A common use for lifecycle functions is integrating a third-party JavaScript library, so Iโm going to use the JavaScript animation library GSAP in the examples โ you can try the examples in the Svelte playground.
Letโs start by creating a box:
<script>
import { gsap } from 'gsap'
</script>
<div class="box"></div>
<style>
.box {
width: 100px;
aspect-ratio: 1;
background: aqua;
border-radius: 8px;
}
</style>
To box element doesnโt exist yet in the DOM, so we have to pass a callback to the onMount
component lifecycle function which runs after the element is created.
Then we can query the element and animate it using GSAP:
<script>
import { onMount } from 'svelte'
import { gsap } from 'gsap'
onMount(() => {
// โ ๏ธ not the most reliable method
const box = document.querySelector('.box')
gsap.to(box, { rotation: 360, duration: 2 })
})
</script>
Instead of using the querySelector()
method, we can use the bind:
directive to bind the element to a variable, and then we can use that variable to animate the element:
<script>
import { onMount } from 'svelte'
import { gsap } from 'gsap'
let box
onMount(() => {
gsap.to(box, { rotation: 360, duration: 2 })
})
</script>
<div bind:this={box} class="box"></div>
You can also use an $effect
to animate the element. Effects run after the component is created, but if you pass a reactive value to the $effect
it will be tracked, so you have to use the untrack()
function to untrack it:
<script>
import { untrack } from 'svelte'
import { gsap } from 'gsap'
let box
let rotation = $state(360)
$effect(() => {
untrack(() => {
// ๐ซ oops!
gsap.to(box, { rotation, duration: 2 })
})
})
</script>
<div bind:this={box} class="box"></div>
The $effect
rune isnโt a replacement for onMount
even if they look similar. Effects and their cleanup function rerun each time the value updates. This is just so you know what methods are available to you.
To be honest, I use $effect
most of the time, because I know how it works.
Svelte Actions (Element-Level Lifecycle Functions)
If onMount
is a component-level lifecycle function, then a Svelte action is an element-level lifecycle function.
A Svelte action is a normal JavaScript function that runs when the element is created. It only works when you use it with the use:
directive.
Hereโs the same GSAP example using a Svelte action:
<script>
import { gsap } from 'gsap'
function to(element, options) {
gsap.to(element, options)
}
</script>
<div use:to={{ rotation: 360, duration: 2 }} class="box">
The to
action has the element
as the first argument, and the options
as the second argument. You can return an update
function or use an $effect
for updates which brings us to their disadvantages.
Svelte actions are one of my favorite underrated Svelte features, but theyโre not perfect:
<!-- ๐ unusual syntax where the element is implicitly passed -->
<div use:action={options}>
<!-- ๐ must be declared elsewhere -->
<div use:createAction()>
<!-- ๐ can't be used inline -->
<div use:action={(element) => ...}>
<!-- ๐ have to use `update` or `$effect` for updates -->
<div use:action={value}>
<!-- ๐ can't be conditionally applied -->
<div FLAG && use:action>
<!-- ๐ can't be spread -->
<div {...props}>
<!-- ๐ can't be used on components -->
<Component use:action />
Letโs look at how the @attach
feature solves these problems.
Svelte Attachments Are The New Svelte Actions
A Svelte attachment is also just a normal JavaScript function that runs when then element is created, and optionally runs a cleanup function when itโs removed.
This example creates a banana
function to show the naming is not important, and then uses it with the @attach
directive:
<script>
function banana(element) {
console.log('๐ซก element created')
return () => console.log('๐งน element removed')
}
</script>
<div {@attach banana}></div>
You can use inline attachments for a quick reference to an element, avoiding the entire ceremony around creating a function:
<div {@attach (element) => console.log(element)}>
Hereโs the GSAP example as a Svelte attachment:
<script>
import { gsap } from 'gsap'
function to(element) {
gsap.to(element, { rotation: 360, duration: 2 })
}
</script>
<div {@attach to} class="box"></div>
If you want to pass your own arguments like options
, you can return the attachment function:
<script>
import { gsap } from 'gsap'
function to(options) {
return (element) => {
gsap.to(element, options)
}
}
</script>
<div {@attach to({ rotation: 360, duration: 2 })} class="box"></div>
๐ฟ๏ธ This pattern is also called a thunk. A thunk is a function that delays some work until itโs needed, rather than performing it immediately.
You can have multiple attachments. In this example weโre using the Draggable
plugin from GSAP to make the box draggable:
<script>
import { gsap } from 'gsap'
import { Draggable } from 'gsap/Draggable'
gsap.registerPlugin(Draggable)
function drag(options) {
return (element) => {
Draggable.create(element, options)
}
}
// ...
</script>
<div
{@attach to({ rotation: 360, duration: 2 })}
{@attach drag({ type: 'x,y' })}
class="box"
>
Attachments are part of the template tracking context. This means if you read a reactive value inside the attachment, itโs going to rerun each time the value changes (you can use a nested $effect
to only rerun that):
<script>
let value = $state(0)
setInterval(() => value++, 1000)
</script>
<!-- tracking context -->
<div {@attach (element) => {
// reading the value inside `$effect` reruns it
console.log(value)
// alternatively read value inside nested effect
$effect(() => {
console.log(value)
})
}}>
Letโs use the ScrambleTextPlugin
from GSAP to create a scramble
function that accepts a text and options. The text
value is reactive, so any changes to it will cause the text to be scrambled:
<script>
import { gsap } from 'gsap'
import { ScrambleTextPlugin } from 'gsap/ScrambleTextPlugin'
gsap.registerPlugin(ScrambleTextPlugin)
// โ ๏ธ attachments live inside of `$effect`
function scramble(text, options) {
return (element) => {
gsap.to(element, {
duration: 2,
scrambleText: text,
...options
})
}
}
let text = $state('Svelte')
</script>
<!-- tracking context -->
<input type="text" bind:value={text} />
<div {@attach scramble(text)}></div>
These are just functions, so you can do whatever you want in theory. Here I created a typed createAnimation
function which returns a to
attachment with the GSAP animation, and a play
function to play the animation:
<script lang="ts">
import { gsap } from 'gsap'
function createAnimation(options: gsap.TweenVars = {}) {
let animation: gsap.core.Tween
return {
to(): Attachment {
return (element) => {
animation = gsap.to(element, options)
}
},
play() {
animation.play()
}
}
}
const { to, play } = createAnimation({
rotation: 360,
duration: 2,
easing: 'power3.inOut',
paused: true
})
</script>
<div {@attach to()} class="box"></div>
<button onclick={() => play()}>Play</button>
This is very cool for something like a UI library where you want to easily add some behaviour to elements. One idea I want to try out involves an attachment that animates UI changes using the View Transition API.
I was also thinking if you could use Svelte attachments to create an animation timeline with GSAP?
<script>
import { gsap } from 'gsap'
function createTimeline(options) {
let timeline = gsap.timeline({ paused: true, ...options })
let position = $state(0)
$effect(() => {
timeline.seek(position)
})
timeline.eventCallback('onUpdate', () => {
position = timeline.time()
})
return {
add(options) {
return (element) => {
timeline.to(element, options)
}
},
get controls() { return timeline },
get position() { return position },
set position(v) { position = v },
}
}
const timeline = createTimeline()
</script>
<div {@attach timeline.add({ x: 400, duration: 1 })}></div>
<div {@attach timeline.add({ x: 400, duration: 2 })}></div>
<div {@attach timeline.add({ x: 400, duration: 1 })}></div>
<button onclick={() => timeline.controls.play()}>Play</button>
<label>
<input
type="range"
bind:value={timeline.position}
min={0}
max={4}
step={0.1}
/>
{timeline.position.toFixed(1)}s
</label>
<style>
div {
width: 100px;
aspect-ratio: 1;
margin-block-end: 0.5rem;
background: aqua;
border-radius: 8px;
}
</style>
Thatโs it! Letโs recap:
<!-- ๐ improved syntax -->
<div {@attach fn}></div>
<!-- ๐ can be declared anywhere -->
<div {@attach createAttachment()}>
<!-- ๐ can be used inline -->
<div {@attach (element) => ...}>
<!-- ๐ reactive by default -->
<div {@attach fn(state)}>
<!-- ๐ can be conditionally applied -->
<div {@attach FLAG && fn}>
<!-- ๐ can be spread -->
<div {...props}>
<!-- ๐ can be used on components -->
<Component {@attach fn} />
I didnโt show every example, but you can read the docs to learn more, like how to create attachments programmatically.
If you need ideas, how about a link attachment that opens an <iframe>
with the preview on hover, or an attachment that tracks the cursor position inside an element?
Stay inspired! ๐