diff --git a/package-lock.json b/package-lock.json index 9783948..0170220 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "hamburger-react": "^2.5.1", "lucide-react": "^0.395.0", "next": "14.2.4", + "nextjs-toploader": "^1.6.12", "react": "^18", "react-dom": "^18", "react-icons": "^5.2.1", @@ -3780,6 +3781,23 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/nextjs-toploader": { + "version": "1.6.12", + "resolved": "https://registry.npmjs.org/nextjs-toploader/-/nextjs-toploader-1.6.12.tgz", + "integrity": "sha512-nbun5lvVjlKnxLQlahzZ55nELVEduqoEXT03KCHnsEYJnFpI/3BaIzpMyq/v8C7UGU2NfxQmjq6ldZ310rsDqA==", + "dependencies": { + "nprogress": "^0.2.0", + "prop-types": "^15.8.1" + }, + "funding": { + "url": "https://github.com/sponsors/TheSGJ" + }, + "peerDependencies": { + "next": ">= 6.0.0", + "react": ">= 16.0.0", + "react-dom": ">= 16.0.0" + } + }, "node_modules/node-releases": { "version": "2.0.14", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", @@ -3803,6 +3821,11 @@ "node": ">=0.10.0" } }, + "node_modules/nprogress": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/nprogress/-/nprogress-0.2.0.tgz", + "integrity": "sha512-I19aIingLgR1fmhftnbWWO3dXc0hSxqHQHQb3H8m+K3TnEn/iSeTZZOyvKXWqQESMwuUVnatlCnZdLBZZt2VSA==" + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -4342,7 +4365,6 @@ "version": "15.8.1", "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", - "dev": true, "dependencies": { "loose-envify": "^1.4.0", "object-assign": "^4.1.1", @@ -4411,8 +4433,7 @@ "node_modules/react-is": { "version": "16.13.1", "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", - "dev": true + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==" }, "node_modules/react-simple-typewriter": { "version": "5.0.1", diff --git a/package.json b/package.json index cfa26ef..e06b4b9 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "hamburger-react": "^2.5.1", "lucide-react": "^0.395.0", "next": "14.2.4", + "nextjs-toploader": "^1.6.12", "react": "^18", "react-dom": "^18", "react-icons": "^5.2.1", diff --git a/src/app/layout.tsx b/src/app/layout.tsx index defdee5..89e5973 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -2,10 +2,12 @@ import type { Metadata } from 'next'; import { Inter } from 'next/font/google'; import './globals.css'; import { + Cursor, FooterComponent, HeaderComponent, PageRouteAnimation, } from '@/components'; +import NextTopLoader from 'nextjs-toploader'; const inter = Inter({ subsets: ['latin'] }); @@ -25,6 +27,9 @@ export default function RootLayout({ {children} + + + ); diff --git a/src/components/cursor.tsx b/src/components/cursor.tsx new file mode 100644 index 0000000..5ffca2a --- /dev/null +++ b/src/components/cursor.tsx @@ -0,0 +1,23 @@ +'use client'; + +import { useEffect, useRef } from 'react'; +import { loadCursor } from '@/utils/cursor'; + +export const Cursor = () => { + const ballCanvas = useRef(null); + + useEffect(() => { + if (typeof window === 'undefined' || !ballCanvas.current) { + return; + } + + return loadCursor(ballCanvas.current); + }, []); + + return ( +
+ ); +}; diff --git a/src/components/index.ts b/src/components/index.ts index 4d9888d..373e11a 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -5,3 +5,4 @@ export * from './page-route-animation'; export * from './hello-type-writer'; export * from './container'; export * from './pinned-repo-item'; +export * from './cursor'; diff --git a/src/utils/cursor.ts b/src/utils/cursor.ts new file mode 100644 index 0000000..d340b1e --- /dev/null +++ b/src/utils/cursor.ts @@ -0,0 +1,72 @@ +/** + * Begins the render loop for the ball under the cursor + * + * @param ball The HTML element to load the ball into + * @return A callback to remove all listeners. This is so that you can safely use this function inside of a useEffect. + */ +export function loadCursor(ball: HTMLDivElement) { + let x = window.innerWidth / 2; + let y = window.innerHeight / 2; + + let ballX = x; + let ballY = y; + + let hideTimeout: NodeJS.Timeout | null = null; + + function drawBall() { + ballX += (x - ballX) * 0.1 - 1; + ballY += (y - ballY) * 0.1 - 1; + + ball.style.top = `${ballY - window.scrollY}px`; + ball.style.left = `${ballX}px`; + } + + function loop() { + drawBall(); + requestAnimationFrame(loop); + } + + loop(); + + function touch(event: TouchEvent) { + x = event.touches[0].pageX; + y = event.touches[0].pageY; + } + + function mousemove(event: MouseEvent) { + ball.style.opacity = '1'; + + if (hideTimeout) { + clearTimeout(hideTimeout); + } + + x = event.pageX; + y = event.pageY; + + hideTimeout = setTimeout(() => { + ball.style.opacity = '0'; + }, 300); + } + + function mousedown() { + ball.style.transform = 'scale(2)'; + } + + function mouseup() { + ball.style.transform = 'scale(1)'; + } + + window.addEventListener('touchstart', touch); + window.addEventListener('touchmove', touch); + window.addEventListener('mousemove', mousemove); + window.addEventListener('mousedown', mousedown); + window.addEventListener('mouseup', mouseup); + + return () => { + window.removeEventListener('touchstart', touch); + window.removeEventListener('touchmove', touch); + window.removeEventListener('mousemove', mousemove); + window.removeEventListener('mousedown', mousedown); + window.removeEventListener('mouseup', mouseup); + }; +}