From b7f4ef6bf4004ef718fd7829ff7d7b8252acd1a6 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Thu, 3 Apr 2025 18:32:02 +0100 Subject: [PATCH 1/3] feat: animations, full width, smaller headers, more CtaCard padding --- frontend/package.json | 6 +- .../src/app/home/components/AnimatedStat.tsx | 104 ++++ frontend/src/app/home/components/Hero.tsx | 74 ++- .../app/home/components/HowKlerosWorks.tsx | 52 +- .../app/home/components/IcosahedronScene.tsx | 83 +++ .../app/home/components/ScrollIndicator.tsx | 24 + .../src/app/home/components/StatsSection.tsx | 15 + .../src/app/home/components/TrustedBy.tsx | 56 +- frontend/src/app/layout.tsx | 8 +- frontend/src/components/CtaCard.tsx | 4 +- frontend/src/components/Footer.tsx | 120 ++-- .../src/components/ui/AnimatedBackground.tsx | 153 +++++ frontend/tailwind.config.ts | 51 +- frontend/yarn.lock | 579 +++++++++++++++++- 14 files changed, 1173 insertions(+), 156 deletions(-) create mode 100644 frontend/src/app/home/components/AnimatedStat.tsx create mode 100644 frontend/src/app/home/components/IcosahedronScene.tsx create mode 100644 frontend/src/app/home/components/ScrollIndicator.tsx create mode 100644 frontend/src/app/home/components/StatsSection.tsx create mode 100644 frontend/src/components/ui/AnimatedBackground.tsx diff --git a/frontend/package.json b/frontend/package.json index da42b2e..5ac338b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,8 @@ "prepare": "husky" }, "dependencies": { + "@react-three/drei": "^10.0.5", + "@react-three/fiber": "^9.1.1", "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", "graphql": "^16.9.0", @@ -20,12 +22,14 @@ "next": "^15.1.4", "react": "^19.0.0", "react-dom": "^19.0.0", - "react-use": "^17.6.0" + "react-use": "^17.6.0", + "three": "^0.175.0" }, "devDependencies": { "@types/node": "^22.10.5", "@types/react": "^19.0.4", "@types/react-dom": "^19.0.2", + "@types/three": "^0.175.0", "eslint": "^8", "eslint-config-next": "^15.0.0", "eslint-config-prettier": "^9.1.0", diff --git a/frontend/src/app/home/components/AnimatedStat.tsx b/frontend/src/app/home/components/AnimatedStat.tsx new file mode 100644 index 0000000..f935522 --- /dev/null +++ b/frontend/src/app/home/components/AnimatedStat.tsx @@ -0,0 +1,104 @@ +"use client"; + +import { useEffect, useState } from "react"; +import clsx from "clsx"; + +interface AnimatedStatProps { + value: string; + label: string; + secondaryValue?: string; +} + +const glitchChars = "!<>-_\\/[]{}—=+*^?#0123456789"; + +function getRandomChar() { + return glitchChars[Math.floor(Math.random() * glitchChars.length)]; +} + +export function AnimatedStat({ value, label, secondaryValue }: AnimatedStatProps) { + const [displayedChars, setDisplayedChars] = useState( + Array(value.length).fill("X") + ); + const [currentIndex, setCurrentIndex] = useState(0); + const [isGlitching, setIsGlitching] = useState(false); + + useEffect(() => { + if (currentIndex >= value.length) return; + + // Glitch effect for current character + const glitchInterval = setInterval(() => { + setIsGlitching(prev => !prev); // Toggle glitch state for animation + setDisplayedChars(prev => { + const next = [...prev]; + // Only glitch characters after the current stable index + for (let i = currentIndex; i < value.length; i++) { + next[i] = getRandomChar(); + } + return next; + }); + }, 50); + + // Stabilize current character after delay and move to next + const stabilizeTimeout = setTimeout(() => { + setDisplayedChars(prev => { + const next = [...prev]; + next[currentIndex] = value[currentIndex]; + return next; + }); + setCurrentIndex(prev => prev + 1); + }, 300); // Time before moving to next character + + return () => { + clearInterval(glitchInterval); + clearTimeout(stabilizeTimeout); + }; + }, [currentIndex, value]); + + // Start the animation after initial delay + useEffect(() => { + const startDelay = setTimeout(() => { + setDisplayedChars(prev => { + const next = [...prev]; + next[0] = value[0]; // Stabilize first character immediately + return next; + }); + setCurrentIndex(1); // Start with second character + }, 500); + + return () => clearTimeout(startDelay); + }, [value]); + + return ( +
+
+

+ {displayedChars.map((char, index) => ( + = currentIndex && { + "animate-glitch-shift": isGlitching, + "relative before:absolute before:left-0 before:top-0 before:z-[-1] before:text-primary-purple before:opacity-50 before:content-[attr(data-char)] before:blur-[1px] after:absolute after:left-0 after:top-0 after:z-[-1] after:text-primary-blue after:opacity-50 after:content-[attr(data-char)] after:blur-[1px]": true, + "text-shadow-neon": true, + } + )} + data-char={char} + style={{ + transform: index >= currentIndex && isGlitching + ? `translate(${Math.random() * 1 - 0.5}px, ${Math.random() * 1 - 0.5}px)` + : 'none' + }} + > + {char} + + ))} +

+ {secondaryValue && ( +

{secondaryValue}

+ )} +
+

{label}

+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/home/components/Hero.tsx b/frontend/src/app/home/components/Hero.tsx index 448086a..5835a51 100644 --- a/frontend/src/app/home/components/Hero.tsx +++ b/frontend/src/app/home/components/Hero.tsx @@ -1,15 +1,14 @@ import React from "react"; -import Image from "next/image"; - import Button from "@/components/Button"; import CustomLink from "@/components/CustomLink"; import ExternalLink from "@/components/ExternalLink"; import { request } from "@/utils/graphQLClient"; import { HeroQueryType, heroQuery } from "../queries/hero"; - -import TokenStats from "./TokenStats"; +import { IcosahedronScene } from "./IcosahedronScene"; +import { ScrollIndicator } from "./ScrollIndicator"; +import { StatsSection } from "./StatsSection"; const Hero: React.FC = async () => { const heroData = await request(heroQuery); @@ -19,45 +18,44 @@ const Hero: React.FC = async () => { primaryButton, secondaryButton, arrowLink, - background, - tokenStats, } = heroData.homePageHero; return ( -
-
-

- {title} -

-

{subtitle}

-
- - - -
-
- - - +
+
+ +
+
+
+

+ {title} +

+

{subtitle}

+
+
+ + + +
+
+ + + +
+
+ +
- -
- Hero Image Background +
); }; diff --git a/frontend/src/app/home/components/HowKlerosWorks.tsx b/frontend/src/app/home/components/HowKlerosWorks.tsx index 9ce1d98..f913ba5 100644 --- a/frontend/src/app/home/components/HowKlerosWorks.tsx +++ b/frontend/src/app/home/components/HowKlerosWorks.tsx @@ -16,32 +16,34 @@ const HowKlerosWorks: React.FC = async () => { howKlerosWorks.homeHowKlerosWorksSection; return ( -
-
- -

- {title} -

-

{subtitle}

+
+
+
+ +

+ {title} +

+

{subtitle}

+
+
-
); }; diff --git a/frontend/src/app/home/components/IcosahedronScene.tsx b/frontend/src/app/home/components/IcosahedronScene.tsx new file mode 100644 index 0000000..5a7abf9 --- /dev/null +++ b/frontend/src/app/home/components/IcosahedronScene.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useRef } from "react"; +import { Canvas, useFrame } from "@react-three/fiber"; +import { Icosahedron } from "@react-three/drei"; +import * as THREE from "three"; + +function RotatingIcosahedron() { + const meshRef = useRef(null); + const groupRef = useRef(null); + + useFrame((state) => { + if (!meshRef.current || !groupRef.current) return; + + // Gentle floating motion + groupRef.current.position.y = Math.sin(state.clock.elapsedTime * 0.5) * 0.2; + + // Smooth rotation + meshRef.current.rotation.x = Math.sin(state.clock.elapsedTime * 0.5) * 0.2; + meshRef.current.rotation.y += 0.005; + }); + + return ( + + {/* Main wireframe icosahedron */} + + + + {/* Inner glow */} + + + + + {/* Outer glow */} + + + + + + ); +} + +export function IcosahedronScene() { + return ( +
+ + + + + + +
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/home/components/ScrollIndicator.tsx b/frontend/src/app/home/components/ScrollIndicator.tsx new file mode 100644 index 0000000..9999cfd --- /dev/null +++ b/frontend/src/app/home/components/ScrollIndicator.tsx @@ -0,0 +1,24 @@ +"use client"; + +export function ScrollIndicator() { + return ( +
+
+ Scroll + + + +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/home/components/StatsSection.tsx b/frontend/src/app/home/components/StatsSection.tsx new file mode 100644 index 0000000..7be05f2 --- /dev/null +++ b/frontend/src/app/home/components/StatsSection.tsx @@ -0,0 +1,15 @@ +"use client"; + +import { useScreenSize } from "@/hooks/useScreenSize"; +import { AnimatedStat } from "./AnimatedStat"; + +export function StatsSection() { + const screenSize = useScreenSize(); + + return screenSize === "lg" ? ( +
+ + +
+ ) : null; +} \ No newline at end of file diff --git a/frontend/src/app/home/components/TrustedBy.tsx b/frontend/src/app/home/components/TrustedBy.tsx index f27f6f7..5b23ecf 100644 --- a/frontend/src/app/home/components/TrustedBy.tsx +++ b/frontend/src/app/home/components/TrustedBy.tsx @@ -13,39 +13,41 @@ const TrustedBy: React.FC = async () => { await request(partnersQuery); return ( -
-

- Trusted By -

-
+
+
+

+ Trusted By +

+
+
+ + +
+
- - + {institutions.map(({ name, link, image }) => ( + + {name} + + ))}
-
- {institutions.map(({ name, link, image }) => ( - - {name} - - ))} -
); }; diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 5c3a260..19f19ee 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -5,6 +5,7 @@ import { Urbanist } from "next/font/google"; import Footer from "@/components/Footer"; import Navbar from "@/components/Navbar"; +import { AnimatedBackground } from "@/components/ui/AnimatedBackground"; import { HeroImagesQueryType, herosImagesQuery } from "@/queries/heroImages"; import { navbarQuery, NavbarQueryType } from "@/queries/navbar"; import "@/styles/globals.css"; @@ -39,10 +40,11 @@ export default async function RootLayout({ > ))} - -
+ + +
-
{children}
+ {children}
diff --git a/frontend/src/components/CtaCard.tsx b/frontend/src/components/CtaCard.tsx index 8b64c01..4fb0825 100644 --- a/frontend/src/components/CtaCard.tsx +++ b/frontend/src/components/CtaCard.tsx @@ -30,7 +30,7 @@ const CtaCard: React.FC = ({
{icon ? ( @@ -42,7 +42,7 @@ const CtaCard: React.FC = ({ alt="Icon" /> ) : null} -

+

{title}

diff --git a/frontend/src/components/Footer.tsx b/frontend/src/components/Footer.tsx index f8dbe21..879d0bd 100644 --- a/frontend/src/components/Footer.tsx +++ b/frontend/src/components/Footer.tsx @@ -20,65 +20,71 @@ const Footer: React.FC = async () => { cta: result.footerSubscribeCta, })); return ( -

-
-
- {sections.map(({ title, links }) => ( -
-

{title}

- {links.map(({ name, url }) => ( - - {name} - - ))} -
- ))} -
-
-
- {socials.map(({ name, icon_white: icon, url }) => ( - - {name} - - ))} +
+
+
+
+ {sections.map(({ title, links }) => ( +
+

{title}

+ {links.map(({ name, url }) => ( + + {name} + + ))} +
+ ))} +
+
+
+ {socials.map(({ name, icon_white: icon, url }) => ( + + {name} + + ))} +
-
- kleros logo -

- {" "} - {cta.notice}{" "} -

-
-

{cta.cta_text}

- +
+
+
+ kleros logo +

+ {" "} + {cta.notice}{" "} +

+
+

{cta.cta_text}

+ +
+
diff --git a/frontend/src/components/ui/AnimatedBackground.tsx b/frontend/src/components/ui/AnimatedBackground.tsx new file mode 100644 index 0000000..7b17b95 --- /dev/null +++ b/frontend/src/components/ui/AnimatedBackground.tsx @@ -0,0 +1,153 @@ +'use client'; + +import { useEffect, useRef } from 'react'; + +interface Particle { + x: number; + y: number; + vx: number; + vy: number; + radius: number; +} + +export function AnimatedBackground() { + const canvasRef = useRef(null); + const particlesRef = useRef([]); + const mouseRef = useRef({ x: 0, y: 0 }); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext('2d'); + if (!ctx) return; + + // Configuration + const config = { + particleCount: 80, + particleRadius: { min: 2, max: 4 }, + particleSpeed: { min: 0.2, max: 0.5 }, + connectionDistance: 150, + mouseRadius: 150, + }; + + // Set canvas size + const setCanvasSize = () => { + canvas.width = window.innerWidth; + canvas.height = window.innerHeight; + initParticles(); // Reinitialize particles when canvas size changes + }; + + // Initialize particles + const initParticles = () => { + particlesRef.current = Array.from({ length: config.particleCount }, () => ({ + x: Math.random() * canvas.width, + y: Math.random() * canvas.height, + vx: (Math.random() - 0.5) * config.particleSpeed.max, + vy: (Math.random() - 0.5) * config.particleSpeed.max, + radius: Math.random() * (config.particleRadius.max - config.particleRadius.min) + config.particleRadius.min + })); + }; + + // Update mouse position + const handleMouseMove = (e: MouseEvent) => { + const rect = canvas.getBoundingClientRect(); + mouseRef.current = { + x: e.clientX - rect.left, + y: e.clientY - rect.top + }; + }; + + // Animation loop + function animate() { + if (!ctx || !canvas) return; + + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // Update and draw particles + particlesRef.current.forEach((particle, i) => { + // Update position + particle.x += particle.vx; + particle.y += particle.vy; + + // Bounce off edges + if (particle.x < 0 || particle.x > canvas.width) particle.vx *= -1; + if (particle.y < 0 || particle.y > canvas.height) particle.vy *= -1; + + // Keep particles within bounds + particle.x = Math.max(0, Math.min(canvas.width, particle.x)); + particle.y = Math.max(0, Math.min(canvas.height, particle.y)); + + // Draw connections + for (let j = i + 1; j < particlesRef.current.length; j++) { + const other = particlesRef.current[j]; + const dx = other.x - particle.x; + const dy = other.y - particle.y; + const distance = Math.sqrt(dx * dx + dy * dy); + + if (distance < config.connectionDistance) { + const opacity = 1 - (distance / config.connectionDistance); + ctx.beginPath(); + ctx.strokeStyle = `rgba(151, 71, 255, ${opacity * 0.5})`; + ctx.lineWidth = 1; + ctx.moveTo(particle.x, particle.y); + ctx.lineTo(other.x, other.y); + ctx.stroke(); + } + } + + // Mouse interaction + const dx = mouseRef.current.x - particle.x; + const dy = mouseRef.current.y - particle.y; + const mouseDistance = Math.sqrt(dx * dx + dy * dy); + + if (mouseDistance < config.mouseRadius) { + const force = (config.mouseRadius - mouseDistance) / config.mouseRadius; + particle.vx += (dx / mouseDistance) * force * 0.02; + particle.vy += (dy / mouseDistance) * force * 0.02; + } + + // Speed limit + const speed = Math.sqrt(particle.vx * particle.vx + particle.vy * particle.vy); + if (speed > config.particleSpeed.max) { + particle.vx = (particle.vx / speed) * config.particleSpeed.max; + particle.vy = (particle.vy / speed) * config.particleSpeed.max; + } + + // Draw particle + ctx.beginPath(); + const gradient = ctx.createRadialGradient( + particle.x, particle.y, 0, + particle.x, particle.y, particle.radius * 2 + ); + gradient.addColorStop(0, 'rgba(151, 71, 255, 0.8)'); + gradient.addColorStop(1, 'rgba(151, 71, 255, 0)'); + ctx.fillStyle = gradient; + ctx.arc(particle.x, particle.y, particle.radius, 0, Math.PI * 2); + ctx.fill(); + }); + + requestAnimationFrame(animate); + } + + // Initialize + setCanvasSize(); + window.addEventListener('resize', setCanvasSize); + canvas.addEventListener('mousemove', handleMouseMove); + animate(); + + // Cleanup + return () => { + window.removeEventListener('resize', setCanvasSize); + canvas.removeEventListener('mousemove', handleMouseMove); + }; + }, []); + + return ( +
); -} \ No newline at end of file +} diff --git a/frontend/src/app/home/components/Hero.tsx b/frontend/src/app/home/components/Hero.tsx index 5835a51..c4e81bd 100644 --- a/frontend/src/app/home/components/Hero.tsx +++ b/frontend/src/app/home/components/Hero.tsx @@ -6,19 +6,15 @@ import ExternalLink from "@/components/ExternalLink"; import { request } from "@/utils/graphQLClient"; import { HeroQueryType, heroQuery } from "../queries/hero"; + import { IcosahedronScene } from "./IcosahedronScene"; import { ScrollIndicator } from "./ScrollIndicator"; import { StatsSection } from "./StatsSection"; const Hero: React.FC = async () => { const heroData = await request(heroQuery); - const { - title, - subtitle, - primaryButton, - secondaryButton, - arrowLink, - } = heroData.homePageHero; + const { title, subtitle, primaryButton, secondaryButton, arrowLink } = + heroData.homePageHero; return (
@@ -27,15 +23,20 @@ const Hero: React.FC = async () => {
-

+

{title}

-

{subtitle}

+

+ {subtitle} +

diff --git a/frontend/src/app/home/components/HowKlerosWorks.tsx b/frontend/src/app/home/components/HowKlerosWorks.tsx index f913ba5..148d483 100644 --- a/frontend/src/app/home/components/HowKlerosWorks.tsx +++ b/frontend/src/app/home/components/HowKlerosWorks.tsx @@ -16,7 +16,7 @@ const HowKlerosWorks: React.FC = async () => { howKlerosWorks.homeHowKlerosWorksSection; return ( -
+
); -} \ No newline at end of file +} diff --git a/frontend/src/app/home/components/ScrollIndicator.tsx b/frontend/src/app/home/components/ScrollIndicator.tsx index 9999cfd..7cab4e4 100644 --- a/frontend/src/app/home/components/ScrollIndicator.tsx +++ b/frontend/src/app/home/components/ScrollIndicator.tsx @@ -21,4 +21,4 @@ export function ScrollIndicator() {
); -} \ No newline at end of file +} diff --git a/frontend/src/app/home/components/StatsSection.tsx b/frontend/src/app/home/components/StatsSection.tsx index 7be05f2..94c45f5 100644 --- a/frontend/src/app/home/components/StatsSection.tsx +++ b/frontend/src/app/home/components/StatsSection.tsx @@ -1,15 +1,16 @@ "use client"; import { useScreenSize } from "@/hooks/useScreenSize"; + import { AnimatedStat } from "./AnimatedStat"; export function StatsSection() { const screenSize = useScreenSize(); - + return screenSize === "lg" ? (
) : null; -} \ No newline at end of file +} diff --git a/frontend/src/app/home/components/TrustedBy.tsx b/frontend/src/app/home/components/TrustedBy.tsx index 5b23ecf..75193db 100644 --- a/frontend/src/app/home/components/TrustedBy.tsx +++ b/frontend/src/app/home/components/TrustedBy.tsx @@ -13,7 +13,7 @@ const TrustedBy: React.FC = async () => { await request(partnersQuery); return ( -
+

Trusted By diff --git a/frontend/src/app/layout.tsx b/frontend/src/app/layout.tsx index 19f19ee..f8ca114 100644 --- a/frontend/src/app/layout.tsx +++ b/frontend/src/app/layout.tsx @@ -42,7 +42,7 @@ export default async function RootLayout({ -
+
{children}