Created
January 15, 2026 13:43
-
-
Save imliam/6f7de6224ae9cd8bea43936ff554bb93 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| @props([ | |
| 'viewBox' => null, | |
| 'trigger' => 'hover', | |
| 'duration' => 300, | |
| 'easing' => 'easeInOut', | |
| 'event' => 'morph-icon', | |
| 'initial' => 0, | |
| ]) | |
| <div | |
| x-data="svgMorph({ | |
| trigger: @js($trigger), | |
| duration: @js($duration), | |
| easing: @js($easing), | |
| viewBox: @js($viewBox), | |
| initial: @js($initial), | |
| })" | |
| x-on:mouseenter="handleHoverEnter()" | |
| x-on:mouseleave="handleHoverLeave()" | |
| x-on:click="handleClick()" | |
| x-on:{{ $event }}.window="morph($event.detail?.index ?? (currentIndex === 0 ? 1 : 0))" | |
| {{ $attributes->class(['inline-flex']) }} | |
| > | |
| <div x-ref="icons" class="hidden">{{ $slot }}</div> | |
| <svg | |
| x-ref="svg" | |
| x-cloak | |
| x-show="currentPaths.length > 0" | |
| viewBox="0 0 24 24" | |
| fill="currentColor" | |
| aria-hidden="true" | |
| {{ $attributes->only(['class']) }} | |
| ></svg> | |
| </div> |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { interpolate, interpolateAll } from 'flubber'; | |
| declare global { | |
| interface Window { | |
| Alpine: typeof import('alpinejs').default; | |
| } | |
| } | |
| type EasingFunction = (t: number) => number; | |
| interface PathState { | |
| paths: string[]; | |
| viewBox: string | null; | |
| } | |
| interface MorphConfig { | |
| trigger: 'hover' | 'click' | 'event'; | |
| duration: number; | |
| easing: string; | |
| viewBox: string | null; | |
| initial: number | 'dark-mode'; | |
| } | |
| const easingFunctions: Record<string, EasingFunction> = { | |
| linear: (t) => t, | |
| easeIn: (t) => t * t * t, | |
| easeOut: (t) => 1 - Math.pow(1 - t, 3), | |
| easeInOut: (t) => (t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2), | |
| easeInQuad: (t) => t * t, | |
| easeOutQuad: (t) => 1 - (1 - t) * (1 - t), | |
| easeInOutQuad: (t) => (t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2), | |
| easeInBack: (t) => 2.70158 * t * t * t - 1.70158 * t * t, | |
| easeOutBack: (t) => 1 + 2.70158 * Math.pow(t - 1, 3) + 1.70158 * Math.pow(t - 1, 2), | |
| }; | |
| function getEasing(name: string): EasingFunction { | |
| return easingFunctions[name] ?? easingFunctions.easeInOut; | |
| } | |
| function splitSubpaths(d: string): string[] { | |
| const subpaths: string[] = []; | |
| const regex = /M[^M]*/gi; | |
| let match; | |
| while ((match = regex.exec(d)) !== null) { | |
| const subpath = match[0].trim(); | |
| if (subpath) { | |
| subpaths.push(subpath); | |
| } | |
| } | |
| return subpaths.length > 0 ? subpaths : [d]; | |
| } | |
| function extractPathData(svgElement: Element): PathState { | |
| const pathElements = Array.from(svgElement.querySelectorAll('path')); | |
| const allSubpaths: string[] = []; | |
| for (const p of pathElements) { | |
| const d = p.getAttribute('d'); | |
| if (d && d.trim()) { | |
| const subpaths = splitSubpaths(d); | |
| allSubpaths.push(...subpaths); | |
| } | |
| } | |
| const viewBox = svgElement.getAttribute('viewBox'); | |
| return { paths: allSubpaths, viewBox }; | |
| } | |
| function padArrays(from: string[], to: string[]): [string[], string[]] { | |
| const maxLength = Math.max(from.length, to.length); | |
| const paddedFrom = [...from]; | |
| const paddedTo = [...to]; | |
| while (paddedFrom.length < maxLength) { | |
| paddedFrom.push(paddedFrom[paddedFrom.length - 1] || 'M0,0'); | |
| } | |
| while (paddedTo.length < maxLength) { | |
| paddedTo.push(paddedTo[paddedTo.length - 1] || 'M0,0'); | |
| } | |
| return [paddedFrom, paddedTo]; | |
| } | |
| document.addEventListener('alpine:init', () => { | |
| window.Alpine.data('svgMorph', (config: MorphConfig) => ({ | |
| states: [] as string[][], | |
| currentIndex: 0, | |
| currentPaths: [] as string[], | |
| pathElements: [] as SVGPathElement[], | |
| viewBox: config.viewBox ?? '0 0 24 24', | |
| isAnimating: false, | |
| prefersReducedMotion: false, | |
| init() { | |
| this.prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches; | |
| const container = this.$refs.icons as HTMLElement; | |
| const svg = this.$refs.svg as SVGSVGElement; | |
| if (!container || !svg) return; | |
| const svgs = container.querySelectorAll('svg'); | |
| if (svgs.length === 0) return; | |
| let maxPaths = 0; | |
| svgs.forEach((svgEl, index) => { | |
| const pathData = extractPathData(svgEl); | |
| this.states.push(pathData.paths); | |
| maxPaths = Math.max(maxPaths, pathData.paths.length); | |
| if (index === 0 && !config.viewBox && pathData.viewBox) { | |
| this.viewBox = pathData.viewBox; | |
| } | |
| }); | |
| svg.setAttribute('viewBox', this.viewBox); | |
| for (let i = 0; i < maxPaths; i++) { | |
| const pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path'); | |
| svg.appendChild(pathEl); | |
| this.pathElements.push(pathEl); | |
| } | |
| if (this.states.length > 0) { | |
| let startIndex = 0; | |
| if (config.initial === 'dark-mode') { | |
| startIndex = document.documentElement.classList.contains('dark') ? 1 : 0; | |
| } else { | |
| startIndex = Math.min(config.initial, this.states.length - 1); | |
| } | |
| this.currentIndex = startIndex; | |
| this.currentPaths = [...this.states[startIndex]]; | |
| this.updatePathElements(); | |
| } | |
| }, | |
| updatePathElements() { | |
| this.pathElements.forEach((el, i) => { | |
| const d = this.currentPaths[i] || ''; | |
| el.setAttribute('d', d); | |
| }); | |
| }, | |
| morph(toIndex: number) { | |
| if (toIndex === this.currentIndex || this.isAnimating) return; | |
| if (toIndex < 0 || toIndex >= this.states.length) return; | |
| const fromPaths = this.states[this.currentIndex]; | |
| const toPaths = this.states[toIndex]; | |
| if (this.prefersReducedMotion) { | |
| this.currentPaths = [...toPaths]; | |
| this.updatePathElements(); | |
| this.currentIndex = toIndex; | |
| return; | |
| } | |
| const [paddedFrom, paddedTo] = padArrays(fromPaths, toPaths); | |
| const interpolators = | |
| paddedFrom.length === 1 | |
| ? [interpolate(paddedFrom[0], paddedTo[0], { maxSegmentLength: 2 })] | |
| : interpolateAll(paddedFrom, paddedTo, { maxSegmentLength: 2 }); | |
| this.isAnimating = true; | |
| const easingFn = getEasing(config.easing); | |
| const duration = config.duration; | |
| const startTime = performance.now(); | |
| const animate = (now: number) => { | |
| const elapsed = now - startTime; | |
| const linearProgress = Math.min(elapsed / duration, 1); | |
| const easedProgress = easingFn(linearProgress); | |
| this.currentPaths = interpolators.map((interp) => interp(easedProgress)); | |
| this.updatePathElements(); | |
| if (linearProgress < 1) { | |
| requestAnimationFrame(animate); | |
| } else { | |
| this.currentPaths = [...toPaths]; | |
| this.updatePathElements(); | |
| this.currentIndex = toIndex; | |
| this.isAnimating = false; | |
| } | |
| }; | |
| requestAnimationFrame(animate); | |
| }, | |
| toggle() { | |
| const nextIndex = (this.currentIndex + 1) % this.states.length; | |
| this.morph(nextIndex); | |
| }, | |
| handleHoverEnter() { | |
| if (config.trigger === 'hover') { | |
| this.morph(1); | |
| } | |
| }, | |
| handleHoverLeave() { | |
| if (config.trigger === 'hover') { | |
| this.morph(0); | |
| } | |
| }, | |
| handleClick() { | |
| if (config.trigger === 'click') { | |
| this.toggle(); | |
| } | |
| }, | |
| })); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment