├── .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 | [](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 |