diff --git a/app/globals.css b/app/globals.css index d33aef9..69539dd 100644 --- a/app/globals.css +++ b/app/globals.css @@ -62,6 +62,23 @@ body::before { will-change: transform; } +html.lenis, +html.lenis body { + height: auto; +} + +.lenis.lenis-smooth { + scroll-behavior: auto !important; +} + +.lenis.lenis-smooth [data-lenis-prevent] { + overscroll-behavior: contain; +} + +.lenis.lenis-stopped { + overflow: hidden; +} + .font-heading { font-family: "Space Grotesk", "Manrope", "Segoe UI", sans-serif; } diff --git a/app/layout.tsx b/app/layout.tsx index 996590f..0802fc1 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -3,6 +3,8 @@ import type { Metadata } from "next"; import "./globals.css"; import "currency-flags/dist/currency-flags.min.css"; +import { SmoothScrollProvider } from "@/components/providers/smooth-scroll-provider"; + export const metadata: Metadata = { title: "NexCurrency | Modern Currency & Crypto Converter", description: @@ -17,7 +19,7 @@ export default function RootLayout({ return ( - {children} + {children} ); diff --git a/components/providers/smooth-scroll-provider.tsx b/components/providers/smooth-scroll-provider.tsx new file mode 100644 index 0000000..f2d2933 --- /dev/null +++ b/components/providers/smooth-scroll-provider.tsx @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, type ReactNode } from "react"; +import { useReducedMotion } from "framer-motion"; +import Lenis from "lenis"; + +interface SmoothScrollProviderProps { + children: ReactNode; +} + +const DESKTOP_POINTER_QUERY = "(hover: hover) and (pointer: fine)"; +const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)"; + +export function SmoothScrollProvider({ children }: SmoothScrollProviderProps) { + const shouldReduceMotion = useReducedMotion(); + + useEffect(() => { + if (typeof window === "undefined") { + return; + } + + const desktopPointerMedia = window.matchMedia(DESKTOP_POINTER_QUERY); + const reducedMotionMedia = window.matchMedia(REDUCED_MOTION_QUERY); + + let lenis: Lenis | null = null; + let frameId = 0; + + const stopLenis = () => { + if (frameId) { + window.cancelAnimationFrame(frameId); + frameId = 0; + } + + if (lenis) { + lenis.destroy(); + lenis = null; + } + }; + + const startLenis = () => { + if (lenis) { + return; + } + + lenis = new Lenis({ + lerp: 0.085, + smoothWheel: true, + syncTouch: false, + wheelMultiplier: 0.92, + anchors: true, + prevent: (node) => { + if (!(node instanceof HTMLElement)) { + return false; + } + + return Boolean( + node.closest( + "[data-lenis-prevent], [data-radix-scroll-lock], [cmdk-list]", + ), + ); + }, + }); + + const raf = (time: number) => { + lenis?.raf(time); + frameId = window.requestAnimationFrame(raf); + }; + + frameId = window.requestAnimationFrame(raf); + }; + + const syncState = () => { + if ( + shouldReduceMotion || + reducedMotionMedia.matches || + !desktopPointerMedia.matches + ) { + stopLenis(); + return; + } + + startLenis(); + }; + + syncState(); + + const handleDesktopPointerChange = () => { + syncState(); + }; + const handleReducedMotionChange = () => { + syncState(); + }; + + desktopPointerMedia.addEventListener("change", handleDesktopPointerChange); + reducedMotionMedia.addEventListener("change", handleReducedMotionChange); + + return () => { + desktopPointerMedia.removeEventListener( + "change", + handleDesktopPointerChange, + ); + reducedMotionMedia.removeEventListener( + "change", + handleReducedMotionChange, + ); + stopLenis(); + }; + }, [shouldReduceMotion]); + + return children; +} diff --git a/components/ui/command.tsx b/components/ui/command.tsx index 581a52a..10c6b0e 100644 --- a/components/ui/command.tsx +++ b/components/ui/command.tsx @@ -45,6 +45,7 @@ const CommandList = React.forwardRef< >(({ className, ...props }, ref) => ( diff --git a/package-lock.json b/package-lock.json index 5c4ffac..19667da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16,6 +16,7 @@ "cryptocurrency-icons": "^0.18.1", "currency-flags": "github:vivekimsit/currency-flags", "framer-motion": "^12.36.0", + "lenis": "^1.3.18", "lucide-react": "^0.475.0", "next": "^14.2.24", "react": "^18.2.0", @@ -4564,6 +4565,32 @@ "node": ">=0.10" } }, + "node_modules/lenis": { + "version": "1.3.18", + "resolved": "https://registry.npmjs.org/lenis/-/lenis-1.3.18.tgz", + "integrity": "sha512-7KBl3V7vx5y1h05pu9fNFZS66I0+1eZ+zUGNNNBKtEn3BONZy+nkHWvdEe2b+zKT+6WX1x7zyOb1zbYYOs6tcg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/darkroomengineering" + }, + "peerDependencies": { + "@nuxt/kit": ">=3.0.0", + "react": ">=17.0.0", + "vue": ">=3.0.0" + }, + "peerDependenciesMeta": { + "@nuxt/kit": { + "optional": true + }, + "react": { + "optional": true + }, + "vue": { + "optional": true + } + } + }, "node_modules/levn": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", diff --git a/package.json b/package.json index 8c1a0ee..9b00e08 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "cryptocurrency-icons": "^0.18.1", "currency-flags": "github:vivekimsit/currency-flags", "framer-motion": "^12.36.0", + "lenis": "^1.3.18", "lucide-react": "^0.475.0", "next": "^14.2.24", "react": "^18.2.0",