@humanspeak/svelte-motion
Version:
Framer Motion for Svelte 5. Declarative motion.<tag> components with AnimatePresence exit animations, gestures (hover, tap, drag, focus, in-view), variants, FLIP layout animations, shared-layout transitions, spring physics, and scroll-linked motion values
513 lines (512 loc) • 16.1 kB
JavaScript
import { Parser } from 'acorn';
/**
* Tag-to-component name mapping. Each key is the lowercase HTML/SVG tag,
* and the value is the PascalCase component filename (without .svelte).
*
* This is used by the Vite plugin to rewrite `motion.div` → `import Div from '…/Div.svelte'`.
*/
const toComponentName = (tag) => tag
.split('-')
.map((part) => part.charAt(0).toUpperCase() + part.slice(1))
.join('');
/** Set of valid HTML/SVG element names that have motion component wrappers. */
const VALID_TAGS = new Set([
'a',
'abbr',
'address',
'animate',
'animatemotion',
'animatetransform',
'area',
'article',
'aside',
'audio',
'b',
'base',
'bdi',
'bdo',
'blockquote',
'br',
'button',
'canvas',
'caption',
'circle',
'cite',
'clippath',
'code',
'col',
'colgroup',
'cursor',
'data',
'datalist',
'dd',
'defs',
'del',
'desc',
'details',
'dfn',
'dialog',
'div',
'dl',
'dt',
'ellipse',
'em',
'embed',
'feblend',
'fecolormatrix',
'fecomponenttransfer',
'fecomposite',
'feconvolvematrix',
'fediffuselighting',
'fedisplacementmap',
'fedistantlight',
'feflood',
'fefunca',
'fefuncb',
'fefuncg',
'fefuncr',
'fegaussianblur',
'feimage',
'femerge',
'femergenode',
'femorphology',
'feoffset',
'fepointlight',
'fespecularlighting',
'fespotlight',
'fetile',
'feturbulence',
'fieldset',
'figcaption',
'figure',
'filter',
'footer',
'foreignobject',
'form',
'g',
'h1',
'h2',
'h3',
'h4',
'h5',
'h6',
'header',
'hgroup',
'hr',
'i',
'iframe',
'image',
'img',
'input',
'ins',
'kbd',
'label',
'legend',
'li',
'line',
'lineargradient',
'main',
'map',
'mark',
'marker',
'mask',
'math',
'menu',
'metadata',
'meter',
'mpath',
'nav',
'noscript',
'object',
'ol',
'optgroup',
'option',
'output',
'p',
'path',
'pattern',
'picture',
'polygon',
'polyline',
'pre',
'progress',
'q',
'radialgradient',
'rect',
'rp',
'rt',
'ruby',
's',
'samp',
'script',
'search',
'section',
'select',
'selectedcontent',
'set',
'slot',
'small',
'source',
'span',
'stop',
'strong',
'style',
'sub',
'summary',
'sup',
'svg',
'switch',
'symbol',
'table',
'tbody',
'td',
'template',
'text',
'textarea',
'textpath',
'tfoot',
'th',
'thead',
'time',
'title',
'tr',
'track',
'tref',
'tspan',
'u',
'ul',
'use',
'var',
'video',
'view',
'wbr'
]);
/**
* Regex to match import of `motion` from the library.
*
* Handles:
* import { motion } from '@humanspeak/svelte-motion'
* import { motion, animate } from '@humanspeak/svelte-motion'
* import { animate, motion } from '@humanspeak/svelte-motion'
*/
const MOTION_IMPORT_RE = /import\s*\{([^}]*\bmotion\b[^}]*)\}\s*from\s*['"]@humanspeak\/svelte-motion['"]/;
/**
* Regex to find `motion.TAG` usage in templates (opening tags and self-closing).
*
* Matches: `<motion.div`, `<motion.span`, `<motion.circle`, etc.
*/
const MOTION_TEMPLATE_OPEN_RE = /<motion\.([a-z][a-z0-9-]*)/g;
/**
* Regex to find closing tags: `</motion.TAG>`
*/
const MOTION_TEMPLATE_CLOSE_RE = /<\/motion\.([a-z][a-z0-9-]*)\s*>/g;
/**
* Regex to find `motion.TAG` usage in script blocks (JS expressions).
*
* Matches property access like `motion.div` but NOT inside strings or comments.
* Uses a simple word-boundary approach.
*/
const MOTION_SCRIPT_RE = /\bmotion\.([a-z][a-z0-9-]*)\b/g;
/**
* A Vite plugin that optimizes `@humanspeak/svelte-motion` imports for tree-shaking.
*
* Transforms `motion.div`, `motion.span`, etc. into direct component imports,
* eliminating the need to bundle all 170+ HTML/SVG element wrappers.
*
* @example
* ```ts
* // vite.config.ts
* import { svelteMotionOptimize } from '@humanspeak/svelte-motion/vite'
* import { sveltekit } from '@sveltejs/kit/vite'
* import { defineConfig } from 'vite'
*
* export default defineConfig({
* plugins: [svelteMotionOptimize(), sveltekit()]
* })
* ```
*
* Before (all 170+ wrappers bundled):
* ```svelte
* <script>
* import { motion } from '@humanspeak/svelte-motion'
* </script>
* <motion.div animate={{ opacity: 1 }}>Hello</motion.div>
* ```
*
* After (only Div.svelte bundled):
* ```svelte
* <script>
* import SvelteMotionDiv from '@humanspeak/svelte-motion/html/Div.svelte'
* </script>
* <SvelteMotionDiv animate={{ opacity: 1 }}>Hello</SvelteMotionDiv>
* ```
*/
export const svelteMotionOptimize = () => ({
name: 'svelte-motion-optimize',
enforce: 'pre',
transform(code, id) {
// Only process .svelte files that import motion (skip node_modules)
if (!id.endsWith('.svelte'))
return null;
if (id.includes('node_modules'))
return null;
if (!code.includes('@humanspeak/svelte-motion'))
return null;
const importMatch = MOTION_IMPORT_RE.exec(code);
if (!importMatch)
return null;
// Collect all motion.TAG usages (both template and script)
const usedTags = new Set();
// Scan template for <motion.TAG (closing tags always pair with an opening tag)
let match;
MOTION_TEMPLATE_OPEN_RE.lastIndex = 0;
while ((match = MOTION_TEMPLATE_OPEN_RE.exec(code)) !== null) {
if (VALID_TAGS.has(match[1]))
usedTags.add(match[1]);
}
// Scan script blocks for motion.TAG in JS expressions
// Only match inside <script> tags to avoid false positives in comments/text
const scriptBlockRe = /<script[^>]*>([\s\S]*?)<\/script>/g;
let scriptMatch;
while ((scriptMatch = scriptBlockRe.exec(code)) !== null) {
const scriptContent = scriptMatch[1];
MOTION_SCRIPT_RE.lastIndex = 0;
while ((match = MOTION_SCRIPT_RE.exec(scriptContent)) !== null) {
if (VALID_TAGS.has(match[1]))
usedTags.add(match[1]);
}
}
if (usedTags.size === 0)
return null;
let transformed = code;
// Build individual component imports
const componentImports = [];
const tagToLocal = new Map();
for (const tag of usedTags) {
const componentName = toComponentName(tag);
const localName = `SvelteMotion${componentName}`;
tagToLocal.set(tag, localName);
componentImports.push(`import ${localName} from '@humanspeak/svelte-motion/html/${componentName}.svelte'`);
}
// Check if motion is used for anything other than .TAG access
// (e.g., passed as a variable, used in a function call)
const otherImports = importMatch[1]
.split(',')
.map((s) => s.trim())
.filter((s) => s && s !== 'motion');
// Check if motion itself is used beyond .TAG access
// Remove all motion.TAG patterns and see if bare `motion` remains
const codeWithoutTags = transformed
.replace(MOTION_TEMPLATE_OPEN_RE, '')
.replace(MOTION_TEMPLATE_CLOSE_RE, '')
.replace(MOTION_SCRIPT_RE, '');
const motionStillUsed = /\bmotion\b/.test(codeWithoutTags.replace(MOTION_IMPORT_RE, '').replace(/['"].*?['"]/g, ''));
// Replace the import statement
if (motionStillUsed) {
// motion is still used as a value (e.g., passed to a function), keep the original import
// Just add the individual imports alongside
transformed = transformed.replace(MOTION_IMPORT_RE, (original) => `${original}\n${componentImports.join('\n')}`);
}
else if (otherImports.length > 0) {
// Other named imports exist, keep those but remove motion
transformed = transformed.replace(MOTION_IMPORT_RE, `import { ${otherImports.join(', ')} } from '@humanspeak/svelte-motion'\n${componentImports.join('\n')}`);
}
else {
// Only motion was imported, replace entirely
transformed = transformed.replace(MOTION_IMPORT_RE, componentImports.join('\n'));
}
// Replace template usage: <motion.TAG → <SvelteMotionTag, </motion.TAG> → </SvelteMotionTag>
for (const [tag, localName] of tagToLocal) {
const openRe = new RegExp(`<motion\\.${escapeRegExp(tag)}(?=[\\s/>])`, 'g');
const closeRe = new RegExp(`</motion\\.${escapeRegExp(tag)}\\s*>`, 'g');
transformed = transformed.replace(openRe, `<${localName}`);
transformed = transformed.replace(closeRe, `</${localName}>`);
}
// Rewrite `motion.TAG` JS references (e.g. `const Component = motion.div`)
// inside <script> blocks only. A naive regex over the script body would
// also clobber the same substring in string literals (`"motion.div"`)
// and comments (`// motion.div`). Parse the script as JS instead and
// only rewrite real `motion.<tag>` MemberExpressions. For scripts that
// fail to parse as plain JS (e.g. `<script lang="ts">`), fall back to a
// string/comment-aware lexer that achieves the same correctness without
// needing a TS parser.
transformed = transformed.replace(/(<script\b[^>]*>)([\s\S]*?)(<\/script>)/g, (_full, open, content, close) => open + rewriteMotionRefsInScript(content, tagToLocal) + close);
return {
code: transformed,
map: null
};
}
});
/**
* Escapes special regex characters in a string.
*
* @param str - The string to escape.
* @returns The escaped string safe for use in a RegExp.
*/
const escapeRegExp = (str) => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
/**
* Rewrite `motion.<tag>` member-expression references inside a `<script>`
* body to the matching `SvelteMotionTag` local. Preserves string literals
* and comments — they look like `motion.div` to a regex but must not be
* rewritten.
*
* Strategy: parse the body as JS with acorn and splice only real
* MemberExpression matches. If parsing fails (TypeScript, JSX, etc.) fall
* back to a string/comment-aware lexer that skips literals and comments.
*
* @param content - Raw script body (between `<script ...>` and `</script>`).
* @param tagToLocal - Map of lowercase tag → local component identifier.
* @returns The rewritten body, ready to splice back into the source.
*/
const rewriteMotionRefsInScript = (content, tagToLocal) => {
try {
return rewriteViaAst(content, tagToLocal);
}
catch {
return rewriteViaLexer(content, tagToLocal);
}
};
const isIdentifier = (n) => !!n && n.type === 'Identifier';
const isMemberExpression = (n) => !!n && n.type === 'MemberExpression';
/**
* Walk an acorn AST and collect every `motion.<tag>` MemberExpression range
* we should rewrite. Splice from end to start so earlier indices stay valid.
*/
const rewriteViaAst = (content, tagToLocal) => {
const ast = Parser.parse(content, {
ecmaVersion: 'latest',
sourceType: 'module',
allowImportExportEverywhere: true,
allowReturnOutsideFunction: true,
allowAwaitOutsideFunction: true,
allowHashBang: true
});
const edits = [];
const visit = (node) => {
if (!node || typeof node !== 'object' || typeof node.type !== 'string')
return;
if (isMemberExpression(node) &&
!node.computed &&
isIdentifier(node.object) &&
node.object.name === 'motion' &&
isIdentifier(node.property)) {
const localName = tagToLocal.get(node.property.name);
if (localName) {
edits.push({ start: node.start, end: node.end, replacement: localName });
return;
}
}
for (const key of Object.keys(node)) {
if (key === 'type' || key === 'start' || key === 'end' || key === 'loc')
continue;
const value = node[key];
if (Array.isArray(value))
value.forEach((v) => visit(v));
else if (value && typeof value === 'object')
visit(value);
}
};
visit(ast);
if (edits.length === 0)
return content;
edits.sort((a, b) => b.start - a.start);
let out = content;
for (const edit of edits) {
out = out.slice(0, edit.start) + edit.replacement + out.slice(edit.end);
}
return out;
};
/**
* Fallback for scripts acorn can't parse (TS, JSX). Walks the source
* character-by-character, skipping string literals (`'`, `"`, backtick incl.
* `${…}` substitutions) and line/block comments, then applies a `motion.<tag>`
* regex to the remaining "code" regions. Less precise than AST but covers
* the same correctness contract for literal/comment preservation.
*/
const rewriteViaLexer = (content, tagToLocal) => {
const len = content.length;
const out = [];
let i = 0;
const isIdStart = (ch) => /[A-Za-z_$]/.test(ch);
const isIdPart = (ch) => /[A-Za-z0-9_$-]/.test(ch);
while (i < len) {
const ch = content[i];
const next = content[i + 1];
// Line comment
if (ch === '/' && next === '/') {
const end = content.indexOf('\n', i);
const stop = end === -1 ? len : end;
out.push(content.slice(i, stop));
i = stop;
continue;
}
// Block comment
if (ch === '/' && next === '*') {
const end = content.indexOf('*/', i + 2);
const stop = end === -1 ? len : end + 2;
out.push(content.slice(i, stop));
i = stop;
continue;
}
// String literals (single/double)
if (ch === '"' || ch === "'") {
const quote = ch;
let j = i + 1;
while (j < len) {
if (content[j] === '\\') {
j += 2;
continue;
}
if (content[j] === quote) {
j++;
break;
}
j++;
}
out.push(content.slice(i, j));
i = j;
continue;
}
// Template literal — naive: skip to matching backtick, no `${…}` parsing
// is needed for our use case (we only need to NOT rewrite the literal
// text; substitutions still look like code but `motion.<tag>` inside
// a template substitution is vanishingly rare and acorn would normally
// handle it).
if (ch === '`') {
let j = i + 1;
while (j < len) {
if (content[j] === '\\') {
j += 2;
continue;
}
if (content[j] === '`') {
j++;
break;
}
j++;
}
out.push(content.slice(i, j));
i = j;
continue;
}
// Possible `motion.<tag>` identifier — require word boundary on left
if ((i === 0 || !isIdPart(content[i - 1])) &&
isIdStart(ch) &&
content.slice(i, i + 7) === 'motion.') {
let j = i + 7;
const tagStart = j;
while (j < len && isIdPart(content[j]))
j++;
const tag = content.slice(tagStart, j);
const localName = tagToLocal.get(tag);
if (localName && (j === len || !isIdPart(content[j]))) {
out.push(localName);
i = j;
continue;
}
}
out.push(ch);
i++;
}
return out.join('');
};