├── .eslintrc.json ├── .github └── assets │ └── demo.gif ├── .gitignore ├── LICENSE.md ├── README.md ├── bun.lockb ├── next.config.mjs ├── package.json ├── postcss.config.mjs ├── public ├── favicon-192x192.png ├── favicon-32x32.png ├── favicon.png ├── fx │ ├── 1-amp.wav │ ├── 2-amp.wav │ ├── 3-amp.wav │ ├── 4-amp.wav │ └── 5-amp.wav └── og.png ├── src └── app │ ├── _components │ └── timeline.tsx │ ├── globals.css │ ├── layout.tsx │ └── page.tsx ├── tailwind.config.ts └── tsconfig.json /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "next/core-web-vitals" 3 | } 4 | -------------------------------------------------------------------------------- /.github/assets/demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/.github/assets/demo.gif -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | .yarn/install-state.gz 8 | 9 | # testing 10 | /coverage 11 | 12 | # next.js 13 | /.next/ 14 | /out/ 15 | 16 | # production 17 | /build 18 | 19 | # misc 20 | .DS_Store 21 | *.pem 22 | 23 | # debug 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # local env files 29 | .env*.local 30 | 31 | # vercel 32 | .vercel 33 | 34 | # typescript 35 | *.tsbuildinfo 36 | next-env.d.ts 37 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # MIT License 2 | 3 | Copyright (c) [2024] [Rudro Dip Sarker] 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Interactive SVG Pan Animation 2 | 3 | > [!NOTE] 4 | > This demo was inspired from [Linear](https://linear.app/plan). I wanted to create a similar effect using SVG and Framer Motion. 5 | 6 | **Live link**: [svg-pan-animation.vercel.app](https://svg-pan-animation.vercel.app) 7 | 8 | [![demo](.github/assets/demo.gif)](https://svg-pan-animation.vercel.app/) 9 | 10 | ## Tech 11 | 12 | - Next.js 13 | - Framer Motion 14 | 15 | ## How it works 16 | 17 | - make a container div 18 | - render the svg 19 | - transform the container div: `rotateX(someDeg)` 20 | - transform the svg to your needs 21 | - add `motion` to the svg 22 | - add `onPan` event listener to the svg 23 | - update the svg's `rotateZ` value accordingly 24 | - and tada!! 🎉 25 | 26 | ## Get started 27 | 28 | > [!IMPORTANT] 29 | > You can use any package manager you like. I'm using `bun` here. 30 | 31 | ```bash 32 | # clone the repo 33 | git clone https://github.com/rudrodip/svg-pan-animation.git 34 | cd svg-pan-animation 35 | 36 | # install dependencies 37 | bun install 38 | 39 | # run the dev server 40 | bun run dev 41 | ``` 42 | 43 | ## License 44 | 45 | This project is licensed under the MIT License - see the [LICENSE](./LICENSE.md) file for details. 46 | -------------------------------------------------------------------------------- /bun.lockb: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/bun.lockb -------------------------------------------------------------------------------- /next.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('next').NextConfig} */ 2 | const nextConfig = {}; 3 | 4 | export default nextConfig; 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "svg-rotation", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start", 9 | "lint": "next lint" 10 | }, 11 | "dependencies": { 12 | "framer-motion": "^11.3.2", 13 | "next": "14.2.5", 14 | "react": "^18", 15 | "react-dom": "^18" 16 | }, 17 | "devDependencies": { 18 | "@types/node": "^20", 19 | "@types/react": "^18", 20 | "@types/react-dom": "^18", 21 | "eslint": "^8", 22 | "eslint-config-next": "14.2.5", 23 | "postcss": "^8", 24 | "tailwindcss": "^3.4.1", 25 | "typescript": "^5" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /postcss.config.mjs: -------------------------------------------------------------------------------- 1 | /** @type {import('postcss-load-config').Config} */ 2 | const config = { 3 | plugins: { 4 | tailwindcss: {}, 5 | }, 6 | }; 7 | 8 | export default config; 9 | -------------------------------------------------------------------------------- /public/favicon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/favicon-192x192.png -------------------------------------------------------------------------------- /public/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/favicon-32x32.png -------------------------------------------------------------------------------- /public/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/favicon.png -------------------------------------------------------------------------------- /public/fx/1-amp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/fx/1-amp.wav -------------------------------------------------------------------------------- /public/fx/2-amp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/fx/2-amp.wav -------------------------------------------------------------------------------- /public/fx/3-amp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/fx/3-amp.wav -------------------------------------------------------------------------------- /public/fx/4-amp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/fx/4-amp.wav -------------------------------------------------------------------------------- /public/fx/5-amp.wav: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/fx/5-amp.wav -------------------------------------------------------------------------------- /public/og.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rudrodip/svg-pan-animation/9ba4fc73a1c1f4c3aa26748bbc2508027c061f37/public/og.png -------------------------------------------------------------------------------- /src/app/_components/timeline.tsx: -------------------------------------------------------------------------------- 1 | "use client"; 2 | 3 | import React, { useState, useCallback, useEffect, useRef } from "react"; 4 | import { 5 | motion, 6 | useMotionValue, 7 | useTransform, 8 | animate, 9 | PanInfo, 10 | } from "framer-motion"; 11 | 12 | const fxs = [ 13 | "/fx/1-amp.wav", 14 | "/fx/2-amp.wav", 15 | "/fx/3-amp.wav", 16 | "/fx/4-amp.wav", 17 | "/fx/5-amp.wav", 18 | ]; 19 | 20 | const ROTATION_THRESHOLD = 10; 21 | const CENTER_X = 2200; // half of the viewBox width 22 | const CENTER_Y = 2200; // half of the viewBox height 23 | 24 | const generateCircle = ( 25 | radius: number, 26 | strokeWidth: number, 27 | color: string = "rgb(var(--foreground))" 28 | ) => { 29 | return ( 30 | <> 31 | 39 | 40 | ); 41 | }; 42 | 43 | const generateLines = ( 44 | numLines: number, 45 | length: number, 46 | radius: number, 47 | color: string = "rgb(var(--foreground))" 48 | ) => { 49 | const lines = []; 50 | for (let i = 0; i < numLines; i++) { 51 | const angle = (i / numLines) * 2 * Math.PI; 52 | const startX = CENTER_X + radius * Math.cos(angle); 53 | const startY = CENTER_Y + radius * Math.sin(angle); 54 | const endX = CENTER_X + (radius + length) * Math.cos(angle); 55 | const endY = CENTER_Y + (radius + length) * Math.sin(angle); 56 | 57 | lines.push( 58 | 68 | ); 69 | } 70 | return lines; 71 | }; 72 | 73 | export default function TimelineSVG() { 74 | const [currentSound, setCurrentSound] = useState(0); 75 | const [playing, setPlaying] = useState(false); 76 | const [rotateZ, setRotateZ] = useState(0); 77 | const rotateMotionValue = useMotionValue(0); 78 | const lastRotation = useRef(0); 79 | const audioRefs = useRef([]); 80 | 81 | const transformedRotate = useTransform(rotateMotionValue, (latest) => { 82 | return `translateY(-70%) scale(2) rotateZ(${latest}deg)`; 83 | }); 84 | 85 | const handlePan = useCallback( 86 | (_: PointerEvent, info: PanInfo) => { 87 | const newRotation = rotateZ - info.delta.x / 15; 88 | setRotateZ(newRotation); 89 | animate(rotateMotionValue, newRotation, { duration: 0, bounce: 0 }); 90 | }, 91 | [rotateMotionValue, rotateZ] 92 | ); 93 | 94 | const playSound = useCallback( 95 | (index: number) => { 96 | if (playing) { 97 | audioRefs.current[currentSound].pause(); 98 | audioRefs.current[currentSound].currentTime = 0; 99 | } 100 | 101 | const audio = audioRefs.current[index]; 102 | if (audio) { 103 | audio.currentTime = 0; 104 | audio 105 | .play() 106 | .then(() => setPlaying(true)) 107 | .catch((error) => console.error("Failed to play audio:", error)); 108 | } 109 | }, 110 | [playing, currentSound] 111 | ); 112 | 113 | const selectNextSound = useCallback(() => { 114 | const nextSound = (currentSound + 1) % fxs.length; 115 | setCurrentSound(nextSound); 116 | playSound(nextSound); 117 | }, [currentSound, playSound]); 118 | 119 | useEffect(() => { 120 | const normalizedRotation = Math.abs( 121 | Math.floor(rotateZ / ROTATION_THRESHOLD) 122 | ); 123 | 124 | if (normalizedRotation !== lastRotation.current) { 125 | selectNextSound(); 126 | lastRotation.current = normalizedRotation; 127 | } 128 | }, [rotateZ, selectNextSound]); 129 | 130 | useEffect(() => { 131 | const handleEnded = () => setPlaying(false); 132 | const currentAudioRefs = audioRefs.current; 133 | 134 | currentAudioRefs.forEach((audio) => { 135 | audio.addEventListener("ended", handleEnded); 136 | }); 137 | 138 | return () => { 139 | currentAudioRefs.forEach((audio) => { 140 | audio.removeEventListener("ended", handleEnded); 141 | }); 142 | }; 143 | }, []); 144 | 145 | return ( 146 | <> 147 | {fxs.map((src, index) => ( 148 |