From 8da3fe77f02eab5d1f9dfd649878df2abc1bf64c Mon Sep 17 00:00:00 2001 From: jinhojang6 Date: Tue, 16 Jun 2026 11:27:31 +0900 Subject: [PATCH] feat: introduce SectionHeadingReveal component and enhance StackCard with scroll-linked rise effect; update various sections to utilize new component and improve layout --- .../motion/section-heading-reveal.tsx | 46 +++++++++++++++++++ apps/web/components/motion/stack-card.tsx | 44 ++++++++++++++++-- .../sections/home/about-section.tsx | 5 +- .../sections/home/builder-portal-section.tsx | 25 +++++----- .../sections/home/civil-society-accordion.tsx | 4 +- .../sections/home/decide-section.tsx | 11 +++-- .../sections/home/feature-cards-section.tsx | 5 +- .../sections/home/social-proof-section.tsx | 10 ++-- .../sections/home/use-cases-section.tsx | 8 ++-- 9 files changed, 123 insertions(+), 35 deletions(-) create mode 100644 apps/web/components/motion/section-heading-reveal.tsx diff --git a/apps/web/components/motion/section-heading-reveal.tsx b/apps/web/components/motion/section-heading-reveal.tsx new file mode 100644 index 0000000000..f337ac4a9f --- /dev/null +++ b/apps/web/components/motion/section-heading-reveal.tsx @@ -0,0 +1,46 @@ +'use client' + +import { motion, useReducedMotion } from 'motion/react' +import type { CSSProperties, ElementType, ReactNode } from 'react' + +import { EASE } from '@/lib/motion' + +interface SectionHeadingRevealProps { + as?: ElementType + children: ReactNode + className?: string + style?: CSSProperties + delay?: number +} + +export function SectionHeadingReveal({ + as = 'h2', + children, + className, + style, + delay = 0, +}: SectionHeadingRevealProps) { + const shouldReduceMotion = useReducedMotion() + const MotionTag = motion.create(as) + + if (shouldReduceMotion) { + return ( + + {children} + + ) + } + + return ( + + {children} + + ) +} diff --git a/apps/web/components/motion/stack-card.tsx b/apps/web/components/motion/stack-card.tsx index 290045bbd4..3d840d8e3f 100644 --- a/apps/web/components/motion/stack-card.tsx +++ b/apps/web/components/motion/stack-card.tsx @@ -1,4 +1,8 @@ +'use client' + +import { motion, useScroll, useTransform } from 'motion/react' import type { CSSProperties, ReactNode } from 'react' +import { useRef } from 'react' interface StackCardProps { children: ReactNode @@ -6,12 +10,46 @@ interface StackCardProps { style?: CSSProperties /** Element id for in-page anchors. */ id?: string + /** + * Pixels this card visibly rises onto the card above it as it scrolls into + * view. The card's className must pull it up with a matching `-mt-[Npx]` + * overlap so the rise lands inside the upper card's empty bottom padding — + * never over its content. `0` (default) makes a static base card. + */ + rise?: number } -export function StackCard({ children, className, style, id }: StackCardProps) { +/** + * Wrapper for the three "stacking" homepage sections (Civil Society → Decide → + * Use Cases). Civil Society is the static base; Decide and Use Cases each rise + * onto the card above as you scroll, scroll-linked so the upward stacking + * motion is clearly visible. The rise only ever laps onto the upper card's + * empty bottom padding, so content is never covered. + */ +export function StackCard({ + children, + className, + style, + id, + rise = 0, +}: StackCardProps) { + const ref = useRef(null) + const { scrollYProgress } = useScroll({ + target: ref, + offset: ['start end', 'start center'], + }) + // Start flush (y = +rise cancels the negative overlap margin), then rise to + // its resting overlap (y = 0) as the card scrolls toward the viewport centre. + const y = useTransform(scrollYProgress, [0, 1], [rise, 0]) + return ( -
+ {children} -
+ ) } diff --git a/apps/web/components/sections/home/about-section.tsx b/apps/web/components/sections/home/about-section.tsx index 57c376c87d..9222e1fe87 100644 --- a/apps/web/components/sections/home/about-section.tsx +++ b/apps/web/components/sections/home/about-section.tsx @@ -1,5 +1,6 @@ import { getTranslations } from 'next-intl/server' +import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal' import { StackCard } from '@/components/motion/stack-card' import CivilSocietyAccordion, { type AccordionItem, @@ -73,9 +74,9 @@ export default async function AboutSection({ locale }: { locale: string }) { className="relative z-[2] mt-[96px] rounded-t-[40px] bg-brand-dark-green text-brand-off-white lg:mt-[48px] lg:rounded-t-[100px]" >
-

+ {t('heading')} -

+
diff --git a/apps/web/components/sections/home/builder-portal-section.tsx b/apps/web/components/sections/home/builder-portal-section.tsx index 7336cfd3c7..8ab0f37348 100644 --- a/apps/web/components/sections/home/builder-portal-section.tsx +++ b/apps/web/components/sections/home/builder-portal-section.tsx @@ -2,7 +2,7 @@ import { getTranslations } from 'next-intl/server' import Image from 'next/image' import ContentWidth from '@/components/layout/content-width' -import { Reveal } from '@/components/motion/reveal' +import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal' import { Button, ButtonArrowIcon } from '@/components/ui' import { ROUTES } from '@/constants/routes' @@ -45,19 +45,16 @@ export default async function BuilderPortalSection({ return (
- -
-
+ +
+
- -

- {t('title')} -

-
+ {t('title')} +
-

+

{t('description')}

-
+
(null) + const [openKey, setOpenKey] = useState( + () => items[0]?.key ?? null, + ) return (
diff --git a/apps/web/components/sections/home/decide-section.tsx b/apps/web/components/sections/home/decide-section.tsx index a1bc2218ce..dc7eda45bc 100644 --- a/apps/web/components/sections/home/decide-section.tsx +++ b/apps/web/components/sections/home/decide-section.tsx @@ -8,10 +8,11 @@ export default async function DecideSection({ locale }: { locale: string }) { return ( -
-
+
+

{t('headline')} {t('headline2')} @@ -19,12 +20,12 @@ export default async function DecideSection({ locale }: { locale: string }) {

{t('body')}

-
+
diff --git a/apps/web/components/sections/home/feature-cards-section.tsx b/apps/web/components/sections/home/feature-cards-section.tsx index 2566fe9096..bb8796e297 100644 --- a/apps/web/components/sections/home/feature-cards-section.tsx +++ b/apps/web/components/sections/home/feature-cards-section.tsx @@ -6,6 +6,7 @@ import { useTranslations } from 'next-intl' import ContentWidth from '@/components/layout/content-width' import { LogosMark } from '@acid-info/logos-ui' +import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal' import { ButtonArrowIcon } from '@/components/ui' import { ROUTES } from '@/constants/routes' import { Link } from '@/i18n/navigation' @@ -101,7 +102,9 @@ export default function FeatureCardsSection() {
-

{t('title')}

+ + {t('title')} +

{t('kicker')}

diff --git a/apps/web/components/sections/home/social-proof-section.tsx b/apps/web/components/sections/home/social-proof-section.tsx index e7b3397fae..44b1ca109a 100644 --- a/apps/web/components/sections/home/social-proof-section.tsx +++ b/apps/web/components/sections/home/social-proof-section.tsx @@ -15,7 +15,7 @@ interface StatCard { function StatCardView({ card }: { card: StatCard }) { return ( -
+
{card.label} @@ -26,9 +26,7 @@ function StatCardView({ card }: { card: StatCard }) { {card.value}

-
- -

+

{card.body}

@@ -73,7 +71,7 @@ export default function SocialProofSection({ stats }: SocialProofSectionProps) {
-

+

{t('headline1')} {t('headline2')}

@@ -85,7 +83,7 @@ export default function SocialProofSection({ stats }: SocialProofSectionProps) {
-
+
{cards.map((card) => ( ))} diff --git a/apps/web/components/sections/home/use-cases-section.tsx b/apps/web/components/sections/home/use-cases-section.tsx index 13376c2e30..ed88f5bdc6 100644 --- a/apps/web/components/sections/home/use-cases-section.tsx +++ b/apps/web/components/sections/home/use-cases-section.tsx @@ -1,6 +1,7 @@ import { getTranslations } from 'next-intl/server' import Image from 'next/image' +import { SectionHeadingReveal } from '@/components/motion/section-heading-reveal' import { StackCard } from '@/components/motion/stack-card' interface UseCaseCard { @@ -20,15 +21,16 @@ export default async function UseCasesSection({ locale }: { locale: string }) { return (

{t('eyebrow')}

-

+ {t('headline')} -

+

{t('lambda')}