feat: implement Lenis smooth scroll and animated stat counters
- Add Lenis smooth scroll library (Landing page only) - Create SmoothScroll wrapper component with GSAP integration - Implement animated stat counters with scroll-triggered count-up - Add GSAP animations for stats section (40%, 5yrs, 15m) - Update package.json with lenis dependency
This commit is contained in:
@@ -15,9 +15,11 @@
|
|||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.34.0",
|
||||||
"groq-sdk": "^0.37.0",
|
"groq-sdk": "^0.37.0",
|
||||||
"gsap": "^3.14.2",
|
"gsap": "^3.14.2",
|
||||||
"leaflet": "^1.9.4",
|
"leaflet": "^1.9.4",
|
||||||
|
"lenis": "^1.3.17",
|
||||||
"lucide-react": "^0.563.0",
|
"lucide-react": "^0.563.0",
|
||||||
"postcss": "^8.5.6",
|
"postcss": "^8.5.6",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
|
|||||||
Generated
+64
@@ -20,6 +20,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
|
framer-motion:
|
||||||
|
specifier: ^12.34.0
|
||||||
|
version: 12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||||
groq-sdk:
|
groq-sdk:
|
||||||
specifier: ^0.37.0
|
specifier: ^0.37.0
|
||||||
version: 0.37.0
|
version: 0.37.0
|
||||||
@@ -29,6 +32,9 @@ importers:
|
|||||||
leaflet:
|
leaflet:
|
||||||
specifier: ^1.9.4
|
specifier: ^1.9.4
|
||||||
version: 1.9.4
|
version: 1.9.4
|
||||||
|
lenis:
|
||||||
|
specifier: ^1.3.17
|
||||||
|
version: 1.3.17(react@19.2.4)
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^0.563.0
|
specifier: ^0.563.0
|
||||||
version: 0.563.0(react@19.2.4)
|
version: 0.563.0(react@19.2.4)
|
||||||
@@ -1089,6 +1095,20 @@ packages:
|
|||||||
fraction.js@5.3.4:
|
fraction.js@5.3.4:
|
||||||
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
resolution: {integrity: sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==}
|
||||||
|
|
||||||
|
framer-motion@12.34.0:
|
||||||
|
resolution: {integrity: sha512-+/H49owhzkzQyxtn7nZeF4kdH++I2FWrESQ184Zbcw5cEqNHYkE5yxWxcTLSj5lNx3NWdbIRy5FHqUvetD8FWg==}
|
||||||
|
peerDependencies:
|
||||||
|
'@emotion/is-prop-valid': '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
react-dom: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
'@emotion/is-prop-valid':
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
react-dom:
|
||||||
|
optional: true
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@@ -1269,6 +1289,20 @@ packages:
|
|||||||
leaflet@1.9.4:
|
leaflet@1.9.4:
|
||||||
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
resolution: {integrity: sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==}
|
||||||
|
|
||||||
|
lenis@1.3.17:
|
||||||
|
resolution: {integrity: sha512-k9T9rgcxne49ggJOvXCraWn5dt7u2mO+BNkhyu6yxuEnm9c092kAW5Bus5SO211zUvx7aCCEtzy9UWr0RB+oJw==}
|
||||||
|
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
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -1408,6 +1442,12 @@ packages:
|
|||||||
minimatch@3.1.2:
|
minimatch@3.1.2:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||||
|
|
||||||
|
motion-dom@12.34.0:
|
||||||
|
resolution: {integrity: sha512-Lql3NuEcScRDxTAO6GgUsRHBZOWI/3fnMlkMcH5NftzcN37zJta+bpbMAV9px4Nj057TuvRooMK7QrzMCgtz6Q==}
|
||||||
|
|
||||||
|
motion-utils@12.29.2:
|
||||||
|
resolution: {integrity: sha512-G3kc34H2cX2gI63RqU+cZq+zWRRPSsNIOjpdl9TN4AQwC4sgwYPl/Q/Obf/d53nOm569T0fYK+tcoSV50BWx8A==}
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@@ -1767,6 +1807,9 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
tslib@2.8.1:
|
||||||
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
@@ -2801,6 +2844,15 @@ snapshots:
|
|||||||
|
|
||||||
fraction.js@5.3.4: {}
|
fraction.js@5.3.4: {}
|
||||||
|
|
||||||
|
framer-motion@12.34.0(react-dom@19.2.4(react@19.2.4))(react@19.2.4):
|
||||||
|
dependencies:
|
||||||
|
motion-dom: 12.34.0
|
||||||
|
motion-utils: 12.29.2
|
||||||
|
tslib: 2.8.1
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
react-dom: 19.2.4(react@19.2.4)
|
||||||
|
|
||||||
fsevents@2.3.3:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -2974,6 +3026,10 @@ snapshots:
|
|||||||
|
|
||||||
leaflet@1.9.4: {}
|
leaflet@1.9.4: {}
|
||||||
|
|
||||||
|
lenis@1.3.17(react@19.2.4):
|
||||||
|
optionalDependencies:
|
||||||
|
react: 19.2.4
|
||||||
|
|
||||||
levn@0.4.1:
|
levn@0.4.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
@@ -3240,6 +3296,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
|
|
||||||
|
motion-dom@12.34.0:
|
||||||
|
dependencies:
|
||||||
|
motion-utils: 12.29.2
|
||||||
|
|
||||||
|
motion-utils@12.29.2: {}
|
||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
mz@2.7.0:
|
mz@2.7.0:
|
||||||
@@ -3624,6 +3686,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
type-check@0.4.0:
|
type-check@0.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
prelude-ls: 1.2.1
|
prelude-ls: 1.2.1
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import AdminSchedule from './pages/AdminSchedule';
|
|||||||
import CustomerProfile from './pages/CustomerProfile';
|
import CustomerProfile from './pages/CustomerProfile';
|
||||||
import LeaderboardPage from './pages/LeaderboardPage';
|
import LeaderboardPage from './pages/LeaderboardPage';
|
||||||
import ErrorBoundary from './components/ErrorBoundary';
|
import ErrorBoundary from './components/ErrorBoundary';
|
||||||
|
import SmoothScroll from './components/SmoothScroll';
|
||||||
import { Toaster } from 'sonner';
|
import { Toaster } from 'sonner';
|
||||||
import NotFound from './pages/NotFound';
|
import NotFound from './pages/NotFound';
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ function App() {
|
|||||||
return (
|
return (
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<Toaster position="top-right" richColors closeButton expand={true} />
|
<Toaster position="top-right" richColors closeButton expand={true} />
|
||||||
|
<SmoothScroll>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
{/* Public Routes */}
|
{/* Public Routes */}
|
||||||
@@ -88,6 +90,7 @@ function App() {
|
|||||||
{/* Catch all */}
|
{/* Catch all */}
|
||||||
<Route path="*" element={<NotFound />} />
|
<Route path="*" element={<NotFound />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
</SmoothScroll>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { useLocation } from 'react-router-dom';
|
||||||
|
import Lenis from 'lenis';
|
||||||
|
import { gsap } from 'gsap';
|
||||||
|
import { ScrollTrigger } from 'gsap/ScrollTrigger';
|
||||||
|
|
||||||
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SmoothScroll - Conditional Lenis smooth scroll wrapper
|
||||||
|
*
|
||||||
|
* Provides buttery-smooth momentum scrolling for the Landing page only.
|
||||||
|
* Integrates seamlessly with GSAP ScrollTrigger animations.
|
||||||
|
*
|
||||||
|
* Usage: Wrap your app routes with <SmoothScroll>
|
||||||
|
*/
|
||||||
|
export default function SmoothScroll({ children }) {
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
|
// Only enable smooth scroll on Landing page
|
||||||
|
const isLandingPage = location.pathname === '/';
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// Skip initialization if not on Landing page
|
||||||
|
if (!isLandingPage) return;
|
||||||
|
|
||||||
|
// Initialize Lenis with optimal settings
|
||||||
|
const lenis = new Lenis({
|
||||||
|
duration: 1.2, // Scroll duration (higher = smoother but slower)
|
||||||
|
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)), // Easing function
|
||||||
|
orientation: 'vertical', // Vertical scrolling
|
||||||
|
gestureOrientation: 'vertical',
|
||||||
|
smoothWheel: true, // Smooth mouse wheel scrolling
|
||||||
|
wheelMultiplier: 1, // Mouse wheel sensitivity
|
||||||
|
smoothTouch: false, // Disable on touch devices (better native feel)
|
||||||
|
touchMultiplier: 2,
|
||||||
|
infinite: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Integrate Lenis with GSAP ScrollTrigger
|
||||||
|
lenis.on('scroll', ScrollTrigger.update);
|
||||||
|
|
||||||
|
// Add Lenis to GSAP ticker for smooth updates
|
||||||
|
gsap.ticker.add((time) => {
|
||||||
|
lenis.raf(time * 1000); // Convert GSAP time to milliseconds
|
||||||
|
});
|
||||||
|
|
||||||
|
// Disable GSAP's lag smoothing to prevent conflicts
|
||||||
|
gsap.ticker.lagSmoothing(0);
|
||||||
|
|
||||||
|
// Cleanup on unmount or route change
|
||||||
|
return () => {
|
||||||
|
lenis.destroy();
|
||||||
|
gsap.ticker.remove(lenis.raf);
|
||||||
|
};
|
||||||
|
}, [isLandingPage]); // Re-run when route changes
|
||||||
|
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
+53
-3
@@ -25,6 +25,7 @@ const Landing = () => {
|
|||||||
const heroRef = useRef(null);
|
const heroRef = useRef(null);
|
||||||
const textRef = useRef(null);
|
const textRef = useRef(null);
|
||||||
const sectionsRef = useRef([]);
|
const sectionsRef = useRef([]);
|
||||||
|
const statRefs = useRef([]);
|
||||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -55,6 +56,34 @@ const Landing = () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Animated Stat Counters
|
||||||
|
statRefs.current.forEach((el) => {
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
const target = parseFloat(el.dataset.target);
|
||||||
|
const suffix = el.dataset.suffix || '';
|
||||||
|
|
||||||
|
gsap.fromTo(el,
|
||||||
|
{ innerText: 0 },
|
||||||
|
{
|
||||||
|
innerText: target,
|
||||||
|
duration: 2,
|
||||||
|
ease: "power1.out",
|
||||||
|
scrollTrigger: {
|
||||||
|
trigger: el,
|
||||||
|
start: "top 85%",
|
||||||
|
toggleActions: "play none none none",
|
||||||
|
},
|
||||||
|
snap: { innerText: suffix === '%' ? 1 : 0.1 },
|
||||||
|
onUpdate: function () {
|
||||||
|
const value = this.targets()[0].innerText;
|
||||||
|
const formattedValue = suffix === '%' ? Math.ceil(value) : Math.ceil(value);
|
||||||
|
el.innerText = formattedValue + suffix;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const addToRefs = (el) => {
|
const addToRefs = (el) => {
|
||||||
@@ -325,17 +354,38 @@ const Landing = () => {
|
|||||||
|
|
||||||
<div className="grid md:grid-cols-3 gap-8">
|
<div className="grid md:grid-cols-3 gap-8">
|
||||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||||
<div className="text-4xl font-black text-red-500 mb-2">40%</div>
|
<div
|
||||||
|
ref={(el) => statRefs.current[0] = el}
|
||||||
|
data-target="40"
|
||||||
|
data-suffix="%"
|
||||||
|
className="text-4xl font-black text-red-500 mb-2"
|
||||||
|
>
|
||||||
|
0%
|
||||||
|
</div>
|
||||||
<h3 className="font-bold text-xl mb-2">Insurance Denials</h3>
|
<h3 className="font-bold text-xl mb-2">Insurance Denials</h3>
|
||||||
<p className="text-zinc-400 text-sm">Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.</p>
|
<p className="text-zinc-400 text-sm">Of claims are denied due to "lack of maintenance" if proof of regular inspection isn't provided.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||||
<div className="text-4xl font-black text-yellow-500 mb-2">5yrs</div>
|
<div
|
||||||
|
ref={(el) => statRefs.current[1] = el}
|
||||||
|
data-target="5"
|
||||||
|
data-suffix="yrs"
|
||||||
|
className="text-4xl font-black text-yellow-500 mb-2"
|
||||||
|
>
|
||||||
|
0yrs
|
||||||
|
</div>
|
||||||
<h3 className="font-bold text-xl mb-2">Life Reduced</h3>
|
<h3 className="font-bold text-xl mb-2">Life Reduced</h3>
|
||||||
<p className="text-zinc-400 text-sm">A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.</p>
|
<p className="text-zinc-400 text-sm">A minor leak left for 6 months can reduce your roof's lifespan by up to 5 years.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
<div className="bg-white/5 p-8 rounded-3xl border border-white/10 hover:bg-white/10 transition-colors">
|
||||||
<div className="text-4xl font-black text-green-500 mb-2">15m</div>
|
<div
|
||||||
|
ref={(el) => statRefs.current[2] = el}
|
||||||
|
data-target="15"
|
||||||
|
data-suffix="m"
|
||||||
|
className="text-4xl font-black text-green-500 mb-2"
|
||||||
|
>
|
||||||
|
0m
|
||||||
|
</div>
|
||||||
<h3 className="font-bold text-xl mb-2">Our Estimate Time</h3>
|
<h3 className="font-bold text-xl mb-2">Our Estimate Time</h3>
|
||||||
<p className="text-zinc-400 text-sm">While others take days, we give you a quote before our drone even lands.</p>
|
<p className="text-zinc-400 text-sm">While others take days, we give you a quote before our drone even lands.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user