@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
329 lines (328 loc) • 11.3 kB
JavaScript
import { buildSVGPath } from 'motion-dom';
/**
* SVG-specific properties that need special handling during animation.
* These properties are not standard CSS properties and need to be transformed.
*/
export const SVG_PATH_PROPERTIES = new Set(['pathLength', 'pathOffset', 'pathSpacing']);
/**
* The SVG namespace URI.
*/
export const SVG_NAMESPACE = 'http://www.w3.org/2000/svg';
/**
* Set of SVG tag names that should be created in the SVG namespace.
* This list covers all standard SVG elements.
*/
export const SVG_TAGS = new Set([
'svg',
'animate',
'animatemotion',
'animatetransform',
'circle',
'clippath',
'defs',
'desc',
'ellipse',
'feblend',
'fecolormatrix',
'fecomponenttransfer',
'fecomposite',
'feconvolvematrix',
'fediffuselighting',
'fedisplacementmap',
'fedistantlight',
'fedropshadow',
'feflood',
'fefunca',
'fefuncb',
'fefuncg',
'fefuncr',
'fegaussianblur',
'feimage',
'femerge',
'femergenode',
'femorphology',
'feoffset',
'fepointlight',
'fespecularlighting',
'fespotlight',
'fetile',
'feturbulence',
'filter',
'foreignobject',
'g',
'image',
'line',
'lineargradient',
'marker',
'mask',
'metadata',
'mpath',
'path',
'pattern',
'polygon',
'polyline',
'radialgradient',
'rect',
'set',
'stop',
'switch',
'symbol',
'text',
'textpath',
'title',
'tref',
'tspan',
'use',
'view'
]);
/**
* Determines whether the provided tag name is an SVG element tag.
*
* @param {string} tag The tag name to test.
* @returns {boolean} True when the tag is an SVG element.
* @example
* isSVGTag('path') // true
* isSVGTag('div') // false
*/
export const isSVGTag = (tag) => {
return SVG_TAGS.has(tag.toLowerCase());
};
/**
* Check if an element is an SVG path element.
*/
/**
* Determines whether the provided element is an SVGPathElement.
*
* @param {Element} element The candidate element to test.
* @returns {element is SVGPathElement} True when the element is an SVG path.
* @example
* const el = document.createElementNS('http://www.w3.org/2000/svg', 'path')
* if (isSVGPathElement(el)) {
* // el is now typed as SVGPathElement
* }
*/
export const isSVGPathElement = (element) => {
if (typeof SVGPathElement !== 'undefined') {
return element instanceof SVGPathElement;
}
const nsOk = element.namespaceURI === 'http://www.w3.org/2000/svg';
const tagOk = element.tagName?.toLowerCase() === 'path';
return !!(nsOk && tagOk);
};
/**
* Check if an element is any SVG element.
*/
/**
* Determines whether the provided element is an SVGElement.
*
* @param {Element} element The candidate element to test.
* @returns {element is SVGElement} True when the element is an SVG element.
* @example
* const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')
* if (isSVGElement(svg)) {
* // svg is now typed as SVGElement
* }
*/
export const isSVGElement = (element) => {
if (typeof SVGElement === 'undefined') {
return false;
}
return element instanceof SVGElement;
};
/**
* Transform SVG path-specific animation properties to their CSS equivalents.
*
* Motion's pathLength property creates a line-drawing effect by manipulating
* strokeDasharray and strokeDashoffset. This function transforms:
* - pathLength: 0 -> 1 becomes strokeDasharray: "0 1" -> "1 1"
* - pathOffset: value becomes strokeDashoffset: -value (inverted for drawing direction)
*
* @param element - The SVG element being animated
* @param keyframes - The animation keyframes that may contain SVG properties
* @returns Transformed keyframes with CSS-compatible properties
*/
/**
* Transforms SVG path-specific animation properties into DOM-compatible attributes.
*
* Normalized behavior (React/Framer Motion parity):
* - Ensures `pathLength="1"` is set when any path prop is present
* - Maps `pathLength`/`pathSpacing` → unitless `stroke-dasharray`
* - Maps `pathOffset` → unitless negative `stroke-dashoffset`
*
* @param {Element} element The element being animated (must be an SVG path).
* @param {Record<string, unknown>} keyframes The input keyframes possibly containing path props.
* @returns {Record<string, unknown>} A transformed keyframe object safe for animation.
*/
export const transformSVGPathProperties = (element, keyframes) => {
if (!isSVGPathElement(element)) {
return keyframes;
}
// logging removed
const transformed = { ...keyframes };
// let hasPathLength = false
// Transform normalized path props to dash attributes using pathLength="1" semantics
if ('pathLength' in transformed ||
'pathSpacing' in transformed ||
'pathOffset' in transformed) {
try {
element.setAttribute('pathLength', '1');
}
catch {
void 0;
}
const toNum = (v) => typeof v === 'number'
? v
: v != null && /^-?\d+(\.\d+)?(px)?$/i.test(String(v).trim())
? parseFloat(String(v))
: undefined;
const length = (() => {
const v = transformed.pathLength;
const n = toNum(v);
return n ?? v;
})();
const spacing = (() => {
const v = transformed.pathSpacing;
const n = toNum(v);
return n ?? v;
})();
const offset = (() => {
const v = transformed.pathOffset;
const n = toNum(v);
return n ?? v;
})();
const toUnitless = (v) => (typeof v === 'number' ? `${v}` : String(v));
const buildDashArray = (len, spa) => `${toUnitless(len)} ${toUnitless(spa)}`;
// stroke-dasharray from pathLength/pathSpacing with upstream's default spacing = 1
if (Array.isArray(length)) {
const lenArr = length;
const spaArr = Array.isArray(spacing)
? spacing
: lenArr.map(() => (spacing !== undefined ? spacing : 1));
const dashArray = lenArr.map((lv, i) => buildDashArray(lv, spaArr[i]));
transformed.strokeDasharray = dashArray;
transformed['stroke-dasharray'] = dashArray;
}
else if (length !== undefined) {
const lenNum = toNum(length);
const spaNum = spacing === undefined ? undefined : toNum(spacing);
const offsetNum = offset === undefined ? undefined : toNum(offset);
let dashArray;
let dashOffset;
if (lenNum !== undefined &&
(spacing === undefined || spaNum !== undefined) &&
(offset === undefined || offsetNum !== undefined)) {
const attrs = {};
buildSVGPath(attrs, lenNum, spacing === undefined ? 1 : spaNum, offsetNum, true);
dashArray = String(attrs['stroke-dasharray']);
dashOffset = String(attrs['stroke-dashoffset']);
}
else {
const spa = spacing !== undefined ? spacing : 1;
dashArray = buildDashArray(length, spa);
}
;
transformed.strokeDasharray = dashArray;
transformed['stroke-dasharray'] = dashArray;
if (dashOffset !== undefined) {
;
transformed.strokeDashoffset = dashOffset;
transformed['stroke-dashoffset'] = dashOffset;
}
}
// stroke-dashoffset from -pathOffset
if (Array.isArray(offset)) {
const offs = offset.map((ov) => {
const n = toNum(ov);
return n !== undefined ? `${-n}` : String(ov);
});
transformed.strokeDashoffset = offs;
transformed['stroke-dashoffset'] = offs;
}
else if (offset !== undefined) {
const n = toNum(offset);
const off = n !== undefined ? `${-n}` : String(offset);
transformed.strokeDashoffset = off;
transformed['stroke-dashoffset'] = off;
}
else if (!('stroke-dashoffset' in transformed)) {
// default 0
;
transformed.strokeDashoffset = '0';
transformed['stroke-dashoffset'] = '0';
}
delete transformed.pathLength;
delete transformed.pathSpacing;
delete transformed.pathOffset;
}
// logging removed
return transformed;
};
/**
* Check if any keyframes contain SVG path properties.
*/
/**
* Checks if any SVG path-related properties are present in the keyframes object.
*
* @param {Record<string, unknown>} keyframes The keyframes to inspect.
* @returns {boolean} True if any of `pathLength`, `pathSpacing`, or `pathOffset` are present.
*/
export const hasSVGPathProperties = (keyframes) => {
return Object.keys(keyframes).some((key) => SVG_PATH_PROPERTIES.has(key));
};
/**
* Transform initial SVG path properties for initial state setup.
* This ensures that the initial state also has the proper strokeDasharray values.
*/
/**
* Transforms initial keyframes for SVG paths so that the initial state uses
* normalized dash attributes.
*
* @param {Element} element The element being animated (must be an SVG path).
* @param {Record<string, unknown> | undefined} initial Initial keyframes, if provided.
* @returns {Record<string, unknown> | undefined} Transformed initial keyframes or the original value.
*/
export const transformInitialSVGPathProperties = (element, initial) => {
if (!initial || !isSVGPathElement(element)) {
return initial;
}
// logging removed
return transformSVGPathProperties(element, initial);
};
/**
* Computes normalized SVG path attributes for initial render without requiring an element.
*
* Behavior matches React/Framer Motion parity:
* - Always sets pathLength="1" whenever any of path props are present
* - stroke-dasharray = pathLength + ' ' + (pathSpacing ?? 1)
* - stroke-dashoffset = -(pathOffset ?? 0)
*
* The returned object is suitable for direct DOM attribute assignment (dash-cased keys).
*
* @param {Record<string, unknown> | null | undefined} initial Incoming initial keyframes object
* @returns {Record<string, string> | null} Normalized attribute map or null if no path props
*/
export const computeNormalizedSVGInitialAttrs = (initial) => {
if (!initial)
return null;
const hasAny = 'pathLength' in initial || 'pathSpacing' in initial || 'pathOffset' in initial;
if (!hasAny)
return null;
const toUnitless = (v) => (typeof v === 'number' ? `${v}` : String(v));
const negate = (v) => {
if (typeof v === 'number')
return `${-v}`;
const s = String(v);
return s.startsWith('-') ? s : /^[\d.]+(px)?$/i.test(s) ? `-${s.replace(/px$/i, '')}` : s;
};
const len = initial.pathLength ?? 0;
const spa = initial.pathSpacing ?? 1;
const off = initial.pathOffset ?? 0;
const dashArray = `${toUnitless(len)} ${toUnitless(spa)}`;
const dashOffset = negate(off);
// logging removed
return {
pathLength: '1',
'stroke-dasharray': dashArray,
'stroke-dashoffset': dashOffset
};
};