From 32a90845df7bf9f89b1ed713721889e09e4db7ba Mon Sep 17 00:00:00 2001 From: Marius Ahmsus Date: Sat, 21 Mar 2026 16:54:26 +0100 Subject: [PATCH] feat: improve animations, navbar, accordion, hero grainient, featurecard & cta layers --- src/components/animations/NodesAnimation.tsx | 61 +-- .../animations/RoleSystemAnimation.tsx | 62 +-- src/components/blog/TableOfContents.tsx | 119 ++++-- src/components/cards/FeatureCard.tsx | 8 +- src/components/cards/TeamMemberCard.tsx | 18 +- src/components/navigation/NavTab.tsx | 14 +- src/components/navigation/Navigation.tsx | 37 +- .../navigation/NavigationDesktop.tsx | 220 +++++++--- .../navigation/NavigationMobile.tsx | 377 ++++++++++-------- src/components/sections/CtaSection.tsx | 7 +- src/components/sections/HeroSection.tsx | 6 +- src/components/ui/Accordion.tsx | 24 +- src/components/ui/Granient.tsx | 88 +++- 13 files changed, 649 insertions(+), 392 deletions(-) diff --git a/src/components/animations/NodesAnimation.tsx b/src/components/animations/NodesAnimation.tsx index e8f1d1b..ee857f6 100644 --- a/src/components/animations/NodesAnimation.tsx +++ b/src/components/animations/NodesAnimation.tsx @@ -2,7 +2,7 @@ import { Card, Flex, Text } from "@code0-tech/pictor" import { IconNote } from "@tabler/icons-react" -import { animate, m as motion, useInView, useMotionValue, useReducedMotion } from "motion/react" +import { useInView, useReducedMotion } from "motion/react" import { useEffect, useRef, useState } from "react" import { LiteralBadge } from "../badges/LiteralBadge" import { ReferenceBadge } from "../badges/ReferenceBadge" @@ -33,8 +33,8 @@ function NodeRow({ }) { const listRef = useRef(null) const [loopDistance, setLoopDistance] = useState(0) - const x = useMotionValue(direction === "left" ? 0 : -loopDistance) const groupGap = 16 + const velocity = 28 const iconColorMap: Record = { brand: "var(--text-brand)", yellow: "var(--text-yellow)", @@ -64,53 +64,30 @@ function NodeRow({ const listElement = listRef.current if (!listElement) return - let frame = 0 - let previousDistance = 0 + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return - const updateLoopDistance = () => { - const nextDistance = Math.round(listElement.getBoundingClientRect().width + groupGap) - if (nextDistance !== previousDistance) { - previousDistance = nextDistance - setLoopDistance(nextDistance) - } - } - - updateLoopDistance() - - const resizeObserver = new ResizeObserver(() => { - cancelAnimationFrame(frame) - frame = requestAnimationFrame(updateLoopDistance) + setLoopDistance(Math.round(entry.contentRect.width + groupGap)) }) resizeObserver.observe(listElement) - return () => { - cancelAnimationFrame(frame) - resizeObserver.disconnect() - } + return () => resizeObserver.disconnect() }, [nodes.length]) - useEffect(() => { - if (!loopDistance || !active) { - x.set(direction === "left" ? 0 : -loopDistance) - return - } - - const controls = animate( - x, - direction === "left" ? [0, -loopDistance] : [-loopDistance, 0], - { - duration: 45, - ease: "linear", - repeat: Infinity, - repeatType: "loop", - }, - ) - - return () => controls.stop() - }, [active, direction, loopDistance, x]) + const duration = loopDistance > 0 ? loopDistance / velocity : 0 return ( - +
0 ? { + animationName: direction === "left" ? "node-marquee-left" : "node-marquee-right", + animationDuration: `${duration}s`, + animationTimingFunction: "linear", + animationIterationCount: "infinite", + animationPlayState: active ? "running" : "paused", + } : undefined} + >
{nodes.map((node, index) => ( ))}
- +
) } diff --git a/src/components/animations/RoleSystemAnimation.tsx b/src/components/animations/RoleSystemAnimation.tsx index 8f47a39..85f040a 100644 --- a/src/components/animations/RoleSystemAnimation.tsx +++ b/src/components/animations/RoleSystemAnimation.tsx @@ -1,7 +1,7 @@ "use client" import { Badge, Card, Text } from "@code0-tech/pictor" -import { animate, m as motion, useInView, useMotionValue, useReducedMotion } from "motion/react" +import { useInView, useReducedMotion } from "motion/react" import { useEffect, useRef, useState } from "react" interface RoleItem { @@ -21,60 +21,28 @@ export function RoleSystemAnimation({ roles }: RoleSystemAnimationProps) { const isInView = useInView(containerRef, { amount: 0.2 }) const prefersReducedMotion = useReducedMotion() const [loopDistance, setLoopDistance] = useState(0) - const y = useMotionValue(0) const groupGap = 16 + const velocity = 30 useEffect(() => { const listElement = listRef.current if (!listElement) return - let frame = 0 - let previousDistance = 0 + const resizeObserver = new ResizeObserver((entries) => { + const entry = entries[0] + if (!entry) return - const updateLoopDistance = () => { - const nextDistance = Math.round(listElement.getBoundingClientRect().height + groupGap) - if (nextDistance !== previousDistance) { - previousDistance = nextDistance - setLoopDistance(nextDistance) - } - } - - updateLoopDistance() - - const resizeObserver = new ResizeObserver(() => { - cancelAnimationFrame(frame) - frame = requestAnimationFrame(updateLoopDistance) + setLoopDistance(Math.round(entry.contentRect.height + groupGap)) }) resizeObserver.observe(listElement) - return () => { - cancelAnimationFrame(frame) - resizeObserver.disconnect() - } + return () => resizeObserver.disconnect() }, [roles.length]) - useEffect(() => { - if (!loopDistance || !isInView || prefersReducedMotion) { - y.set(0) - return - } - - const controls = animate( - y, - [0, -loopDistance], - { - duration: 20, - ease: "linear", - repeat: Infinity, - repeatType: "loop", - }, - ) - - return () => controls.stop() - }, [isInView, loopDistance, prefersReducedMotion, y]) - if (!roles.length) return null + const duration = loopDistance > 0 ? loopDistance / velocity : 0 + const renderRoleCard = (role: RoleItem, index: number) => ( - 0 ? { + animationName: "role-marquee-up", + animationDuration: `${duration}s`, + animationTimingFunction: "linear", + animationIterationCount: "infinite", + animationPlayState: isInView && !prefersReducedMotion ? "running" : "paused", + } : undefined} >
{roles.map(renderRoleCard)} @@ -109,7 +83,7 @@ export function RoleSystemAnimation({ roles }: RoleSystemAnimationProps) { - +
) } diff --git a/src/components/blog/TableOfContents.tsx b/src/components/blog/TableOfContents.tsx index f16099e..748ae29 100644 --- a/src/components/blog/TableOfContents.tsx +++ b/src/components/blog/TableOfContents.tsx @@ -53,29 +53,53 @@ export function TableOfContents({ headings }: TableOfContentsProps) { if (!elements.length) return - const updateActiveHeading = () => { - const activationOffset = isMobile ? headingScrollOffset + 16 : desktopTopOffset + 24 - let currentId = elements[0].id + const activationOffset = isMobile ? headingScrollOffset + 16 : desktopTopOffset + 24 + const visibleHeadings = new Map() + + const observer = new IntersectionObserver( + (entries) => { + for (const entry of entries) { + const id = (entry.target as HTMLElement).id + + if (entry.isIntersecting) { + visibleHeadings.set(id, entry.boundingClientRect.top) + } else { + visibleHeadings.delete(id) + } + } + + let nextId = elements[0].id + + if (visibleHeadings.size > 0) { + const sortedVisibleIds = Array.from(visibleHeadings.entries()) + .sort((a, b) => b[1] - a[1]) + .map(([id]) => id) - for (const element of elements) { - if (element.getBoundingClientRect().top <= activationOffset) { - currentId = element.id + nextId = sortedVisibleIds[0] } else { - break + for (const element of elements) { + if (element.offsetTop <= window.scrollY + activationOffset) { + nextId = element.id + } else { + break + } + } } - } - setActiveIds([currentId]) + setActiveIds((prev) => (prev[0] === nextId ? prev : [nextId])) + }, + { + root: null, + rootMargin: `-${activationOffset}px 0px -55% 0px`, + threshold: [0, 1], + }, + ) + + for (const element of elements) { + observer.observe(element) } - updateActiveHeading() - window.addEventListener("scroll", updateActiveHeading, { passive: true }) - window.addEventListener("resize", updateActiveHeading) - - return () => { - window.removeEventListener("scroll", updateActiveHeading) - window.removeEventListener("resize", updateActiveHeading) - } + return () => observer.disconnect() }, [headings, isMobile]) useEffect(() => { @@ -113,14 +137,28 @@ export function TableOfContents({ headings }: TableOfContentsProps) { return } + let frame = 0 + const handleScrollVisibility = () => { - setShowMobileToc(window.scrollY > 32) - setIsOpen(false) + if (frame) return + + frame = window.requestAnimationFrame(() => { + frame = 0 + + const shouldShow = window.scrollY > 32 + setShowMobileToc((prev) => (prev === shouldShow ? prev : shouldShow)) + setIsOpen((prev) => (prev ? false : prev)) + }) } handleScrollVisibility() window.addEventListener("scroll", handleScrollVisibility, { passive: true }) - return () => window.removeEventListener("scroll", handleScrollVisibility) + return () => { + if (frame) { + window.cancelAnimationFrame(frame) + } + window.removeEventListener("scroll", handleScrollVisibility) + } }, [isMobile]) useEffect(() => { @@ -129,18 +167,33 @@ export function TableOfContents({ headings }: TableOfContentsProps) { return } - const updateDesktopPosition = () => { - const wrapper = desktopWrapperRef.current - if (!wrapper) return + const wrapper = desktopWrapperRef.current + if (!wrapper) return + const measure = () => { + const rect = wrapper.getBoundingClientRect() + setDesktopStyle((prev) => ( + prev?.left === rect.left && prev?.width === rect.width + ? prev + : { left: rect.left, width: rect.width } + )) + } + + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(wrapper) + + const updateDesktopPosition = () => { const rect = wrapper.getBoundingClientRect() const nextFixed = rect.top <= desktopTopOffset - setIsDesktopFixed(nextFixed) - setDesktopStyle({ - left: rect.left, - width: rect.width, - }) + setIsDesktopFixed((prev) => (prev === nextFixed ? prev : nextFixed)) + setDesktopStyle((prev) => ( + prev?.left === rect.left && prev?.width === rect.width + ? prev + : { left: rect.left, width: rect.width } + )) } updateDesktopPosition() @@ -148,10 +201,11 @@ export function TableOfContents({ headings }: TableOfContentsProps) { window.addEventListener("resize", updateDesktopPosition) return () => { + resizeObserver.disconnect() window.removeEventListener("scroll", updateDesktopPosition) window.removeEventListener("resize", updateDesktopPosition) } - }, [isMobile, desktopTopOffset]) + }, [desktopTopOffset, isMobile]) const scrollToHeading = (id: string) => { const element = document.getElementById(id) @@ -254,12 +308,9 @@ export function TableOfContents({ headings }: TableOfContentsProps) { )} -
+
-
-
-
+
+
+
{children} diff --git a/src/components/cards/TeamMemberCard.tsx b/src/components/cards/TeamMemberCard.tsx index 1672a04..b22e992 100644 --- a/src/components/cards/TeamMemberCard.tsx +++ b/src/components/cards/TeamMemberCard.tsx @@ -53,13 +53,12 @@ export function TeamMemberCard({ member, locale }: TeamMemberCardProps) { > -
-
-
+
+
-
+
@@ -105,13 +104,12 @@ export function TeamMemberCard({ member, locale }: TeamMemberCardProps) { > -
-
-
+
+
-
+
diff --git a/src/components/navigation/NavTab.tsx b/src/components/navigation/NavTab.tsx index 21729bd..07688c3 100644 --- a/src/components/navigation/NavTab.tsx +++ b/src/components/navigation/NavTab.tsx @@ -8,7 +8,8 @@ import React, { useRef } from "react" import { fadeInUp, SubNavItem } from "./Navigation" type TabProps = { - setPosition: React.Dispatch> + setPosition: (position: { left: number; width: number; opacity: number }) => void + containerRef: React.RefObject href: string | null subMenu?: SubNavItem[] activeSubMenu?: SubNavItem[] | null @@ -16,7 +17,7 @@ type TabProps = { title: string } -const NavTab: React.FC = ({ setPosition, href, title, subMenu, activeSubMenu, onMouseEnter }) => { +const NavTab: React.FC = ({ setPosition, containerRef, href, title, subMenu, activeSubMenu, onMouseEnter }) => { const ref = useRef(null) const hasSubMenu = Boolean(subMenu?.length) const active = activeSubMenu && activeSubMenu === subMenu @@ -33,13 +34,14 @@ const NavTab: React.FC = ({ setPosition, href, title, subMenu, activeS animate={fadeInUp.animate} transition={fadeInUp.transition} onMouseEnter={() => { - if (!ref?.current) return + if (!ref.current || !containerRef.current) return - const { width } = ref.current.getBoundingClientRect() + const tabRect = ref.current.getBoundingClientRect() + const containerRect = containerRef.current.getBoundingClientRect() setPosition({ - left: ref.current.offsetLeft, - width, + left: tabRect.left - containerRect.left, + width: tabRect.width, opacity: 1 }) onMouseEnter() diff --git a/src/components/navigation/Navigation.tsx b/src/components/navigation/Navigation.tsx index 40baf9c..bb4b49d 100644 --- a/src/components/navigation/Navigation.tsx +++ b/src/components/navigation/Navigation.tsx @@ -44,7 +44,6 @@ function Navigation({ locale, items }: NavigationProps) { const menuRef = useOutsideClick(() => setIsOpen(false)) const subMenuRef = useOutsideClick(() => setActiveSubMenu(null)) - const [position, setPosition] = useState({ left: 0, width: 0, opacity: 0 }) const [isScrolled, setIsScrolled] = useState(false) const [isOpen, setIsOpen] = useState(false) const [activeSubMenu, setActiveSubMenu] = useState(null) @@ -80,19 +79,28 @@ function Navigation({ locale, items }: NavigationProps) { }, [items, locale]) useEffect(() => { + let frame = 0 + const handleScroll = () => { - setIsScrolled((prevIsScrolled) => { - const nextIsScrolled = prevIsScrolled - ? window.scrollY > scrollCloseThreshold - : window.scrollY > scrollOpenThreshold + if (frame) return + + frame = window.requestAnimationFrame(() => { + frame = 0 + + setIsScrolled((prevIsScrolled) => { + const nextIsScrolled = prevIsScrolled + ? window.scrollY > scrollCloseThreshold + : window.scrollY > scrollOpenThreshold - if (prevIsScrolled !== nextIsScrolled && nextIsScrolled) { - setActiveSubMenu(null) - } + if (prevIsScrolled !== nextIsScrolled && nextIsScrolled) { + setActiveSubMenu(null) + } - return nextIsScrolled + return prevIsScrolled === nextIsScrolled ? prevIsScrolled : nextIsScrolled + }) + + setIsOpen((prevIsOpen) => (prevIsOpen ? false : prevIsOpen)) }) - setIsOpen(false) } if (window.scrollY > scrollOpenThreshold) { @@ -100,7 +108,12 @@ function Navigation({ locale, items }: NavigationProps) { } window.addEventListener("scroll", handleScroll) - return () => window.removeEventListener("scroll", handleScroll) + return () => { + if (frame) { + window.cancelAnimationFrame(frame) + } + window.removeEventListener("scroll", handleScroll) + } }, [scrollCloseThreshold, scrollOpenThreshold]) useEffect(() => { @@ -128,8 +141,6 @@ function Navigation({ locale, items }: NavigationProps) { > activeSubMenu: SubNavItem[] | null setActiveSubMenu: React.Dispatch> setHoveredSubMenu: React.Dispatch> @@ -27,46 +25,135 @@ type NavigationDesktopProps = { const NavigationDesktop: React.FC = ({ isScrolled, navbarItems, - position, - setPosition, activeSubMenu, setActiveSubMenu, setHoveredSubMenu, subMenuRef, homeHref, }) => { + const rootRef = useRef(null) + const submenuContentRef = useRef(null) + const navTabsRef = useRef(null) + const [submenuHeight, setSubmenuHeight] = useState(0) + const [overlayOffsetLeft, setOverlayOffsetLeft] = useState(0) + const [overlayPosition, setOverlayPosition] = useState({ left: 0, width: 0, opacity: 0 }) + const cursorX = useSpring(useMotionValue(0), { stiffness: 260, damping: 30, mass: 0.9 }) + const cursorOpacity = useSpring(useMotionValue(0), { stiffness: 260, damping: 30, mass: 0.9 }) + const cursorWidth = useSpring(useMotionValue(0), { stiffness: 260, damping: 30, mass: 0.9 }) + + const updateIndicatorPosition = (nextPosition: { left: number; width: number; opacity: number }) => { + cursorX.set(nextPosition.left) + cursorWidth.set(nextPosition.width) + cursorOpacity.set(nextPosition.opacity) + setOverlayPosition(nextPosition) + } + + useLayoutEffect(() => { + const element = submenuContentRef.current + if (!element) { + setSubmenuHeight(0) + return + } + + const measure = () => { + setSubmenuHeight(element.scrollHeight) + } + + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(element) + + return () => resizeObserver.disconnect() + }, [activeSubMenu]) + + useLayoutEffect(() => { + const rootElement = rootRef.current + const navTabsElement = navTabsRef.current + if (!rootElement || !navTabsElement) { + setOverlayOffsetLeft(0) + return + } + + const measure = () => { + const rootRect = rootElement.getBoundingClientRect() + const navTabsRect = navTabsElement.getBoundingClientRect() + setOverlayOffsetLeft(navTabsRect.left - rootRect.left) + } + + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(rootElement) + resizeObserver.observe(navTabsElement) + + return () => resizeObserver.disconnect() + }, [isScrolled]) + return ( -
+
- { - setPosition({ left: position.left, width: position.width, opacity: 0 }) + cursorOpacity.set(0) + setOverlayPosition((prev) => ({ ...prev, opacity: 0 })) setActiveSubMenu(null) setHoveredSubMenu(null) }} - initial={{ - marginLeft: "0%", - marginRight: "0%", - }} - animate={{ - marginLeft: isScrolled ? "10%" : "0%", - marginRight: isScrolled ? "10%" : "0%" - }} - transition={{ - type: "spring", - stiffness: 40, - damping: 10, - }} > -
+
+ + + +
- = ({ -
+
{navbarItems.map((item) => ( { @@ -91,25 +179,7 @@ const NavigationDesktop: React.FC = ({ /> ))}
- - {activeSubMenu && !isScrolled && ( -
-
- { - setActiveSubMenu(null) - setHoveredSubMenu(null) - }} - variant="overlay" - /> -
-
- )} +
@@ -131,36 +201,60 @@ const NavigationDesktop: React.FC = ({ +
+ { + setActiveSubMenu(null) + setHoveredSubMenu(null) + }} + variant="inline" + /> +
+
+ )} + + +
+ {activeSubMenu && !isScrolled && ( +
+
{ setActiveSubMenu(null) setHoveredSubMenu(null) }} - variant="inline" + variant="overlay" /> - - )} - - +
+
+ )} +
) } -const Cursor: React.FC<{ position: {left: number, width: number, opacity: number} }> = ({ position }) => { +const Cursor: React.FC<{ + x: ReturnType + width: ReturnType + opacity: ReturnType +}> = ({ x, width, opacity }) => { return ( ) } diff --git a/src/components/navigation/NavigationMobile.tsx b/src/components/navigation/NavigationMobile.tsx index b2b58af..0bd6c50 100644 --- a/src/components/navigation/NavigationMobile.tsx +++ b/src/components/navigation/NavigationMobile.tsx @@ -8,7 +8,7 @@ import { IconChevronUp, IconMenu2, IconX } from "@tabler/icons-react" import { AnimatePresence, m as motion } from "motion/react" import Image from "next/image" import Link from "next/link" -import React from "react" +import React, { useLayoutEffect, useRef, useState } from "react" import { useWebHaptics } from "web-haptics/react" import { fadeInUp, NavItem } from "./Navigation" @@ -34,30 +34,79 @@ const NavigationMobile: React.FC = ({ homeHref, }) => { const { trigger } = useWebHaptics() + const menuContentRef = useRef(null) + const [menuHeight, setMenuHeight] = useState(0) + + useLayoutEffect(() => { + const element = menuContentRef.current + if (!element) { + setMenuHeight(0) + return + } + + const measure = () => { + setMenuHeight(element.scrollHeight) + } + + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(element) + + return () => resizeObserver.disconnect() + }, [isOpen, mobileOpenKey, navbarItems]) return (
- +
+ + +
= ({ setIsOpen(false) }} > - = ({ = ({ {isOpen && ( - {navbarItems.map((item, i) => { - const isAccordion = !!item.subMenu?.length - const isOpenAcc = mobileOpenKey === item.title +
+ {navbarItems.map((item, i) => { + const isAccordion = !!item.subMenu?.length + const isOpenAcc = mobileOpenKey === item.title - const hasRoute = Boolean(item.href) - return ( -
- - {isAccordion ? ( - - ) : ( - { - trigger("medium") - setIsOpen(false) - }} - > - {item.title} - - )} - - - {isAccordion && ( - - {isOpenAcc && ( - + + {isAccordion ? ( + + ) : ( + { + trigger("medium") + setIsOpen(false) + }} > -
- {item.subMenu!.map((sub) => ( - { - trigger("medium") - setIsOpen(false) - setMobileOpenKey(null) - }} - > -
- {sub.icon} -
-
- {sub.title} - {sub.description} -
- - ))} -
-
+ {item.title} + )} -
- )} -
- ) - })} -
- - { - trigger("medium") - setIsOpen(false) - }} + + + {isAccordion && ( + + {isOpenAcc && ( + +
+ {item.subMenu!.map((sub) => ( + { + trigger("medium") + setIsOpen(false) + setMobileOpenKey(null) + }} + > +
+ {sub.icon} +
+
+ {sub.title} + {sub.description} +
+ + ))} +
+
+ )} +
+ )} +
+ ) + })} +
+ - - - - - { - trigger("medium") - setIsOpen(false) - }} + + + + - - - + + + +
)} - + +
) diff --git a/src/components/sections/CtaSection.tsx b/src/components/sections/CtaSection.tsx index a3cc14e..ae209cc 100644 --- a/src/components/sections/CtaSection.tsx +++ b/src/components/sections/CtaSection.tsx @@ -66,11 +66,10 @@ export const CtaSection: React.FC = ({ content }) => { height={48} squares={[50, 10]} /> -
-
-
+
+
- +
{"Code0 diff --git a/src/components/sections/HeroSection.tsx b/src/components/sections/HeroSection.tsx index a77d40c..462f4da 100644 --- a/src/components/sections/HeroSection.tsx +++ b/src/components/sections/HeroSection.tsx @@ -93,7 +93,7 @@ export const HeroSection: React.FC = ({ content }) => { />
-
+
= ({ content }) => { height={620} width={900} loading="eager" - className="rounded-2xl border border-white/10 shadow-[0_18px_50px_rgba(0,0,0,0.28)] lg:rounded-l-2xl lg:rounded-r-none lg:border-0 lg:border-l lg:border-y lg:ring-4 lg:ring-white/5" + className="rounded-2xl border border-white/10 shadow-[0_14px_38px_rgba(0,0,0,0.24)] lg:rounded-l-2xl lg:rounded-r-none lg:border-0 lg:border-l lg:border-y lg:ring-4 lg:ring-white/5" /> -
+
diff --git a/src/components/ui/Accordion.tsx b/src/components/ui/Accordion.tsx index 29a1435..df39bab 100644 --- a/src/components/ui/Accordion.tsx +++ b/src/components/ui/Accordion.tsx @@ -1,6 +1,6 @@ import {IconChevronDown} from "@tabler/icons-react" import { m as motion } from "motion/react" -import React from "react" +import React, { useLayoutEffect, useRef, useState } from "react" import { cn } from "@/lib/utils" const accordionCardBaseClassName = @@ -18,11 +18,30 @@ interface FAQItemProps { } const AccordionItemComponent = ({ index, question, answer, isOpen, onToggle }: FAQItemProps) => { + const contentRef = useRef(null) + const [contentHeight, setContentHeight] = useState(0) + const handleClick = (e: React.MouseEvent) => { e.preventDefault() onToggle(index) } + useLayoutEffect(() => { + const element = contentRef.current + if (!element) return + + const measure = () => { + setContentHeight(element.scrollHeight) + } + + measure() + + const resizeObserver = new ResizeObserver(measure) + resizeObserver.observe(element) + + return () => resizeObserver.disconnect() + }, [answer, question]) + return (
= ({ maxDpr = 2 }) => { const containerRef = useRef(null); + const prefersReducedMotion = useReducedMotion(); useEffect(() => { if (!containerRef.current) return; @@ -221,16 +223,93 @@ const Grainient: React.FC = ({ let raf = 0; const t0 = performance.now(); - const loop = (t: number) => { + let disposed = false; + let running = false; + let isInViewport = true; + let isPageVisible = document.visibilityState === 'visible'; + let lastFrameTime = 0; + const frameInterval = 1000 / 30; + + const renderFrame = (t: number) => { (program.uniforms.iTime as { value: number }).value = (t - t0) * 0.001; renderer.render({ scene: mesh }); + }; + + const stopLoop = () => { + running = false; + if (raf) { + cancelAnimationFrame(raf); + raf = 0; + } + }; + + const loop = (t: number) => { + if (disposed || !running) return; + + if (t - lastFrameTime >= frameInterval) { + lastFrameTime = t; + renderFrame(t); + } + + raf = requestAnimationFrame(loop); + }; + + const startLoop = () => { + if (disposed || prefersReducedMotion || running || !isInViewport || !isPageVisible) return; + + running = true; + lastFrameTime = 0; raf = requestAnimationFrame(loop); }; - raf = requestAnimationFrame(loop); + + const visibilityObserver = new IntersectionObserver( + ([entry]) => { + isInViewport = Boolean(entry?.isIntersecting); + + if (isInViewport) { + if (prefersReducedMotion) { + renderFrame(performance.now()); + } else { + startLoop(); + } + } else { + stopLoop(); + } + }, + { threshold: 0.05 }, + ); + + visibilityObserver.observe(container); + + const handleVisibilityChange = () => { + isPageVisible = document.visibilityState === 'visible'; + + if (!isPageVisible) { + stopLoop(); + return; + } + + if (prefersReducedMotion) { + renderFrame(performance.now()); + } else { + startLoop(); + } + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + if (prefersReducedMotion) { + renderFrame(performance.now()); + } else { + startLoop(); + } return () => { - cancelAnimationFrame(raf); + disposed = true; + stopLoop(); ro.disconnect(); + visibilityObserver.disconnect(); + document.removeEventListener('visibilitychange', handleVisibilityChange); try { container.removeChild(canvas); } catch { @@ -260,7 +339,8 @@ const Grainient: React.FC = ({ color1, color2, color3, - maxDpr + maxDpr, + prefersReducedMotion ]); return
;