From 2efc1fe36adbada405a18a1ac85611c6c5958c85 Mon Sep 17 00:00:00 2001 From: Brandon Saldan <26472557+brandonsaldan@users.noreply.github.com> Date: Wed, 1 Jan 2025 00:17:25 -0500 Subject: [PATCH] feat(ui): add animations --- src/app/page.tsx | 124 +++++++++++++++++++---------- src/components/bento-card.tsx | 8 ++ src/components/interstate-rows.tsx | 53 +++++++++++- src/components/logo-cluster.tsx | 101 +++++++++++++++++++++-- src/components/map.tsx | 33 ++++++++ src/components/testimonials.tsx | 4 +- src/hooks/useScrollAnimation.ts | 57 +++++++++++++ 7 files changed, 331 insertions(+), 49 deletions(-) create mode 100644 src/hooks/useScrollAnimation.ts diff --git a/src/app/page.tsx b/src/app/page.tsx index 1ae691b..d75a1aa 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -16,6 +16,7 @@ import { Heading, Subheading } from '@/components/text' import { ChevronRightIcon } from '@heroicons/react/16/solid' import dynamic from 'next/dynamic' import 'prismjs/themes/prism.css' +import { useEffect, useRef } from 'react' const CodeBlock = dynamic(() => import('@/components/code-block'), { ssr: false, @@ -116,6 +117,45 @@ function BentoSection() { } function SupportCTA() { + const ctaRef = useRef(null) + const imageRef = useRef(null) + + useEffect(() => { + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting) { + if (ctaRef.current) { + ctaRef.current.classList.remove('opacity-0', 'scale-95') + ctaRef.current.classList.add('opacity-100', 'scale-100') + } + if (imageRef.current) { + setTimeout(() => { + if (imageRef.current) { + imageRef.current.classList.remove( + 'opacity-0', + 'translate-x-8', + ) + imageRef.current.classList.add('opacity-100', 'translate-x-0') + } + }, 200) + } + observer.unobserve(entry.target) + } + }) + }, + { + threshold: 0.2, + }, + ) + + if (ctaRef.current) { + observer.observe(ctaRef.current) + } + + return () => observer.disconnect() + }, []) + return ( Support Nocturne @@ -124,49 +164,53 @@ function SupportCTA() {
-
-
-

- Choose Your Support Method -

-

- Nocturne is a community-driven initiative. If you find it - valuable, consider supporting our work through a donation. All - contributions go towards development and maintenance. -

- -

- Learn more about our mission → +

+
+
+

+ Choose Your Support Method +

+

+ Nocturne is a community-driven initiative. If you find it + valuable, consider supporting our work through a donation. All + contributions go towards development and maintenance.

- -
-
- -
-
- + +

+ Learn more about our mission → +

+ +
+
+ +
+
+ +
-
-
- Nocturne Screenshot +
+ Nocturne Screenshot +
@@ -179,7 +223,7 @@ export default function Home() {
-
+
diff --git a/src/components/bento-card.tsx b/src/components/bento-card.tsx index 9e8516a..798b8b7 100644 --- a/src/components/bento-card.tsx +++ b/src/components/bento-card.tsx @@ -1,5 +1,6 @@ 'use client' +import { useScrollAnimation } from '@/hooks/useScrollAnimation' import { clsx } from 'clsx' import { motion } from 'framer-motion' import { Subheading } from './text' @@ -21,8 +22,14 @@ export function BentoCard({ graphic: React.ReactNode fade?: ('top' | 'bottom')[] }) { + const cardRef = useScrollAnimation({ + y: 8, + threshold: 0.2, + }) + return (
{graphic}
diff --git a/src/components/interstate-rows.tsx b/src/components/interstate-rows.tsx index 1c0cb53..1c46846 100644 --- a/src/components/interstate-rows.tsx +++ b/src/components/interstate-rows.tsx @@ -1,4 +1,5 @@ import { clsx } from 'clsx' +import { useEffect, useRef, useState } from 'react' function Row({ children }: { children: React.ReactNode }) { return ( @@ -14,10 +15,12 @@ function Logo({ label, src, className, + isActive, }: { label: string src: string className: string + isActive: boolean }) { return (
@@ -35,6 +42,38 @@ function Logo({ } export function InterstateRows() { + const [isInView, setIsInView] = useState(false) + const [isMobile, setIsMobile] = useState(false) + const hasAnimated = useRef(false) + const containerRef = useRef(null) + + useEffect(() => { + setIsMobile(window.innerWidth < 640) + }, []) + + useEffect(() => { + if (!isMobile || hasAnimated.current) return + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasAnimated.current) { + setIsInView(true) + hasAnimated.current = true + observer.disconnect() + } + }) + }, + { threshold: 0.3 }, + ) + + if (containerRef.current) { + observer.observe(containerRef.current) + } + + return () => observer.disconnect() + }, [isMobile]) + return (