diff --git a/playgrounds/vite/src/App.vue b/playgrounds/vite/src/App.vue
index 613f890c..f2d78495 100644
--- a/playgrounds/vite/src/App.vue
+++ b/playgrounds/vite/src/App.vue
@@ -3,6 +3,7 @@ import Delay from './demos/Delay.vue'
import Editor from './demos/Editor.vue'
import Transitions from './demos/Transitions.vue'
import Sandbox from './demos/Sandbox.vue'
+import SVGPath from './demos/SVGPath.vue'
const sandbox = false
@@ -33,6 +34,12 @@ const sandbox = false
+
+
+ SVGPath
+
+
+
diff --git a/playgrounds/vite/src/demos/SVGPath.vue b/playgrounds/vite/src/demos/SVGPath.vue
new file mode 100644
index 00000000..67560dcd
--- /dev/null
+++ b/playgrounds/vite/src/demos/SVGPath.vue
@@ -0,0 +1,133 @@
+
+
+
+
+
+
+
+
+
diff --git a/playgrounds/vite/src/examples/svgpath.ts b/playgrounds/vite/src/examples/svgpath.ts
new file mode 100644
index 00000000..119a1169
--- /dev/null
+++ b/playgrounds/vite/src/examples/svgpath.ts
@@ -0,0 +1,132 @@
+export default () => `
+
+
+
+
+
+
+
+
+
+`
diff --git a/src/reactiveStyle.ts b/src/reactiveStyle.ts
index 03488a1c..bca1c21c 100644
--- a/src/reactiveStyle.ts
+++ b/src/reactiveStyle.ts
@@ -1,27 +1,26 @@
import type { Ref } from 'vue'
import { reactive, ref, watch } from 'vue'
-import type { StyleProperties } from './types'
+import type { SVGPathProperties, StyleProperties } from './types'
import { getValueAsType, getValueType } from './utils/style'
-
/**
* Reactive style object implementing all native CSS properties.
*
* @param props
*/
-export function reactiveStyle(props: StyleProperties = {}) {
+export function reactiveStyle(props: StyleProperties | SVGPathProperties = {}) {
// Reactive StyleProperties object
- const state = reactive({
+ const state = reactive({
...props,
})
- const style = ref({}) as Ref
+ const style = ref({}) as Ref
// Reactive DOM Element compatible `style` object bound to state
watch(
state,
() => {
// Init result object
- const result: StyleProperties = {}
+ const result: StyleProperties | SVGPathProperties = {}
for (const [key, value] of Object.entries(state)) {
// Get value type for key
diff --git a/src/useElementStyle.ts b/src/useElementStyle.ts
index 6168b028..95336ba1 100644
--- a/src/useElementStyle.ts
+++ b/src/useElementStyle.ts
@@ -1,9 +1,9 @@
import type { MaybeRef } from '@vueuse/core'
import { watch } from 'vue'
import { reactiveStyle } from './reactiveStyle'
-import type { MotionTarget, PermissiveTarget, StyleProperties } from './types'
+import type { MotionTarget, PermissiveTarget, SVGPathProperties, StyleProperties } from './types'
import { usePermissiveTarget } from './usePermissiveTarget'
-import { valueTypes } from './utils/style'
+import { getSVGPath, isSVGElement, isSVGPathProp, setSVGPath, valueTypes } from './utils/style'
import { isTransformOriginProp, isTransformProp } from './utils/transform'
/**
@@ -13,7 +13,7 @@ import { isTransformOriginProp, isTransformProp } from './utils/transform'
*/
export function useElementStyle(target: MaybeRef, onInit?: (initData: Partial) => void) {
// Transform cache available before the element is mounted
- let _cache: StyleProperties | undefined
+ let _cache: StyleProperties | SVGPathProperties | undefined
// Local target cache as we need to resolve the element from PermissiveTarget
let _target: MotionTarget
// Create a reactive style object
@@ -22,10 +22,19 @@ export function useElementStyle(target: MaybeRef, onInit?: (in
usePermissiveTarget(target, (el) => {
_target = el
+ if (isSVGElement(_target)) {
+ const { pathLength, pathSpacing, pathOffset } = getSVGPath(_target as SVGElement)
+ if (pathLength !== undefined) {
+ (state as SVGPathProperties).pathLength = pathLength;
+ (state as SVGPathProperties).pathSpacing = pathSpacing;
+ (state as SVGPathProperties).pathOffset = pathOffset
+ }
+ }
+
// Loop on style keys
for (const key of Object.keys(valueTypes)) {
// @ts-expect-error - Fix errors later for typescript 5
- if (el.style[key] === null || el.style[key] === '' || isTransformProp(key) || isTransformOriginProp(key))
+ if (el.style[key] === null || el.style[key] === '' || isTransformProp(key) || isTransformOriginProp(key) || isSVGPathProp(key))
continue
// Append a defined key to the local StyleProperties state object
@@ -35,6 +44,13 @@ export function useElementStyle(target: MaybeRef, onInit?: (in
// If cache is present, init the target with the current cached value
if (_cache) {
+ if (isSVGElement(_target)) {
+ const { pathLength, pathOffset, pathSpacing } = _cache as SVGPathProperties
+ if (pathLength !== undefined) {
+ setSVGPath((_target as SVGElement), pathLength, pathSpacing, pathOffset)
+ }
+ }
+
// @ts-expect-error - Fix errors later for typescript 5
Object.entries(_cache).forEach(([key, value]) => (el.style[key] = value))
}
@@ -53,6 +69,13 @@ export function useElementStyle(target: MaybeRef, onInit?: (in
return
}
+ if (isSVGElement(_target)) {
+ const { pathLength, pathOffset, pathSpacing } = newVal as SVGPathProperties
+ if (pathLength !== undefined) {
+ setSVGPath((_target as SVGElement), pathLength, pathSpacing, pathOffset)
+ }
+ }
+
// Append the state object to the target style properties
// @ts-expect-error - Fix errors later for typescript 5
for (const key in newVal) _target.style[key] = newVal[key]
diff --git a/src/utils/component.ts b/src/utils/component.ts
index f447bc06..a70f25e7 100644
--- a/src/utils/component.ts
+++ b/src/utils/component.ts
@@ -15,6 +15,7 @@ import * as presets from '../presets'
import type { MotionInstance } from '../types/instance'
import type {
MotionVariants,
+ SVGPathProperties,
StyleProperties,
Variant,
} from '../types/variants'
@@ -228,7 +229,7 @@ export function setupMotionComponent(
}
// Set node style and register to `instances` on mount
- function setNodeInstance(node: VNode, index: number, style: StyleProperties) {
+ function setNodeInstance(node: VNode, index: number, style: StyleProperties | SVGPathProperties) {
node.props ??= {}
node.props.style ??= {}
diff --git a/src/utils/style.ts b/src/utils/style.ts
index db036b44..20ee0d71 100644
--- a/src/utils/style.ts
+++ b/src/utils/style.ts
@@ -103,6 +103,11 @@ export const valueTypes: ValueTypeMap = {
fillOpacity: alpha,
strokeOpacity: alpha,
numOctaves: int,
+
+ // custom SVG properties
+ pathLength: auto,
+ pathOffset: auto,
+ pathSpacing: auto,
}
/**
@@ -135,3 +140,63 @@ export function getAnimatableNone(key: string, value: string): any {
// If value is not recognised as animatable, ie "none", create an animatable version origin based on the target
return defaultValueType.getAnimatableNone ? defaultValueType.getAnimatableNone(value) : undefined
}
+
+/**
+ * A quick lookup for custom SVG props.
+ */
+const SVGPathProps = new Set(['pathLength', 'pathOffset', 'pathSpacing'])
+export function isSVGPathProp(key: string): boolean {
+ return SVGPathProps.has(key)
+}
+
+/**
+ * Determine whether it is an svg element
+ * @param target
+ */
+export function isSVGElement(target: HTMLElement | SVGElement): boolean {
+ return !!((target as SVGElement)?.ownerSVGElement || target.tagName.toLowerCase() === 'svg')
+}
+
+/**
+ * Build SVG path properties from custom properties
+ * pathLength always normalize to 1
+ * pathOffset to stroke-dashoffset
+ * pathLength and pathSpacing to stroke-dasharray
+ *
+ * pathLength: https://developer.mozilla.org/en-US/docs/Web/SVG/Attribute/pathLength
+ * the path is always normalize to 1, so you need deal with other paths by yourself
+ * base the total length is 1.
+ * for example stroke-dasharray will assume the start of the path being 0 and
+ * the end point the value defined in the pathLength attribute as 1.
+ *
+ * @param target
+ * @param length
+ * @param spacing
+ * @param offset
+ */
+export function setSVGPath(target: SVGElement, length: number, spacing = 1, offset = 0) {
+ target.setAttribute('pathLength', '1') // normalize to 1
+ target.setAttribute('stroke-dashoffset', `${offset}`)
+ target.setAttribute('stroke-dasharray', `${length} ${spacing}`)
+}
+
+/**
+ * Get pathLength pathSpacing pathOffset from svg element
+ * Convert stroke-dashoffset stroke-dasharray to custome properties
+ * @param target
+ */
+export function getSVGPath(target: SVGElement) {
+ // pathLength is normalize to 1
+ const pathLength = target.getAttribute('pathLength') ? 1 : undefined
+ const pathOffset = target.getAttribute('stroke-dashoffset') ? Number.parseFloat(target.getAttribute('stroke-dashoffset')!) : undefined
+ // TODO need to support odd?
+ // sinle: dashes and gaps are same size
+ // two: dashes and gaps are different sizes
+ // odd: dashes and gaps of various sizes with an odd number of values, [4,1,2] is equivalent to [4,1,2,4,1,2]
+ const pathSpacing = target.getAttribute('stroke-dasharray') ? Number.parseFloat(target.getAttribute('stroke-dasharray')!.split(' ')[1]!) : undefined
+ return {
+ pathLength,
+ pathSpacing,
+ pathOffset,
+ }
+}
diff --git a/tests/components.spec.ts b/tests/components.spec.ts
index 1548a169..c0d30493 100644
--- a/tests/components.spec.ts
+++ b/tests/components.spec.ts
@@ -4,7 +4,12 @@ import { h, nextTick, ref } from 'vue'
import { MotionComponent, MotionPlugin } from '../src'
import MotionGroup from '../src/components/MotionGroup'
import { intersect } from './utils/intersectionObserver'
-import { getTestComponent, useCompletionFn, waitForMockCalls } from './utils'
+import {
+ getTestComponent,
+ getTestComponentSVG,
+ useCompletionFn,
+ waitForMockCalls,
+} from './utils'
// Register plugin
config.global.plugins.push([
@@ -236,3 +241,143 @@ describe('`` component', async () => {
).toEqual('0')
})
})
+
+/**
+ * tests for svg path
+ * pathLegnth
+ * pathSpacing
+ * pathOffset
+ */
+describe.each([
+ { t: 'dirctive', name: 'v-motion svg element directive' },
+ { t: 'component', name: ' svg element directive' },
+])(`$name`, async ({ t }) => {
+ const TestComponentSVG = getTestComponentSVG(t)
+
+ it('svg variants number', async () => {
+ const onComplete = useCompletionFn()
+
+ const wrapper = mount(TestComponentSVG, {
+ props: {
+ initial: {
+ pathLength: 1,
+ pathSpacing: 1,
+ pathOffset: 2,
+ },
+ enter: {
+ pathLength: 2,
+ pathSpacing: 2,
+ pathOffset: 3,
+ transition: { onComplete },
+ },
+ duration: 10,
+ },
+ })
+
+ const el = wrapper.element as SVGElement
+ await nextTick()
+
+ // Renders initial variant
+ expect(el.style.pathLength).toEqual(1)
+ expect(el.style.pathSpacing).toEqual(1)
+ expect(el.style.pathOffset).toEqual(2)
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('2')
+ expect(el.getAttribute('stroke-dasharray')).toBe('1 1')
+
+ await waitForMockCalls(onComplete)
+
+ // Renders enter variant
+ expect(el.style.pathLength).toEqual(2)
+ expect(el.style.pathSpacing).toEqual(2)
+ expect(el.style.pathOffset).toEqual(3)
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('3')
+ expect(el.getAttribute('stroke-dasharray')).toBe('2 2')
+ })
+
+ it('svg variants px', async () => {
+ const onComplete = useCompletionFn()
+
+ const wrapper = mount(TestComponentSVG, {
+ props: {
+ initial: {
+ pathLength: '1px',
+ pathSpacing: '1px',
+ pathOffset: '2px',
+ },
+ enter: {
+ pathLength: '2px',
+ pathSpacing: '2px',
+ pathOffset: '3px',
+ transition: { onComplete },
+ },
+ duration: 10,
+ },
+ })
+
+ const el = wrapper.element as SVGElement
+ await nextTick()
+
+ // Renders initial variant
+ expect(el.style.pathLength).toEqual('1px')
+ expect(el.style.pathSpacing).toEqual('1px')
+ expect(el.style.pathOffset).toEqual('2px')
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('2px')
+ expect(el.getAttribute('stroke-dasharray')).toBe('1px 1px')
+
+ await waitForMockCalls(onComplete)
+
+ // Renders enter variant
+ expect(el.style.pathLength).toEqual('2px')
+ expect(el.style.pathSpacing).toEqual('2px')
+ expect(el.style.pathOffset).toEqual('3px')
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('3px')
+ expect(el.getAttribute('stroke-dasharray')).toBe('2px 2px')
+ })
+
+ it('svg event variants', async () => {
+ const onComplete = useCompletionFn()
+
+ const wrapper = mount(TestComponentSVG, {
+ props: {
+ initial: {
+ pathLength: 1,
+ pathSpacing: 1,
+ pathOffset: 2,
+ },
+ hovered: {
+ pathLength: 2,
+ pathSpacing: 2,
+ pathOffset: 4,
+ transition: { onComplete },
+ },
+ duration: 10,
+ },
+ })
+
+ const el = wrapper.element as SVGElement
+ await nextTick()
+
+ // Renders initial variant
+ expect(el.style.pathLength).toEqual(1)
+ expect(el.style.pathSpacing).toEqual(1)
+ expect(el.style.pathOffset).toEqual(2)
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('2')
+ expect(el.getAttribute('stroke-dasharray')).toBe('1 1')
+
+ await wrapper.trigger('mouseenter')
+ await waitForMockCalls(onComplete)
+
+ // Renders enter variant
+ expect(el.style.pathLength).toEqual(2)
+ expect(el.style.pathSpacing).toEqual(2)
+ expect(el.style.pathOffset).toEqual(4)
+ expect(el.getAttribute('pathLength')).toBe('1')
+ expect(el.getAttribute('stroke-dashoffset')).toBe('4')
+ expect(el.getAttribute('stroke-dasharray')).toBe('2 2')
+ })
+})
diff --git a/tests/useElementStyle.spec.ts b/tests/useElementStyle.spec.ts
index 4ae78cd0..244250de 100644
--- a/tests/useElementStyle.spec.ts
+++ b/tests/useElementStyle.spec.ts
@@ -44,3 +44,109 @@ describe('useElementStyle', () => {
expect(element.value.style.backgroundColor).toBe('blue')
})
})
+
+/**
+ * tests for svg path
+ * pathLegnth
+ * pathSpacing
+ * pathOffset
+ */
+const TestComponentSVG = {
+ template: `
+`,
+}
+
+function getSVGElementRef() {
+ const c = mount(TestComponentSVG)
+
+ return ref(c.element as SVGElement)
+}
+
+describe('useElementStyle Test SVGPath', () => {
+ it('accepts an svg element', () => {
+ const element = getSVGElementRef()
+
+ const { style } = useElementStyle(element)
+
+ expect(style).toBeDefined()
+ })
+
+ it('mutates pathLength', async () => {
+ const element = getSVGElementRef()
+ const { style } = useElementStyle(element)
+ style.pathLength = 2
+
+ const line = element.value.querySelector('line')
+ const { style: lineStyle } = useElementStyle(line)
+ lineStyle.pathLength = 3
+
+ await nextTick()
+
+ // svg
+ expect(element.value.getAttribute('pathLength')).toBe('1')
+ expect(element.value.getAttribute('stroke-dashoffset')).toBe('0')
+ expect(element.value.getAttribute('stroke-dasharray')).toBe('2 1')
+
+ // line
+ expect(line.getAttribute('pathLength')).toBe('1')
+ expect(line.getAttribute('stroke-dashoffset')).toBe('0')
+ expect(line.getAttribute('stroke-dasharray')).toBe('3 1')
+
+ // number
+ lineStyle.pathLength = 3
+ lineStyle.pathOffset = 3
+ lineStyle.pathSpacing = 3
+
+ await nextTick()
+
+ expect(line.getAttribute('pathLength')).toBe('1')
+ expect(line.getAttribute('stroke-dashoffset')).toBe('3')
+ expect(line.getAttribute('stroke-dasharray')).toBe('3 3')
+
+ // string
+ lineStyle.pathLength = '3'
+ lineStyle.pathOffset = '3'
+ lineStyle.pathSpacing = '2'
+
+ await nextTick()
+
+ expect(line.getAttribute('pathLength')).toBe('1')
+ expect(line.getAttribute('stroke-dashoffset')).toBe('3')
+ expect(line.getAttribute('stroke-dasharray')).toBe('3 2')
+
+ // px
+ lineStyle.pathLength = '3px'
+ lineStyle.pathOffset = '3px'
+ lineStyle.pathSpacing = '2px'
+
+ await nextTick()
+
+ expect(line.getAttribute('pathLength')).toBe('1')
+ expect(line.getAttribute('stroke-dashoffset')).toBe('3px')
+ expect(line.getAttribute('stroke-dasharray')).toBe('3px 2px')
+
+ // percentage
+ lineStyle.pathLength = '3%'
+ lineStyle.pathOffset = '3%'
+ lineStyle.pathSpacing = '2%'
+
+ await nextTick()
+
+ expect(line.getAttribute('pathLength')).toBe('1')
+ expect(line.getAttribute('stroke-dashoffset')).toBe('3%')
+ expect(line.getAttribute('stroke-dasharray')).toBe('3% 2%')
+ })
+})
diff --git a/tests/utils/index.ts b/tests/utils/index.ts
index 5f28b1d3..f6bd9731 100644
--- a/tests/utils/index.ts
+++ b/tests/utils/index.ts
@@ -33,3 +33,31 @@ export async function waitForMockCalls(fn: Mock, calls = 1, options: Parameters<
throw err
}
}
+
+export function getTestComponentSVG(t: string) {
+ if (t === 'directive') {
+ return {
+ template: `
+
+`,
+ }
+ }
+ return {
+ render: () => h(MotionComponent, {
+ is: 'svg',
+ }),
+ }
+}