├── .eslintrc.cjs
├── .gitignore
├── LICENSE
├── index.html
├── package-lock.json
├── package.json
├── postcss.config.cjs
├── public
├── android-chrome-192x192.png
├── android-chrome-256x256.png
├── apple-touch-icon.png
├── browserconfig.xml
├── favicon-16x16.png
├── favicon-32x32.png
├── favicon.ico
├── img
│ ├── fishtrack
│ │ ├── mockup.webp
│ │ └── preview.webp
│ ├── meetmate
│ │ ├── dashboard.webp
│ │ └── landing.webp
│ ├── portfolio
│ │ ├── about.webp
│ │ └── landing.webp
│ └── tcg
│ │ ├── collection.webp
│ │ └── landing.webp
├── mstile-150x150.png
├── safari-pinned-tab.svg
└── site.webmanifest
├── renovate.json
├── src
├── App.tsx
├── assets
│ └── globals.scss
├── components
│ ├── Loader.tsx
│ ├── Magnetic.tsx
│ ├── MouseGradient.tsx
│ ├── NavMenu.tsx
│ ├── Redirect.tsx
│ ├── SectionSpacer.tsx
│ ├── about
│ │ └── index.tsx
│ ├── contact
│ │ └── index.tsx
│ ├── hero
│ │ ├── BackgroundSVG.tsx
│ │ ├── LoopingAnimation.tsx
│ │ └── Navbar.tsx
│ └── projects
│ │ ├── Curve.tsx
│ │ ├── Overlay.tsx
│ │ └── index.tsx
├── hooks
│ ├── useColorAnimation.ts
│ └── useIsTouchDevice.ts
├── index.tsx
└── vite-env.d.ts
├── tailwind.config.js
├── tsconfig.app.json
├── tsconfig.json
├── tsconfig.node.json
├── vercel.json
└── vite.config.ts
/.eslintrc.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | root: true,
3 | env: { browser: true, es2020: true },
4 | extends: [
5 | 'eslint:recommended',
6 | 'plugin:@typescript-eslint/recommended',
7 | 'plugin:react-hooks/recommended',
8 | ],
9 | ignorePatterns: ['dist', '.eslintrc.cjs'],
10 | parser: '@typescript-eslint/parser',
11 | plugins: ['react-refresh'],
12 | rules: {
13 | 'react-refresh/only-export-components': [
14 | 'warn',
15 | { allowConstantExport: true },
16 | ],
17 | },
18 | }
19 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Logs
2 | logs
3 | *.log
4 | npm-debug.log*
5 | yarn-debug.log*
6 | yarn-error.log*
7 | pnpm-debug.log*
8 | lerna-debug.log*
9 |
10 | node_modules
11 | dist
12 | dist-ssr
13 | *.local
14 |
15 | # Editor directories and files
16 | .vscode/*
17 | !.vscode/extensions.json
18 | .idea
19 | .DS_Store
20 | *.suo
21 | *.ntvs*
22 | *.njsproj
23 | *.sln
24 | *.sw?
25 |
26 | bun.lockb
27 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2024 Ben Böckmann
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 |
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
9 |
10 | Portfolio | bencodes
11 |
15 |
16 |
17 |
18 |
19 |
20 |
24 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
38 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "portfolio_v3",
3 | "private": true,
4 | "version": "0.0.0",
5 | "type": "module",
6 | "scripts": {
7 | "dev": "vite --host",
8 | "build": "tsc -b && vite build",
9 | "lint": "eslint . --ext ts,tsx --report-unused-disable-directives --max-warnings 0",
10 | "preview": "vite preview"
11 | },
12 | "dependencies": {
13 | "@studio-freight/react-lenis": "^0.0.47",
14 | "framer-motion": "^11.3.8",
15 | "gsap": "^3.12.5",
16 | "is-touch-device": "^1.0.1",
17 | "lodash": "^4.17.21",
18 | "lucide-react": "^0.414.0",
19 | "react": "^18.3.1",
20 | "react-dom": "^18.3.1",
21 | "react-router-dom": "^6.25.1",
22 | "react-spring": "^9.7.3"
23 | },
24 | "devDependencies": {
25 | "@types/lodash": "^4.17.7",
26 | "@types/react": "^18.3.3",
27 | "@types/react-dom": "^18.3.0",
28 | "@typescript-eslint/eslint-plugin": "^7.15.0",
29 | "@typescript-eslint/parser": "^7.15.0",
30 | "@vitejs/plugin-react-swc": "^3.5.0",
31 | "autoprefixer": "^10.4.19",
32 | "cz-conventional-changelog": "^3.3.0",
33 | "eslint": "^8.57.0",
34 | "eslint-plugin-react-hooks": "^4.6.2",
35 | "eslint-plugin-react-refresh": "^0.4.7",
36 | "postcss": "^8.4.39",
37 | "sass": "^1.77.8",
38 | "tailwindcss": "^3.4.6",
39 | "typescript": "^5.2.2",
40 | "vite": "^5.3.4"
41 | },
42 | "config": {
43 | "commitizen": {
44 | "path": "./node_modules/cz-conventional-changelog"
45 | }
46 | }
47 | }
48 |
--------------------------------------------------------------------------------
/postcss.config.cjs:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | plugins: {
3 | tailwindcss: {},
4 | autoprefixer: {},
5 | },
6 | };
7 |
--------------------------------------------------------------------------------
/public/android-chrome-192x192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/android-chrome-192x192.png
--------------------------------------------------------------------------------
/public/android-chrome-256x256.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/android-chrome-256x256.png
--------------------------------------------------------------------------------
/public/apple-touch-icon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/apple-touch-icon.png
--------------------------------------------------------------------------------
/public/browserconfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | #da532c
7 |
8 |
9 |
10 |
--------------------------------------------------------------------------------
/public/favicon-16x16.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon-16x16.png
--------------------------------------------------------------------------------
/public/favicon-32x32.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon-32x32.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/favicon.ico
--------------------------------------------------------------------------------
/public/img/fishtrack/mockup.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/fishtrack/mockup.webp
--------------------------------------------------------------------------------
/public/img/fishtrack/preview.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/fishtrack/preview.webp
--------------------------------------------------------------------------------
/public/img/meetmate/dashboard.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/meetmate/dashboard.webp
--------------------------------------------------------------------------------
/public/img/meetmate/landing.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/meetmate/landing.webp
--------------------------------------------------------------------------------
/public/img/portfolio/about.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/portfolio/about.webp
--------------------------------------------------------------------------------
/public/img/portfolio/landing.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/portfolio/landing.webp
--------------------------------------------------------------------------------
/public/img/tcg/collection.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/tcg/collection.webp
--------------------------------------------------------------------------------
/public/img/tcg/landing.webp:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/img/tcg/landing.webp
--------------------------------------------------------------------------------
/public/mstile-150x150.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bencodes07/portfolio_v3/920ea849766423e341f6d09b71c67c035e05e90e/public/mstile-150x150.png
--------------------------------------------------------------------------------
/public/safari-pinned-tab.svg:
--------------------------------------------------------------------------------
1 |
2 |
4 |
7 |
8 | Created by potrace 1.14, written by Peter Selinger 2001-2017
9 |
10 |
12 |
16 |
17 |
18 |
--------------------------------------------------------------------------------
/public/site.webmanifest:
--------------------------------------------------------------------------------
1 | {
2 | "name": "",
3 | "short_name": "",
4 | "icons": [
5 | {
6 | "src": "/android-chrome-192x192.png",
7 | "sizes": "192x192",
8 | "type": "image/png"
9 | },
10 | {
11 | "src": "/android-chrome-256x256.png",
12 | "sizes": "256x256",
13 | "type": "image/png"
14 | }
15 | ],
16 | "theme_color": "#ffffff",
17 | "background_color": "#ffffff",
18 | "display": "standalone"
19 | }
20 |
--------------------------------------------------------------------------------
/renovate.json:
--------------------------------------------------------------------------------
1 | {
2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json",
3 | "extends": [
4 | "config:recommended"
5 | ]
6 | }
7 |
--------------------------------------------------------------------------------
/src/App.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef, useCallback, useMemo } from "react";
2 | import "./assets/globals.scss";
3 | import Navbar from "./components/hero/Navbar";
4 | import {
5 | motion,
6 | useInView,
7 | useMotionValue,
8 | useMotionValueEvent,
9 | useScroll,
10 | useTransform,
11 | } from "framer-motion";
12 | import MouseGradient from "./components/MouseGradient";
13 | import { debounce } from "lodash";
14 | import BackgroundSVG from "./components/hero/BackgroundSVG";
15 | import About from "./components/about";
16 | import { useColorAnimation } from "./hooks/useColorAnimation";
17 | import Contact from "./components/contact";
18 | import Projects from "./components/projects";
19 | import SectionSpacer from "./components/SectionSpacer";
20 | import { useIsTouchDevice } from "./hooks/useIsTouchDevice";
21 | import Loader from "./components/Loader";
22 | import { ReactLenis } from "@studio-freight/react-lenis";
23 |
24 | function App() {
25 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 });
26 | const dimensionsRef = useRef({ width: 0, height: 0 });
27 | const aboutRef = useRef(null);
28 | const projectsRef = useRef(null);
29 | const contactRef = useRef(null);
30 | const isMobile = useMemo(() => window.innerWidth <= 768, []);
31 |
32 | const isTouchDevice = useIsTouchDevice();
33 |
34 | // ----- Dimension update ----- //
35 | const updateDimensions = useCallback(
36 | debounce(() => {
37 | const newDimensions = {
38 | width: window.innerWidth,
39 | height: window.innerHeight,
40 | };
41 | dimensionsRef.current = newDimensions;
42 | setDimensions(newDimensions);
43 | }, 200),
44 | [],
45 | );
46 |
47 | useEffect(() => {
48 | updateDimensions();
49 | window.addEventListener("resize", updateDimensions);
50 |
51 | return () => window.removeEventListener("resize", updateDimensions);
52 | }, [updateDimensions]);
53 |
54 | // ----- Scroll animations ----- //
55 | const { scrollYProgress } = useScroll();
56 | const backgroundGradient = useMotionValue(
57 | "radial-gradient(circle, #111111 0%, #000000 65%)",
58 | );
59 | const textColor = useMotionValue("#FFFFFF");
60 | const svgOpacity = useMotionValue(1);
61 |
62 | const handleScroll = useCallback(
63 | (latest: number) => {
64 | requestAnimationFrame(() => {
65 | const progress = !isMobile
66 | ? Math.max(0, Math.min((latest - 0.1) / 0.1, 1))
67 | : Math.max(0, Math.min((latest - 0.03) / 0.1, 1));
68 |
69 | const startColor = [0, 0, 0];
70 | const endColor = [255, 255, 255]; // #FFFFFF
71 |
72 | const interpolateColor = (start: number[], end: number[]): string =>
73 | start
74 | .map((channel, i) =>
75 | Math.round(channel + (end[i] - channel) * progress),
76 | )
77 | .join(", ");
78 |
79 | const newGradient = `radial-gradient(circle, rgb(${interpolateColor(
80 | [17, 17, 17],
81 | endColor,
82 | )}) 0%, rgb(${interpolateColor(startColor, endColor)}) 65%)`;
83 | backgroundGradient.set(newGradient);
84 |
85 | if (progress < 0.1) {
86 | document.body.style.backgroundColor = "#000000";
87 | document.getElementById("root")!.style.backgroundColor = "#000000";
88 | document.documentElement.style.backgroundColor = "#000000";
89 | } else if (progress > 0.3) {
90 | document.body.style.backgroundColor = "#ffffff";
91 | document.getElementById("root")!.style.backgroundColor = "#ffffff";
92 | document.documentElement.style.backgroundColor = "#ffffff";
93 | }
94 |
95 | const txtColor = `rgb(${255 - Math.round(255 * progress)}, ${
96 | 255 - Math.round(255 * progress)
97 | }, ${255 - Math.round(255 * progress)})`;
98 | textColor.set(txtColor);
99 | const newOpacity = 1 - progress * 3;
100 | svgOpacity.set(newOpacity);
101 | });
102 | },
103 | [isMobile],
104 | );
105 |
106 | useMotionValueEvent(scrollYProgress, "change", handleScroll);
107 |
108 | // ----- Color Animation ----- //
109 | const { hue1, hue2 } = useColorAnimation();
110 |
111 | // ----- Loading Animation ----- //
112 | const [isLoading, setIsLoading] = useState(true);
113 |
114 | const landingSectionVariants = {
115 | hidden: { scale: 0.8, opacity: 0 },
116 | visible: {
117 | scale: 1,
118 | opacity: 1,
119 | transition: {
120 | duration: 0.5,
121 | ease: "easeOut",
122 | when: "beforeChildren",
123 | staggerChildren: 0.1,
124 | delay: 0.5,
125 | type: "tween",
126 | useNativeDriver: true
127 | },
128 | },
129 | };
130 |
131 | const initialState = isMobile ? "visible" : "hidden";
132 |
133 | return (
134 |
135 | setIsLoading(false)} />
136 |
137 |
138 |
139 |
143 |
150 |
151 |
157 |
171 | Turning ideas into{" "}
172 |
177 | `linear-gradient(90deg, hsl(${h1}, 100%, 50%), hsl(${h2}, 100%, 50%))`
178 | ),
179 | backgroundClip: "text",
180 | WebkitBackgroundClip: "text",
181 | color: "transparent",
182 | }}
183 | >
184 | creative
185 | {" "}
186 | solutions.
187 |
188 |
201 | Innovative web developer crafting unique user experiences.
202 |
203 |
204 |
205 |
212 |
213 |
214 |
215 |
224 |
225 |
226 |
231 |
232 |
233 |
234 | );
235 | }
236 |
237 | export default App;
238 |
--------------------------------------------------------------------------------
/src/assets/globals.scss:
--------------------------------------------------------------------------------
1 | @tailwind base;
2 | @tailwind components;
3 | @tailwind utilities;
4 |
5 | @import url("https://fonts.googleapis.com/css2?family=Khula:wght@300;400;600;700;800&family=Poppins:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap");
6 |
7 | @layer base {
8 | :root {
9 | --dark: #000000;
10 | --light: #ffffff;
11 | --landing-bg-image: radial-gradient(circle, #111111 0%, #000000 65%);
12 |
13 | --gray-4: #1c1c1c;
14 | --gray-3: #666666;
15 | --gray-2: #888888;
16 | --gray-1: #b0b0b0;
17 |
18 | --svg-line: #2c2c2c;
19 | }
20 | }
21 |
22 | #root,
23 | body,
24 | html {
25 | overflow-x: hidden;
26 | background-color: var(--dark);
27 | }
28 |
29 | .contact-bg {
30 | position: relative;
31 | overflow: hidden;
32 | background-color: var(--light);
33 | &::before {
34 | content: "";
35 | position: absolute;
36 | left: -50%;
37 | bottom: -50%;
38 | width: 100%;
39 | height: 100%;
40 | background-color: var(--light);
41 | background: radial-gradient(
42 | circle,
43 | rgb(177, 175, 255) 0%,
44 | rgba(255, 255, 255, 1) 60%
45 | );
46 | filter: blur(100px);
47 | z-index: -1;
48 | }
49 |
50 | &::after {
51 | content: "";
52 | position: absolute;
53 | left: 50%;
54 | bottom: -50%;
55 | width: 100%;
56 | height: 100%;
57 | background-color: var(--light);
58 | background: radial-gradient(
59 | circle,
60 | rgb(187, 233, 255) 0%,
61 | rgba(255, 255, 255, 1) 60%
62 | );
63 | filter: blur(100px);
64 | z-index: -1;
65 | }
66 | }
67 |
68 | // Khula Font
69 | .khula-light {
70 | font-family: "Khula", sans-serif;
71 | font-weight: 300;
72 | font-style: normal;
73 | }
74 |
75 | .khula-regular {
76 | font-family: "Khula", sans-serif;
77 | font-weight: 400;
78 | font-style: normal;
79 | }
80 |
81 | .khula-semibold {
82 | font-family: "Khula", sans-serif;
83 | font-weight: 600;
84 | font-style: normal;
85 | }
86 |
87 | .khula-bold {
88 | font-family: "Khula", sans-serif;
89 | font-weight: 700;
90 | font-style: normal;
91 | }
92 |
93 | .khula-extrabold {
94 | font-family: "Khula", sans-serif;
95 | font-weight: 800;
96 | font-style: normal;
97 | }
98 |
99 | // Poppins Font
100 | .poppins-thin {
101 | font-family: "Poppins", sans-serif;
102 | font-weight: 100;
103 | font-style: normal;
104 | }
105 |
106 | .poppins-extralight {
107 | font-family: "Poppins", sans-serif;
108 | font-weight: 200;
109 | font-style: normal;
110 | }
111 |
112 | .poppins-light {
113 | font-family: "Poppins", sans-serif;
114 | font-weight: 300;
115 | font-style: normal;
116 | }
117 |
118 | .poppins-regular {
119 | font-family: "Poppins", sans-serif;
120 | font-weight: 400;
121 | font-style: normal;
122 | }
123 |
124 | .poppins-medium {
125 | font-family: "Poppins", sans-serif;
126 | font-weight: 500;
127 | font-style: normal;
128 | }
129 |
130 | .poppins-semibold {
131 | font-family: "Poppins", sans-serif;
132 | font-weight: 600;
133 | font-style: normal;
134 | }
135 |
136 | .poppins-bold {
137 | font-family: "Poppins", sans-serif;
138 | font-weight: 700;
139 | font-style: normal;
140 | }
141 |
142 | .poppins-extrabold {
143 | font-family: "Poppins", sans-serif;
144 | font-weight: 800;
145 | font-style: normal;
146 | }
147 |
148 | .poppins-black {
149 | font-family: "Poppins", sans-serif;
150 | font-weight: 900;
151 | font-style: normal;
152 | }
153 |
154 | .poppins-thin-italic {
155 | font-family: "Poppins", sans-serif;
156 | font-weight: 100;
157 | font-style: italic;
158 | }
159 |
160 | .poppins-extralight-italic {
161 | font-family: "Poppins", sans-serif;
162 | font-weight: 200;
163 | font-style: italic;
164 | }
165 |
166 | .poppins-light-italic {
167 | font-family: "Poppins", sans-serif;
168 | font-weight: 300;
169 | font-style: italic;
170 | }
171 |
172 | .poppins-regular-italic {
173 | font-family: "Poppins", sans-serif;
174 | font-weight: 400;
175 | font-style: italic;
176 | }
177 |
178 | .poppins-medium-italic {
179 | font-family: "Poppins", sans-serif;
180 | font-weight: 500;
181 | font-style: italic;
182 | }
183 |
184 | .poppins-semibold-italic {
185 | font-family: "Poppins", sans-serif;
186 | font-weight: 600;
187 | font-style: italic;
188 | }
189 |
190 | .poppins-bold-italic {
191 | font-family: "Poppins", sans-serif;
192 | font-weight: 700;
193 | font-style: italic;
194 | }
195 |
196 | .poppins-extrabold-italic {
197 | font-family: "Poppins", sans-serif;
198 | font-weight: 800;
199 | font-style: italic;
200 | }
201 |
202 | .poppins-black-italic {
203 | font-family: "Poppins", sans-serif;
204 | font-weight: 900;
205 | font-style: italic;
206 | }
207 |
--------------------------------------------------------------------------------
/src/components/Loader.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useState, useRef } from "react";
2 | import { motion, AnimatePresence, useAnimation } from "framer-motion";
3 |
4 | type LoaderProps = {
5 | onLoadingComplete: () => void;
6 | };
7 |
8 | const Loader: React.FC = ({ onLoadingComplete }) => {
9 | const [show, setShow] = useState(true);
10 | const controls = useAnimation();
11 | const animationCompleted = useRef(false);
12 |
13 | useEffect(() => {
14 | const animateSvg = async () => {
15 | document.body.style.cursor = "wait";
16 | // Start the SVG animation
17 | await controls
18 | .start({
19 | pathLength: 1,
20 | transition: { duration: 2, ease: "easeInOut" },
21 | })
22 | .then(() => {
23 | document.body.style.cursor = "auto";
24 | });
25 |
26 | // Wait 300ms after animation completes
27 | await new Promise((resolve) => setTimeout(resolve, 300));
28 |
29 | animationCompleted.current = true;
30 |
31 | // Only now check if the page has loaded
32 | if (document.readyState === "complete") {
33 | setShow(false);
34 | } else {
35 | window.addEventListener("load", handlePageLoad);
36 | }
37 | };
38 |
39 | const handlePageLoad = () => {
40 | if (animationCompleted.current) {
41 | setShow(false);
42 | }
43 | };
44 |
45 | // Start animation
46 | animateSvg();
47 |
48 | return () => {
49 | window.removeEventListener("load", handlePageLoad);
50 | };
51 | }, [controls]);
52 |
53 | // Call onLoadingComplete when the loader starts to fade out
54 | useEffect(() => {
55 | if (!show) {
56 | onLoadingComplete();
57 | }
58 | }, [show, onLoadingComplete]);
59 |
60 | return (
61 |
62 | {show && (
63 |
80 |
89 |
99 |
100 |
101 | )}
102 |
103 | );
104 | };
105 |
106 | export default Loader;
107 |
--------------------------------------------------------------------------------
/src/components/Magnetic.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef } from "react";
2 | import gsap from "gsap";
3 | import { useIsTouchDevice } from "../hooks/useIsTouchDevice";
4 |
5 | // Utility function for throttling
6 | function throttle(func: Function, limit: number) {
7 | let inThrottle: boolean;
8 | return function (this: any, ...args: any[]) {
9 | if (!inThrottle) {
10 | func.apply(this, args);
11 | inThrottle = true;
12 | setTimeout(() => (inThrottle = false), limit);
13 | }
14 | };
15 | }
16 |
17 | export default function Index({ children }: { children: JSX.Element }) {
18 | const magnetic = useRef(null);
19 | const animation = useRef(null);
20 | const isTouchDevice = useIsTouchDevice();
21 |
22 | useEffect(() => {
23 | if (isTouchDevice) return;
24 | const element = magnetic.current;
25 | if (!element) return;
26 |
27 | const maxDistance = 20; // Maximum pixel distance to move
28 |
29 | const animate = (x: number, y: number) => {
30 | if (animation.current) {
31 | animation.current.kill();
32 | }
33 | animation.current = gsap.to(element, {
34 | x,
35 | y,
36 | duration: 0.7,
37 | ease: "power2.out",
38 | });
39 | };
40 |
41 | const calculateMovement = (clientX: number, clientY: number) => {
42 | const { height, width, left, top } = element.getBoundingClientRect();
43 | let x = clientX - (left + width / 2);
44 | let y = clientY - (top + height / 2);
45 |
46 | // Limit the movement range
47 | const distance = Math.sqrt(x * x + y * y);
48 | if (distance > maxDistance) {
49 | const factor = maxDistance / distance;
50 | x *= factor;
51 | y *= factor;
52 | }
53 |
54 | return { x, y };
55 | };
56 |
57 | const mouseMove = throttle((e: MouseEvent) => {
58 | const { x, y } = calculateMovement(e.clientX, e.clientY);
59 | requestAnimationFrame(() => animate(x, y));
60 | }, 16); // Throttle to about 60fps
61 |
62 | const mouseLeave = () => {
63 | requestAnimationFrame(() => animate(0, 0));
64 | };
65 |
66 | element.addEventListener("mousemove", mouseMove);
67 | element.addEventListener("mouseleave", mouseLeave);
68 |
69 | return () => {
70 | element.removeEventListener("mousemove", mouseMove);
71 | element.removeEventListener("mouseleave", mouseLeave);
72 | };
73 | }, [isTouchDevice]);
74 |
75 | return React.cloneElement(children, { ref: magnetic });
76 | }
77 |
--------------------------------------------------------------------------------
/src/components/MouseGradient.tsx:
--------------------------------------------------------------------------------
1 | import { useState, useEffect, useRef } from "react";
2 | import { useSpring, animated, config } from "react-spring";
3 | import NavMenu from "./NavMenu";
4 | import { Equal } from "lucide-react";
5 | import { useScroll, useTransform, motion } from "framer-motion";
6 | import gsap from "gsap";
7 |
8 | const MouseGradient = ({ isMobile }: { isMobile: boolean }) => {
9 | const [isMenuOpen, setIsMenuOpen] = useState(false);
10 | const [textColor, setTextColor] = useState<"white" | "transparent">("white");
11 | const gradientRef = useRef(null);
12 | const animation = useRef(null);
13 |
14 | const { scrollYProgress, scrollY } = useScroll();
15 |
16 | const gradientOpacity = useTransform(scrollY, [0, 200], [1, 0]);
17 |
18 | const [buttonProps, setButtonProps] = useSpring(() => ({
19 | color: "rgb(255, 255, 255)",
20 | backgroundColor: "rgba(0, 0, 0, 0)",
21 | config: config.gentle,
22 | }));
23 |
24 | useEffect(() => {
25 | const element = gradientRef.current;
26 | if (!element) return;
27 |
28 | const maxDistance = 80; // Maximum pixel distance to move
29 |
30 | const animate = (x: number, y: number) => {
31 | if (animation.current) {
32 | animation.current.kill();
33 | }
34 | animation.current = gsap.to(element, {
35 | x,
36 | y,
37 | duration: 0.3,
38 | ease: "power2.out",
39 | });
40 | };
41 |
42 | const calculateMovement = (clientX: number, clientY: number) => {
43 | const centerX = window.innerWidth / 2;
44 | const centerY = window.innerHeight / 2;
45 | let x = (clientX - centerX) / 10;
46 | let y = (clientY - centerY) / 10;
47 |
48 | // Limit the movement range
49 | const distance = Math.sqrt(x * x + y * y);
50 | if (distance > maxDistance) {
51 | const factor = maxDistance / distance;
52 | x *= factor;
53 | y *= factor;
54 | }
55 |
56 | return { x, y };
57 | };
58 |
59 | const mouseMove = (e: MouseEvent) => {
60 | const { x, y } = calculateMovement(e.clientX, e.clientY);
61 | requestAnimationFrame(() => animate(x, y));
62 | };
63 |
64 | const mouseLeave = () => {
65 | requestAnimationFrame(() => animate(0, 0));
66 | };
67 |
68 | window.addEventListener("mousemove", mouseMove);
69 | window.addEventListener("mouseleave", mouseLeave);
70 |
71 | return () => {
72 | window.removeEventListener("mousemove", mouseMove);
73 | window.removeEventListener("mouseleave", mouseLeave);
74 | };
75 | }, []);
76 |
77 | useEffect(() => {
78 | const handleScroll = () => {
79 | if (scrollYProgress.get() > 0.2 && scrollYProgress.get() < 0.4) {
80 | const t = (scrollYProgress.get() - 0.2) / 0.2; // normalize to 0-1
81 | setButtonProps({
82 | color: `rgb(${Math.round(255 * (1 - t))}, ${Math.round(
83 | 255 * (1 - t),
84 | )}, ${Math.round(255 * (1 - t))})`,
85 | backgroundColor: `rgba(255, 255, 255, ${t})`,
86 | });
87 | setTextColor("transparent");
88 | } else if (scrollYProgress.get() <= 0.2) {
89 | setTextColor("white");
90 | setButtonProps({
91 | color: "rgb(255, 255, 255)",
92 | backgroundColor: "rgba(0, 0, 0, 0)",
93 | });
94 | } else if (scrollYProgress.get() >= 0.4) {
95 | setButtonProps({
96 | color: "rgb(0, 0, 0)",
97 | backgroundColor: "rgb(255, 255, 255)",
98 | });
99 | setTextColor("transparent");
100 | }
101 | };
102 |
103 | window.addEventListener("scroll", handleScroll);
104 |
105 | return () => {
106 | window.removeEventListener("scroll", handleScroll);
107 | };
108 | }, [setButtonProps]);
109 |
110 | return (
111 | <>
112 | {!isMobile && (
113 | 768 ? "fixed" : "absolute",
117 | top: "50%",
118 | left: "50%",
119 | width: `${
120 | !isMobile ? Math.min(window.innerWidth, window.innerHeight) : 0
121 | }px`,
122 | height: `${
123 | !isMobile ? Math.min(window.innerWidth, window.innerHeight) : 0
124 | }px`,
125 | transform: "translate(-50%, -50%)",
126 | pointerEvents: "none",
127 | zIndex: 0,
128 | opacity: gradientOpacity,
129 | background: `radial-gradient(circle, rgba(190, 190, 255, 0.06) 0%, transparent 50%)`,
130 | }}
131 | />
132 | )}
133 |
134 |
135 | {window.innerWidth > 768 && (
136 |
setIsMenuOpen(!isMenuOpen)}
140 | >
141 | menu
142 |
143 | )}
144 |
145 |
768 ? "fixed" : "absolute",
148 | right: "1.5rem",
149 | padding: "0.5rem 1rem",
150 | color: buttonProps.color,
151 | }}
152 | className="fixed top-6 right-16 z-[11] px-4 py-2 text-light text-xl poppins-regular flex flex-row gap-x-2 items-center"
153 | onClick={() => setIsMenuOpen(!isMenuOpen)}
154 | >
155 |
156 |
157 |
158 | setIsMenuOpen(false)} />
159 | >
160 | );
161 | };
162 |
163 | export default MouseGradient;
164 |
--------------------------------------------------------------------------------
/src/components/NavMenu.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { motion, AnimatePresence, useScroll } from "framer-motion";
3 | import { X } from "lucide-react";
4 | import { useLenis } from "@studio-freight/react-lenis";
5 |
6 | type NavMenuProps = {
7 | isOpen: boolean;
8 | onClose: () => void;
9 | };
10 |
11 | const NavMenu: React.FC = ({ isOpen, onClose }) => {
12 | const { scrollY } = useScroll();
13 |
14 | const lenis = useLenis();
15 |
16 | const handleNavClick = (
17 | e: React.MouseEvent,
18 | targetId: string,
19 | ) => {
20 | e.preventDefault();
21 | if (lenis) {
22 | const target = document.getElementById(targetId);
23 | if (target) {
24 | lenis.scrollTo(target);
25 | }
26 | }
27 | onClose();
28 | };
29 | return (
30 | <>
31 | {/* Backdrop */}
32 |
33 | {isOpen && (
34 |
43 | )}
44 |
45 |
46 |
63 |
64 | {/* Navigation Menu */}
65 |
91 |
97 |
101 |
102 |
103 |
104 |
105 |
106 |
107 |
113 | Social
114 |
115 | {[
116 | {
117 | name: "LinkedIn",
118 | link: "https://linkedin.com/in/ben-böckmann-296293265",
119 | },
120 | {
121 | name: "Instagram",
122 | link: "https://instagram.com/ben.bck_prvt",
123 | },
124 | { name: "Github", link: "https://github.com/bencodes07" },
125 | ].map((item, index) => (
126 |
132 |
137 | {item.name}
138 |
139 |
140 | ))}
141 |
142 |
143 |
144 |
150 | Menu
151 |
152 | {[
153 | { name: "About Me", id: "about" },
154 | { name: "Projects", id: "projects" },
155 | /* { name: "Experience", id: "about" }, */
156 | { name: "Contact", id: "contact" },
157 | ].map((item, index) => (
158 |
164 | window.innerWidth <= 768 &&
165 | document.getElementById(item.id)?.scrollIntoView()
166 | }
167 | >
168 | handleNavClick(e, item.id)}
171 | className={`text-[2.5rem] ${
172 | !(window.innerWidth <= 768) && "hover:left-2"
173 | } left-0 relative transition-[left] duration-300 ease-in-out`}
174 | >
175 | {item.name}
176 |
177 |
178 | ))}
179 |
180 |
181 |
182 |
183 |
184 |
190 | Get in touch
191 |
192 | info@bencodes.de
193 |
194 |
195 |
196 | >
197 | );
198 | };
199 |
200 | export default NavMenu;
201 |
--------------------------------------------------------------------------------
/src/components/Redirect.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from "react";
2 | import { useNavigate } from "react-router-dom";
3 |
4 | export default function Redirect() {
5 | const navigate = useNavigate();
6 |
7 | useEffect(() => {
8 | navigate("/");
9 | }, [navigate]);
10 | return null;
11 | }
12 |
--------------------------------------------------------------------------------
/src/components/SectionSpacer.tsx:
--------------------------------------------------------------------------------
1 | import { MotionValue, motion } from "framer-motion";
2 |
3 | export default function SectionSpacer({
4 | backgroundGradient,
5 | height,
6 | }: {
7 | backgroundGradient: MotionValue;
8 | height: number;
9 | }) {
10 | return (
11 |
15 | );
16 | }
17 |
--------------------------------------------------------------------------------
/src/components/about/index.tsx:
--------------------------------------------------------------------------------
1 | import { MotionValue, useAnimationControls, motion } from "framer-motion";
2 | import { ArrowUpRight } from "lucide-react";
3 | import { useEffect, useState } from "react";
4 | import Magnetic from "../Magnetic";
5 | import { useLenis } from "@studio-freight/react-lenis";
6 |
7 | type AboutSectionProps = {
8 | isAboutInView: boolean;
9 | isMobile: boolean;
10 | backgroundGradient: MotionValue;
11 | };
12 |
13 | const fadeInUpVariants = {
14 | hidden: { opacity: 0, y: 50 },
15 | visible: (custom: number) => ({
16 | opacity: 1,
17 | y: 0,
18 | transition: {
19 | duration: 0.6,
20 | ease: "easeOut",
21 | delay: custom * 0.2,
22 | type: "tween",
23 | useNativeDriver: true
24 | },
25 | }),
26 | };
27 |
28 | const lineVariants = {
29 | hidden: { width: 0 },
30 | visible: {
31 | width: "100%",
32 | transition: {
33 | duration: 1.5,
34 | ease: "easeInOut",
35 | type: "tween",
36 | useNativeDriver: true
37 | },
38 | },
39 | };
40 |
41 | const About: React.FC = ({
42 | isAboutInView,
43 | isMobile,
44 | backgroundGradient,
45 | }) => {
46 | const aboutControls = useAnimationControls();
47 |
48 | const [hasAnimated, setHasAnimated] = useState(false);
49 |
50 | const lenis = useLenis();
51 |
52 | useEffect(() => {
53 | if (isAboutInView && !hasAnimated) {
54 | aboutControls.start("visible");
55 | setHasAnimated(true);
56 | } else if (!isAboutInView && hasAnimated) {
57 | aboutControls.start("hidden");
58 | setHasAnimated(false);
59 | }
60 | }, [isAboutInView, aboutControls, hasAnimated, setHasAnimated]);
61 |
62 | const initialState = isMobile ? "visible" : "hidden";
63 |
64 | return (
65 |
69 |
74 |
79 | I believe in a user centered design approach, ensuring that every
80 | project I work on is tailored to meet the specific needs of its users.
81 |
82 |
83 |
88 |
89 | This is me.
90 |
91 |
95 |
96 |
101 |
102 |
107 | Hi, I'm Ben.
108 |
109 | {!isMobile && (
110 |
111 | lenis?.scrollTo("#contact")}
115 | className="flex bg-dark rounded-full text-light pl-4 pr-6 gap-x-1 py-3 w-max poppins-regular mt-24 select-none"
116 | >
117 |
118 | Get in Touch
119 |
120 |
121 | )}
122 |
123 |
128 |
129 | I'm a 17 year-old web developer dedicated to turning ideas into
130 | creative solutions. I specialize in creating seamless and
131 | intuitive user experiences.
132 |
133 |
134 | I'm involved in every step of the process: from discovery and
135 | design to development, testing, and deployment. I focus on
136 | delivering high-quality, scalable results that drive positive user
137 | experiences.
138 |
139 |
140 | {isMobile && (
141 |
145 | document.getElementById("contact")?.scrollIntoView()
146 | }
147 | className="flex bg-dark rounded-full text-light pl-4 pr-6 gap-x-1 py-3 w-max h-fit poppins-regular select-none mt-8"
148 | >
149 |
150 | Get in Touch
151 |
152 | )}
153 |
154 |
155 |
156 | );
157 | };
158 |
159 | export default About;
160 |
--------------------------------------------------------------------------------
/src/components/contact/index.tsx:
--------------------------------------------------------------------------------
1 | import { MotionValue, motion, useAnimationControls } from "framer-motion";
2 | import { Linkedin, Mail, Phone } from "lucide-react";
3 | import { useEffect, useState } from "react";
4 | import Magnetic from "../Magnetic";
5 |
6 | type ContactSectionProps = {
7 | isContactInView: boolean;
8 | isMobile: boolean;
9 | backgroundGradient: MotionValue;
10 | };
11 |
12 | const fadeInUpVariants = {
13 | hidden: { opacity: 0, y: 50 },
14 | visible: (custom: number) => ({
15 | opacity: 1,
16 | y: 0,
17 | transition: {
18 | duration: 0.6,
19 | ease: "easeOut",
20 | delay: custom * 0.2,
21 | type: "tween",
22 | useNativeDriver: true
23 | },
24 | }),
25 | };
26 |
27 | const Contact: React.FC = ({
28 | isContactInView,
29 | isMobile,
30 | }) => {
31 | const contactControls = useAnimationControls();
32 |
33 | const [hasAnimated, setHasAnimated] = useState(false);
34 |
35 | useEffect(() => {
36 | if (isContactInView && !hasAnimated) {
37 | contactControls.start("visible");
38 | setHasAnimated(true);
39 | } else if (!isContactInView && hasAnimated) {
40 | contactControls.start("hidden");
41 | setHasAnimated(false);
42 | }
43 | }, [isContactInView, contactControls, hasAnimated, setHasAnimated]);
44 |
45 | const initialState = isMobile ? "visible" : "hidden";
46 |
47 | return (
48 |
55 |
60 | Want to collaborate?
61 |
62 |
67 | Let's have a chat!
68 |
69 |
74 |
75 |
79 |
80 | Email
81 |
82 |
83 |
84 |
88 |
89 | Phone
90 |
91 |
92 |
93 |
98 |
99 | LinkedIn
100 |
101 |
102 |
103 |
108 | bb
109 | Ben Böckmann
110 |
111 |
112 | © Ben Böckmann {new Date().getFullYear()}. All rights reserved.
113 | Location: Germany
114 |
115 |
116 | This site showcases my personal projects and professional work.
117 | Content may not be used without permission.
118 |
119 |
120 |
121 | );
122 | };
123 | export default Contact;
124 |
--------------------------------------------------------------------------------
/src/components/hero/BackgroundSVG.tsx:
--------------------------------------------------------------------------------
1 | import { MotionValue, motion } from "framer-motion";
2 | import { useMemo } from "react";
3 | import LoopingAnimation from "./LoopingAnimation";
4 |
5 | type BackgroundSVGProps = {
6 | width: number;
7 | height: number;
8 | isMobile: boolean;
9 | svgOpacity: MotionValue;
10 | isLoading: boolean;
11 | };
12 |
13 | const drawVariant = {
14 | hidden: { pathLength: 0, opacity: 0 },
15 | visible: {
16 | pathLength: 1,
17 | opacity: 1,
18 | transition: {
19 | pathLength: { type: "spring", duration: 5, bounce: 0, delay: 0.5 },
20 | opacity: { duration: 0.8, ease: "easeInOut", delay: 0.5 },
21 | type: "tween",
22 | useNativeDriver: true
23 | },
24 | },
25 | };
26 |
27 | const BackgroundSVG: React.FC = ({
28 | width,
29 | height,
30 | isMobile,
31 | svgOpacity,
32 | isLoading,
33 | }) => {
34 | const renderSVGLines = useMemo(() => {
35 | if (width === 0 || height === 0 || isLoading) return null;
36 |
37 | if (isMobile) {
38 | // Render three lines for mobile: left, center, and right
39 | const centerX = width / 2;
40 | const xOffset = width * 0.3;
41 | const leftX = centerX - xOffset;
42 | const rightX = centerX + xOffset;
43 |
44 | return (
45 | <>
46 |
56 |
66 |
76 | >
77 | );
78 | } else {
79 | const offsets = [
80 | -0.12 - 30 / width - 0.125,
81 | -0.12 - 30 / width,
82 | -0.12,
83 | 0,
84 | 0.12,
85 | 0.12 + 30 / width,
86 | 0.12 + 30 / width + 0.125,
87 | ];
88 |
89 | return offsets.map((offset, index) => {
90 | const x = width / 2 + width * offset;
91 | const centerY = height / 2;
92 |
93 | return (
94 |
102 | );
103 | });
104 | }
105 | }, [width, height, isMobile, isLoading]);
106 |
107 | const initialState = isMobile ? "visible" : "hidden";
108 |
109 | return (
110 |
114 | {width > 0 && height > 0 && (
115 | <>
116 |
123 | {renderSVGLines}
124 |
125 |
126 | >
127 | )}
128 |
129 | );
130 | };
131 |
132 | export default BackgroundSVG;
133 |
--------------------------------------------------------------------------------
/src/components/hero/LoopingAnimation.tsx:
--------------------------------------------------------------------------------
1 | import { useReducer, useEffect, useRef } from "react";
2 | import { motion, useAnimate } from "framer-motion";
3 |
4 | type AnimationState = {
5 | leftKey: number;
6 | rightKey: number;
7 | };
8 |
9 | type Action = { type: "INCREMENT_LEFT" } | { type: "INCREMENT_RIGHT" };
10 |
11 | const initialState: AnimationState = { leftKey: 0, rightKey: 1 };
12 |
13 | function reducer(state: AnimationState, action: Action): AnimationState {
14 | switch (action.type) {
15 | case "INCREMENT_LEFT":
16 | return { ...state, leftKey: state.leftKey + 2 };
17 | case "INCREMENT_RIGHT":
18 | return { ...state, rightKey: state.rightKey + 2 };
19 | }
20 | }
21 |
22 | type AnimatedShapeProps = {
23 | side: "left" | "right";
24 | onComplete: () => void;
25 | width: number;
26 | height: number;
27 | isMobile: boolean;
28 | };
29 |
30 | function AnimatedShape({
31 | side,
32 | onComplete,
33 | width,
34 | height,
35 | isMobile,
36 | }: AnimatedShapeProps) {
37 | const [scope, animate] = useAnimate();
38 | const animationRef = useRef(null);
39 |
40 | useEffect(() => {
41 | const runAnimation = async () => {
42 | // Draw the outline
43 | await animate(scope.current, { pathLength: 1 }, { duration: 1.5 });
44 | // Fill the shape
45 | await animate(scope.current, { fillOpacity: 1 }, { duration: 0.5 });
46 | // Move the shape down
47 | await animate(scope.current, { y: "50%" }, { duration: 1 });
48 | onComplete();
49 | };
50 |
51 | animationRef.current = requestAnimationFrame(runAnimation);
52 |
53 | return () => {
54 | if (animationRef.current) {
55 | cancelAnimationFrame(animationRef.current);
56 | }
57 | };
58 | }, [animate, onComplete]);
59 |
60 | const centerX = width / 2;
61 | const yStart = (height * 2) / 3;
62 | const xOffset = isMobile ? width * 0.3 : width * 0.12;
63 |
64 | const path = `M ${centerX + (side === "left" ? -xOffset : xOffset)} ${yStart}
65 | L ${centerX} ${yStart + 100}
66 | L ${centerX} ${yStart + 132}
67 | L ${centerX + (side === "left" ? -xOffset : xOffset)} ${
68 | yStart + 32
69 | } Z`;
70 |
71 | return (
72 |
78 |
86 |
87 | );
88 | }
89 |
90 | interface LoopingAnimationProps {
91 | width: number;
92 | height: number;
93 | isMobile: boolean;
94 | }
95 |
96 | function LoopingAnimation({ width, height, isMobile }: LoopingAnimationProps) {
97 | const [state, dispatch] = useReducer(reducer, initialState);
98 |
99 | return (
100 | <>
101 | dispatch({ type: "INCREMENT_LEFT" })}
105 | width={width}
106 | height={height}
107 | isMobile={isMobile}
108 | />
109 | dispatch({ type: "INCREMENT_RIGHT" })}
113 | width={width}
114 | height={height}
115 | isMobile={isMobile}
116 | />
117 | >
118 | );
119 | }
120 |
121 | export default LoopingAnimation;
122 |
--------------------------------------------------------------------------------
/src/components/hero/Navbar.tsx:
--------------------------------------------------------------------------------
1 | export default function Navbar() {
2 | return (
3 |
6 | );
7 | }
8 |
--------------------------------------------------------------------------------
/src/components/projects/Curve.tsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { motion, useAnimationControls } from "framer-motion";
3 |
4 | type CurveProps = {
5 | isVisible: boolean;
6 | };
7 |
8 | export default function Curve({ isVisible }: CurveProps) {
9 | const [dimensions, setDimensions] = useState({
10 | width: typeof window !== "undefined" ? window.innerWidth : 1920,
11 | height: typeof window !== "undefined" ? window.innerHeight : 1080,
12 | });
13 | const controls = useAnimationControls();
14 |
15 | useEffect(() => {
16 | function resize() {
17 | setDimensions({
18 | width: window.innerWidth,
19 | height: window.innerHeight,
20 | });
21 | }
22 | resize();
23 | window.addEventListener("resize", resize);
24 | return () => {
25 | window.removeEventListener("resize", resize);
26 | };
27 | }, []);
28 |
29 | useEffect(() => {
30 | if (isVisible) {
31 | controls.start("visible");
32 | } else {
33 | controls.start("hidden");
34 | }
35 | }, [isVisible, controls]);
36 |
37 | const curveHeight = dimensions.height * 0.1; // 10% of view height
38 |
39 | const initialPath = `
40 | M0 ${dimensions.height + curveHeight}
41 | Q${dimensions.width / 2} ${dimensions.height + curveHeight} ${
42 | dimensions.width
43 | } ${dimensions.height + curveHeight}
44 | L${dimensions.width} ${dimensions.height + curveHeight}
45 | L0 ${dimensions.height + curveHeight}
46 | `;
47 |
48 | const midPath = `
49 | M0 ${curveHeight}
50 | Q${dimensions.width / 2} 0 ${dimensions.width} ${curveHeight}
51 | L${dimensions.width} ${dimensions.height + curveHeight}
52 | L0 ${dimensions.height + curveHeight}
53 | `;
54 |
55 | const targetPath = `
56 | M0 ${-curveHeight}
57 | Q${dimensions.width / 2} ${-curveHeight * 2} ${
58 | dimensions.width
59 | } ${-curveHeight}
60 | L${dimensions.width} ${dimensions.height + curveHeight}
61 | L0 ${dimensions.height + curveHeight}
62 | `;
63 |
64 | const variants = {
65 | hidden: {
66 | d: [targetPath, midPath, initialPath],
67 | transition: {
68 | duration: 1.2,
69 | ease: [0.76, 0, 0.24, 1],
70 | times: [0, 0.4, 1],
71 | },
72 | },
73 | visible: {
74 | d: [initialPath, midPath, targetPath],
75 | transition: {
76 | duration: 1.2,
77 | ease: [0.76, 0, 0.24, 1],
78 | times: [0, 0.6, 1],
79 | },
80 | },
81 | };
82 |
83 | return (
84 |
90 |
96 |
102 |
103 |
104 | );
105 | }
106 |
--------------------------------------------------------------------------------
/src/components/projects/Overlay.tsx:
--------------------------------------------------------------------------------
1 | import { ArrowUpRight } from "lucide-react";
2 | import { Project } from ".";
3 | import { motion } from "framer-motion";
4 | import Magnetic from "../Magnetic";
5 |
6 | export default function Overlay({
7 | project,
8 | isMobile,
9 | }: {
10 | project: Project;
11 | isMobile: boolean;
12 | }) {
13 | return (
14 |
22 |
26 |
29 |
30 | {project.title}
31 |
32 |
33 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
49 |
50 |
51 | Description
52 |
53 |
54 |
55 | {project.description}
56 |
57 |
58 |
59 |
60 | Technologies
61 |
62 |
63 |
64 |
65 | Frontend:
66 | {project.technologies.frontend}
67 |
68 |
69 | Backend:
70 | {project.technologies.backend.includes("Not Involved") ? (
71 | Not Involved
72 | ) : (
73 | project.technologies.backend
74 | )}
75 |
76 |
77 |
78 |
79 |
87 | {project.title.includes("TCG") && (
88 |
89 | Disclaimer: This project was developed during my employment at
90 | TCG-Vault, where I contributed as part of the development team.
91 |
92 | )}
93 |
94 |
95 |
96 | );
97 | }
98 |
--------------------------------------------------------------------------------
/src/components/projects/index.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useState, useCallback } from "react";
2 | import {
3 | MotionValue,
4 | motion,
5 | AnimatePresence,
6 | useSpring,
7 | useAnimationControls,
8 | } from "framer-motion";
9 | import { useIsTouchDevice } from "../../hooks/useIsTouchDevice";
10 | import Curve from "./Curve";
11 | import Overlay from "./Overlay";
12 | import { X } from "lucide-react";
13 | import { useLenis } from "@studio-freight/react-lenis";
14 |
15 | type ProjectsSectionProps = {
16 | isProjectsInView: boolean;
17 | isMobile: boolean;
18 | backgroundGradient: MotionValue;
19 | };
20 |
21 | export type Project = {
22 | number: string;
23 | title: string;
24 | category: string;
25 | year: string;
26 | image: string;
27 | imageDetail: string;
28 | description: string;
29 | technologies: { frontend: string; backend: string };
30 | color: string;
31 | link: string;
32 | };
33 |
34 | const fadeInUpVariants = {
35 | hidden: { opacity: 0, y: 50 },
36 | visible: (custom: number) => ({
37 | opacity: 1,
38 | y: 0,
39 | transition: {
40 | duration: 0.6,
41 | ease: "easeOut",
42 | delay: custom * 0.2,
43 | type: "tween",
44 | useNativeDriver: true
45 | },
46 | }),
47 | exit: {
48 | opacity: 0,
49 | y: 50,
50 | transition: {
51 | duration: 0.4,
52 | ease: "easeIn",
53 | type: "tween",
54 | useNativeDriver: true
55 | },
56 | },
57 | };
58 |
59 | const Projects: React.FC = ({
60 | isProjectsInView,
61 | isMobile,
62 | backgroundGradient,
63 | }) => {
64 | const galleryRef = useRef(null);
65 | const imagesRef = useRef(null);
66 | const itemsRef = useRef(null);
67 | const [activeIndex, setActiveIndex] = useState(-1);
68 | const [isScrolling, setIsScrolling] = useState(false);
69 |
70 | const isTouchDevice = useIsTouchDevice();
71 |
72 | const projectsControls = useAnimationControls();
73 | const [hasAnimated, setHasAnimated] = useState(false);
74 |
75 | const cursorX = useSpring(0, { stiffness: 200, damping: 50 });
76 | const cursorY = useSpring(0, { stiffness: 200, damping: 50 });
77 |
78 | const projects: Project[] = [
79 | {
80 | number: "01",
81 | title: "MeetMate",
82 | category: "Web Development / Design",
83 | year: "2024-25",
84 | image: "./img/meetmate/landing.webp",
85 | imageDetail: "./img/meetmate/dashboard.webp",
86 | description:
87 | "MeetMate is a web application streamlining appointment management for businesses and clients. It simplifies scheduling, allowing clients to book with various companies while businesses manage availability efficiently. This approach reduces time spent on booking and organizing appointments for all parties.",
88 | color: "77, 128, 237",
89 | technologies: {
90 | frontend: "NextJS, TailwindCSS, ThreeJS",
91 | backend: "Spring Boot, GraphQL, PostgreSQL, MongoDB",
92 | },
93 | link: "https://meetmate.dev",
94 | },
95 | {
96 | number: "02",
97 | title: "fishtrack.",
98 | category: "iOS Development / Product Design",
99 | year: "2023-24",
100 | image: "./img/fishtrack/preview.webp",
101 | imageDetail: "./img/fishtrack/mockup.webp",
102 | description:
103 | "fishtrack is an iOS app for fishing enthusiasts to log and analyze their catches. It extracts date and location from photos, allows users to add fish details, and provides filtering options. Anglers can easily track their catches and view statistics, gaining insights into their fishing patterns over time.",
104 | technologies: {
105 | frontend: "Swift, SwiftUI, UIKit",
106 | backend: "Supabase",
107 | },
108 | color: "0 122 255",
109 | link: "https://github.com/bencodes07/fishtrackMobile",
110 | },
111 | {
112 | number: "03",
113 | title: "TCG-Home",
114 | category: "Frontend Development",
115 | year: "2021-Now",
116 | image: "./img/tcg/landing.webp",
117 | imageDetail: "./img/tcg/collection.webp",
118 | description: `TCG Home is an innovative online platform transforming the global niche market of collectible card games like "Magic: The Gathering". This project moves such games into the digital era by creating a comprehensive seamless portal where collecting, playing, and trading can take place.`,
119 | technologies: {
120 | frontend: "VueJS, Typescript, GraphQL",
121 | backend: "Not Involved",
122 | },
123 | color: "121 35 208",
124 | link: "https://tcg-home.com",
125 | },
126 | {
127 | number: "04",
128 | title: "Portfolio",
129 | category: "Web Development",
130 | year: "2024",
131 | image: "./img/portfolio/landing.webp",
132 | imageDetail: "./img/portfolio/about.webp",
133 | description:
134 | "This portfolio showcases a range of web development projects, demonstrating proficiency in creating practical, user-focused applications. From appointment management systems to specialized mobile apps, each project highlights problem-solving skills and technical expertise. Click the arrow to view the Figma Design",
135 | technologies: {
136 | frontend: "React, TailwindCSS, Framer Motion",
137 | backend: "N/A",
138 | },
139 | color: "255 255 255",
140 | link: "https://www.figma.com/design/fSOLXbVsHPG3k61ffrfFLQ/Portfolio?m=auto&t=KL4Fad6LDLPN60Us-1",
141 | },
142 | ];
143 |
144 | useEffect(() => {
145 | if (isProjectsInView && !hasAnimated) {
146 | projectsControls.start("visible");
147 | setTimeout(() => {
148 | setHasAnimated(true);
149 | }, 500);
150 | } else if (!isProjectsInView && hasAnimated) {
151 | projectsControls.start("hidden");
152 | setHasAnimated(false);
153 | }
154 | }, [isProjectsInView, projectsControls, hasAnimated, setHasAnimated]);
155 |
156 | // ----- Hover effect ----- //
157 |
158 | const handleMouseMove = useCallback(
159 | (e: MouseEvent) => {
160 | cursorX.set(e.clientX);
161 | cursorY.set(e.clientY);
162 | },
163 | [cursorX, cursorY],
164 | );
165 |
166 | const handleScroll = useCallback(() => {
167 | setIsScrolling(true);
168 | setTimeout(() => setIsScrolling(false), 100); // Debounce scrolling state
169 | }, []);
170 |
171 | useEffect(() => {
172 | if (isMobile) return;
173 |
174 | const items = itemsRef.current;
175 | if (!items) return;
176 |
177 | window.addEventListener("mousemove", handleMouseMove);
178 | window.addEventListener("scroll", handleScroll);
179 |
180 | const checkHover = () => {
181 | if (isScrolling) {
182 | const hoverItem = document.elementFromPoint(
183 | cursorX.get(),
184 | cursorY.get(),
185 | );
186 | const projectItem = hoverItem?.closest(".project-item");
187 | if (projectItem) {
188 | const index = Array.from(items.children).indexOf(
189 | projectItem as Element,
190 | );
191 | setActiveIndex(index);
192 | } else {
193 | setActiveIndex(-1);
194 | }
195 | }
196 | };
197 |
198 | items.addEventListener("mouseleave", () => {
199 | setActiveIndex(-1);
200 | });
201 |
202 | const scrollCheckInterval = setInterval(checkHover, 100);
203 |
204 | return () => {
205 | window.removeEventListener("mousemove", handleMouseMove);
206 | window.removeEventListener("scroll", handleScroll);
207 | clearInterval(scrollCheckInterval);
208 | };
209 | }, [isMobile, handleMouseMove, handleScroll, cursorX, cursorY, isScrolling]);
210 |
211 | // ----- Overlay ----- //
212 |
213 | const [isOverlayVisible, setIsOverlayVisible] = useState(false);
214 | const [selectedProject, setSelectedProject] = useState(null);
215 | const [isContentVisible, setIsContentVisible] = useState(false);
216 |
217 | const handleProjectClick = (project: Project) => {
218 | setSelectedProject(project);
219 | setIsOverlayVisible(true);
220 | };
221 |
222 | const closeOverlay = () => {
223 | setIsContentVisible(false);
224 | setTimeout(() => {
225 | setIsOverlayVisible(false);
226 | }, 800);
227 | };
228 |
229 | const lenis = useLenis();
230 |
231 | useEffect(() => {
232 | if (isOverlayVisible) {
233 | lenis?.stop();
234 | document.documentElement.style.overflowY = "hidden";
235 | const timer = setTimeout(() => {
236 | setIsContentVisible(true);
237 | }, 800);
238 | return () => clearTimeout(timer);
239 | } else {
240 | lenis?.start();
241 | document.documentElement.style.overflowY = "auto";
242 | }
243 | }, [isOverlayVisible]);
244 |
245 | // ----- Image Preloading ----- //
246 |
247 | useEffect(() => {
248 | projects.map((project: Project) => {
249 | const img = new Image();
250 | img.src = project.image;
251 |
252 | const img2 = new Image();
253 | img2.src = project.imageDetail;
254 | });
255 | }, []);
256 |
257 | const initialState = isMobile ? "visible" : "hidden";
258 |
259 | return (
260 |
269 | {isTouchDevice || (!isTouchDevice && isMobile) ? (
270 |
271 |
276 | Selected Projects
277 |
278 |
279 | {/* Mobile Version: Card like design */}
280 |
281 | {projects.map((project, index) => (
282 |
handleProjectClick(project)}
287 | custom={index + 1}
288 | >
289 |
294 | {project.title}
295 |
296 |
297 |
298 | {project.category}
299 |
300 |
{project.year}
301 |
302 |
303 | ))}
304 |
305 |
306 | ) : (
307 |
312 |
317 | Selected Projects
318 |
319 |
320 | {hasAnimated && (
321 |
322 | {activeIndex !== -1 && (
323 |
340 |
346 | {projects.map((project) => (
347 |
352 | ))}
353 |
354 |
355 | )}
356 |
357 | )}
358 |
359 |
363 | {projects.map((project, index) => (
364 |
setActiveIndex(index)}
369 | onClick={() => handleProjectClick(project)}
370 | variants={fadeInUpVariants}
371 | custom={index + 1}
372 | >
373 |
374 |
375 |
376 | {project.number}
377 |
378 |
379 | {project.title}
380 |
381 |
382 |
383 | {project.category}
384 |
385 |
386 |
387 |
388 | ))}
389 |
390 |
391 | )}
392 |
393 |
394 | {(isOverlayVisible || selectedProject) && (
395 | <>
396 |
397 | e.stopPropagation()}
404 | onTouchMove={(e) => e.stopPropagation()}
405 | >
406 |
407 | {isContentVisible && selectedProject && (
408 |
409 | )}
410 |
411 |
412 | {isContentVisible && (
413 |
417 |
418 |
419 | )}
420 | >
421 | )}
422 |
423 |
424 | );
425 | };
426 |
427 | export default Projects;
428 |
--------------------------------------------------------------------------------
/src/hooks/useColorAnimation.ts:
--------------------------------------------------------------------------------
1 | import { useMotionValue, useTransform } from "framer-motion";
2 | import { useCallback, useEffect } from "react";
3 |
4 | export const useColorAnimation = () => {
5 | const baseHue = useMotionValue(0);
6 |
7 | const mapHue = useCallback((h: number) => {
8 | // Map 0-360 to 0-270 (excluding yellow and green)
9 | h = h % 360;
10 | if (h < 60) return h; // Red to purple
11 | if (h < 180) return 60 + (h - 60) * (60 / 120); // Purple to blue
12 | return 120 + (h - 180) * (150 / 180); // Blue to red
13 | }, []);
14 |
15 | const hue1 = useTransform(baseHue, mapHue);
16 | const hue2 = useTransform(baseHue, (h) => mapHue((h + 60) % 360));
17 |
18 | useEffect(() => {
19 | const interval = setInterval(() => {
20 | baseHue.set(baseHue.get() - 1);
21 | }, 50);
22 |
23 | return () => clearInterval(interval);
24 | }, [baseHue]);
25 |
26 | return { hue1, hue2 };
27 | };
28 |
--------------------------------------------------------------------------------
/src/hooks/useIsTouchDevice.ts:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 |
3 | export function useIsTouchDevice() {
4 | const [isTouchDevice, setIsTouchDevice] = useState(false);
5 |
6 | useEffect(() => {
7 | const mediaQuery = window.matchMedia("(pointer: coarse)");
8 | setIsTouchDevice(mediaQuery.matches);
9 |
10 | const handleChange = (e: MediaQueryListEvent) => {
11 | setIsTouchDevice(e.matches);
12 | };
13 |
14 | mediaQuery.addListener(handleChange);
15 | return () => mediaQuery.removeListener(handleChange);
16 | }, []);
17 |
18 | return isTouchDevice;
19 | }
20 |
--------------------------------------------------------------------------------
/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom/client";
3 | import App from "./App.tsx";
4 | import { RouterProvider, createBrowserRouter } from "react-router-dom";
5 | import "./assets/globals.scss";
6 | import Redirect from "./components/Redirect.tsx";
7 |
8 | const router = createBrowserRouter([
9 | {
10 | path: "/",
11 | element: ,
12 | },
13 | {
14 | path: "*",
15 | element: ,
16 | },
17 | ]);
18 |
19 | ReactDOM.createRoot(document.getElementById("root")!).render(
20 |
21 |
22 | ,
23 | );
24 |
--------------------------------------------------------------------------------
/src/vite-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/tailwind.config.js:
--------------------------------------------------------------------------------
1 | /** @type {import('tailwindcss').Config} */
2 | export default {
3 | content: ["./src/**/*.{html,js,ts,jsx,tsx}", "./src/App.tsx"],
4 | theme: {
5 | extend: {
6 | backgroundImage: () => ({
7 | "landing-bg-image": "var(--landing-bg-image)",
8 | }),
9 | colors: {
10 | dark: "var(--dark)",
11 | light: "var(--light)",
12 |
13 | "gray-4": "var(--gray-4)",
14 | "gray-3": "var(--gray-3)",
15 | "gray-2": "var(--gray-2)",
16 | "gray-1": "var(--gray-1)",
17 |
18 | "svg-line": "var(--svg-line)",
19 | },
20 | },
21 | },
22 | plugins: [],
23 | };
24 |
--------------------------------------------------------------------------------
/tsconfig.app.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
5 | "target": "ES2020",
6 | "useDefineForClassFields": true,
7 | "lib": ["ES2020", "DOM", "DOM.Iterable"],
8 | "module": "ESNext",
9 | "skipLibCheck": true,
10 |
11 | /* Bundler mode */
12 | "moduleResolution": "bundler",
13 | "allowImportingTsExtensions": true,
14 | "resolveJsonModule": true,
15 | "isolatedModules": true,
16 | "moduleDetection": "force",
17 | "noEmit": true,
18 | "jsx": "react-jsx",
19 |
20 | /* Linting */
21 | "strict": true,
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true,
24 | "noFallthroughCasesInSwitch": true
25 | },
26 | "include": ["src"]
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "files": [],
3 | "references": [
4 | {
5 | "path": "./tsconfig.app.json"
6 | },
7 | {
8 | "path": "./tsconfig.node.json"
9 | }
10 | ]
11 | }
12 |
--------------------------------------------------------------------------------
/tsconfig.node.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "composite": true,
4 | "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
5 | "skipLibCheck": true,
6 | "module": "ESNext",
7 | "moduleResolution": "bundler",
8 | "allowSyntheticDefaultImports": true,
9 | "strict": true,
10 | "noEmit": true
11 | },
12 | "include": ["vite.config.ts"]
13 | }
14 |
--------------------------------------------------------------------------------
/vercel.json:
--------------------------------------------------------------------------------
1 | {
2 | "redirects": []
3 | }
4 |
--------------------------------------------------------------------------------
/vite.config.ts:
--------------------------------------------------------------------------------
1 | import { defineConfig } from 'vite'
2 | import react from '@vitejs/plugin-react-swc'
3 |
4 | // https://vitejs.dev/config/
5 | export default defineConfig({
6 | plugins: [react()],
7 | })
8 |
--------------------------------------------------------------------------------