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>scoped CSS
Native heading keeps the style.
Motion heading keeps it too.
Install
pnpm add -D @humanspeak/svelte-scoped-propspnpm add -D @humanspeak/svelte-scoped-propsAdd 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 configimport { 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 configIf 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 }]} />