logo svelte /motion v0.7.2

Scoped Motion Classes

Svelte can prune a component-scoped CSS selector when that selector is only passed to a component, including motion.*. This is the behavior reported in issue #215: a normal <h2 class="black"> keeps .black, but <motion.h2 class="black"> can make Svelte treat .black as unused.

For those cases, use the companion preprocessor @humanspeak/svelte-scoped-props. It adds explicit scoped: props so the parent component can opt into forwarding its own CSS scope hash through a component prop.

For the deeper compiler-design notes behind this workaround, see the Svelte Scoped Props docs and the design notes.

<script lang="ts">
    import { motion, useTime, useTransform } from '@humanspeak/svelte-motion'

    const time = useTime()
    const y = useTransform(time, (latest) => Math.sin(latest / 280) * -10)
    const scale = useTransform(time, (latest) => 1 + Math.sin(latest / 280) * 0.04)
    const rotate = useTransform(time, (latest) => Math.sin(latest / 360) * -1.5)
</script>

<h2 class="headline">Native heading keeps the style.</h2>

<motion.h2 scoped:class="headline" style={{ y, scale, rotate }}>
    Motion heading keeps it too.
</motion.h2>

<style>
    .headline {
        width: fit-content;
        border: 2px solid hsl(var(--primary));
        background: color-mix(in oklab, hsl(var(--primary)) 12%, hsl(var(--background)));
        color: hsl(var(--foreground));
        font-size: 2rem;
        font-weight: 800;
    }
</style>
<script lang="ts">
    import { motion, useTime, useTransform } from '@humanspeak/svelte-motion'

    const time = useTime()
    const y = useTransform(time, (latest) => Math.sin(latest / 280) * -10)
    const scale = useTransform(time, (latest) => 1 + Math.sin(latest / 280) * 0.04)
    const rotate = useTransform(time, (latest) => Math.sin(latest / 360) * -1.5)
</script>

<h2 class="headline">Native heading keeps the style.</h2>

<motion.h2 scoped:class="headline" style={{ y, scale, rotate }}>
    Motion heading keeps it too.
</motion.h2>

<style>
    .headline {
        width: fit-content;
        border: 2px solid hsl(var(--primary));
        background: color-mix(in oklab, hsl(var(--primary)) 12%, hsl(var(--background)));
        color: hsl(var(--foreground));
        font-size: 2rem;
        font-weight: 800;
    }
</style>
mode · live running open

scoped CSS

Native heading keeps the style.

Motion heading keeps it too.

Install

pnpm add -D @humanspeak/svelte-scoped-props
pnpm add -D @humanspeak/svelte-scoped-props

Add the preprocessor to svelte.config.js before Svelte compiles your components:

import { scopedProps } from '@humanspeak/svelte-scoped-props'
import adapter from '@sveltejs/adapter-cloudflare'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { mdsvex } from 'mdsvex'

const scopedPropsPreprocess = scopedProps({
    runtimeModule: '@humanspeak/svelte-scoped-props/runtime'
})

const scopedPropsSvelteOnly = {
    name: 'svelte-scoped-props-motion-docs',
    markup(options) {
        if (!options.filename?.endsWith('.svelte')) return undefined

        return scopedPropsPreprocess.markup?.(options)
    }
}

const config = {
    preprocess: [
        scopedPropsSvelteOnly,
        vitePreprocess(),
        mdsvex()
    ],
    kit: {
        adapter: adapter()
    }
}

export default config
import { scopedProps } from '@humanspeak/svelte-scoped-props'
import adapter from '@sveltejs/adapter-cloudflare'
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'
import { mdsvex } from 'mdsvex'

const scopedPropsPreprocess = scopedProps({
    runtimeModule: '@humanspeak/svelte-scoped-props/runtime'
})

const scopedPropsSvelteOnly = {
    name: 'svelte-scoped-props-motion-docs',
    markup(options) {
        if (!options.filename?.endsWith('.svelte')) return undefined

        return scopedPropsPreprocess.markup?.(options)
    }
}

const config = {
    preprocess: [
        scopedPropsSvelteOnly,
        vitePreprocess(),
        mdsvex()
    ],
    kit: {
        adapter: adapter()
    }
}

export default config

If your docs pipeline also processes non-Svelte files, wrap scopedProps() so it only transforms .svelte markup, then run your usual preprocessors afterward.

How it works

scoped:class is rewritten before Svelte analyzes the component:

<motion.h2 scoped:class="black" />
<motion.h2 scoped:class="black" />

becomes equivalent to:

<motion.h2 class="black svelte-abc123" />
<motion.h2 class="black svelte-abc123" />

The package also adds a tiny compile-time marker so Svelte sees .black as used in the parent file and keeps the scoped CSS rule.

When to use it

Use scoped:class when all of these are true:

  • The class is defined in the same Svelte file’s <style> block.
  • The class is passed to motion.div, motion.h2, m.div, or another component tag.
  • Svelte warns that the selector is unused, or the compiled CSS no longer contains it.

You do not need it for native elements:

<h2 class="black">Hello</h2>
<h2 class="black">Hello</h2>

Native elements already live in the current component, so Svelte can see the selector usage directly.

Limits

@humanspeak/svelte-scoped-props is an experimental companion package, not a Svelte core feature. It mirrors Svelte’s default CSS hash behavior because Svelte does not export that hash helper publicly.

The directive is intentionally explicit. Use it on component tags only:

<motion.div scoped:class="card" />
<Child scoped:class="card" />
<motion.div scoped:class="card" />
<Child scoped:class="card" />

Avoid combining scoped:class and class on the same component. If a value is dynamic, pass the dynamic value to scoped:class instead:

<motion.div scoped:class={['card', { active }]} />
<motion.div scoped:class={['card', { active }]} />