The New Svelte Attachments Feature Explained

Published May 16, 2025

Table of Contents

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:

app.svelte
<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:

app.svelte
<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:

app.svelte
<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:

app.svelte
<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:

app.svelte
<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:

actions
<!-- ๐Ÿ˜” 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:

app.svelte
<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:

app.svelte
<div {@attach (element) => console.log(element)}>

Hereโ€™s the GSAP example as a Svelte attachment:

app.svelte
<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:

app.svelte
<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:

app.svelte
<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):

app.svelte
<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:

app.svelte
<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:

app.svelte
<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?

app.svelte
<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:

attachments
<!-- ๐Ÿ˜„ 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! ๐Ÿ˜„

Support

You can subscribe on YouTube, or consider becoming a patron if you want to support my work.

Patreon
Found a mistake?

Every post is a Markdown file so contributing is simple as following the link below and pressing the pencil icon inside GitHub to edit it.

Edit on GitHub