feat: introduce SectionHeadingReveal component and enhance StackCard with scroll-linked rise effect; update various sections to utilize new component and improve layout

This commit is contained in:
jinhojang6
2026-06-16 11:56:58 +09:00
committed by Jinho Jang
parent 021e8965d2
commit 8da3fe77f0
9 changed files with 123 additions and 35 deletions
@@ -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 (
<MotionTag className={className} style={style}>
{children}
</MotionTag>
)
}
return (
<MotionTag
className={className}
style={style}
initial={{ opacity: 0, y: 18, filter: 'blur(8px)' }}
whileInView={{ opacity: 1, y: 0, filter: 'blur(0px)' }}
viewport={{ once: true, amount: 0.45, margin: '0px 0px -12% 0px' }}
transition={{ duration: 1, ease: EASE.out, delay }}
>
{children}
</MotionTag>
)
}
+41 -3
View File
@@ -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<HTMLElement>(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 (
<section id={id} className={className} style={style}>
<motion.section
ref={ref}
id={id}
className={className}
style={rise ? { ...style, y } : style}
>
{children}
</section>
</motion.section>
)
}
@@ -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]"
>
<div className="mx-auto max-w-[1440px] px-6 pt-[88px] pb-[200px] lg:px-[130px] lg:pt-[112px] lg:pb-[291px]">
<h2 className="mx-auto max-w-[853px] whitespace-pre-line text-center font-display text-[36px] leading-none tracking-[-0.03em]">
<SectionHeadingReveal className="mx-auto max-w-[853px] whitespace-normal text-center font-display text-[36px] leading-none tracking-[-0.03em] desktop:whitespace-pre-line">
{t('heading')}
</h2>
</SectionHeadingReveal>
<div className="mt-14 lg:mt-[74px]">
<CivilSocietyAccordion items={items} />
</div>
@@ -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 (
<section className="border-t border-brand-dark-green/10 bg-brand-off-white">
<ContentWidth className="py-25 lg:pt-28 lg:pb-0">
<div className="grid gap-9 lg:grid-cols-3 lg:gap-3">
<div className="flex flex-col gap-10 lg:min-h-[532px] lg:justify-between lg:gap-0">
<ContentWidth className="desktop:pt-28 desktop:pb-0 py-25">
<div className="desktop:grid-cols-3 desktop:gap-3 grid gap-9">
<div className="desktop:min-h-[532px] desktop:justify-between desktop:gap-0 flex flex-col gap-10">
<div className="flex flex-col gap-7.5">
<Reveal
amount={0.4}
delay={0.18}
viewportMargin="0px 0px -20% 0px"
<SectionHeadingReveal
className="text-h2 desktop:w-[702px] relative z-[1] max-w-[702px] whitespace-pre-line text-brand-dark-green"
delay={0.08}
>
<h2 className="text-h2 relative z-[1] max-w-[702px] whitespace-pre-line text-brand-dark-green lg:w-[702px]">
{t('title')}
</h2>
</Reveal>
{t('title')}
</SectionHeadingReveal>
<Button
href={ROUTES.basecamp}
variant="secondary"
@@ -68,12 +65,12 @@ export default async function BuilderPortalSection({
</Button>
</div>
<p className="text-mono-s whitespace-pre-line text-brand-dark-green lg:w-[345px]">
<p className="text-mono-s desktop:w-[345px] whitespace-pre-line text-brand-dark-green">
{t('description')}
</p>
</div>
<div className="relative aspect-[2820/1596] overflow-hidden rounded-3xl bg-[#1c1c1c] lg:col-span-2 lg:aspect-auto lg:h-[532px]">
<div className="desktop:col-span-2 desktop:aspect-auto desktop:h-[532px] relative aspect-[2820/1596] overflow-hidden rounded-3xl bg-[#1c1c1c]">
<Image
src="/images/home/figma-refresh/basecamp.webp"
alt=""
@@ -73,7 +73,9 @@ export default function CivilSocietyAccordion({
}: {
items: AccordionItem[]
}) {
const [openKey, setOpenKey] = useState<AccordionItem['key'] | null>(null)
const [openKey, setOpenKey] = useState<AccordionItem['key'] | null>(
() => items[0]?.key ?? null,
)
return (
<div className="flex w-full flex-col">
@@ -8,10 +8,11 @@ export default async function DecideSection({ locale }: { locale: string }) {
return (
<StackCard
className="relative z-[3] -mt-[112px] rounded-t-[36px] bg-gray-03 text-brand-dark-green"
rise={180}
className="relative z-[3] -mt-[180px] rounded-t-[36px] bg-gray-03 text-brand-dark-green"
>
<div className="mx-auto flex max-w-[1440px] flex-col gap-10 px-6 pt-[88px] pb-[200px] lg:flex-row lg:items-center lg:justify-between lg:gap-16 lg:px-[131px] lg:pt-[112px] lg:pb-[224px]">
<div className="flex flex-col gap-8 lg:max-w-[510px] lg:gap-12">
<div className="desktop:flex-row desktop:items-center desktop:justify-between desktop:gap-16 desktop:px-[131px] desktop:pt-[112px] desktop:pb-[224px] mx-auto flex max-w-[1440px] flex-col gap-10 px-6 pt-[88px] pb-[200px]">
<div className="desktop:max-w-[510px] desktop:gap-12 flex flex-col gap-8">
<h2 className="text-h2">
{t('headline')}
<span className="mt-[1em] block">{t('headline2')}</span>
@@ -19,12 +20,12 @@ export default async function DecideSection({ locale }: { locale: string }) {
<p className="text-h4-serif max-w-[510px]">{t('body')}</p>
</div>
<div className="relative aspect-[583/520] w-full shrink-0 overflow-hidden rounded-[40px] lg:aspect-auto lg:h-[695px] lg:w-[583px] lg:rounded-[50px]">
<div className="desktop:aspect-auto desktop:h-[695px] desktop:w-[583px] desktop:rounded-[50px] relative aspect-[583/520] w-full shrink-0 overflow-hidden rounded-[40px]">
<Image
src="/images/home/figma-refresh/path-activism.webp"
alt=""
fill
sizes="(max-width: 1023px) calc(100vw - 48px), 583px"
sizes="(max-width: 1439px) calc(100vw - 48px), 583px"
className="object-cover"
/>
</div>
@@ -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() {
<section className="border-t border-brand-dark-green/10 bg-brand-off-white">
<ContentWidth className="relative pt-3 pb-25 lg:h-[848px] lg:py-0">
<div className="mt-16 flex flex-col gap-8 text-center lg:absolute lg:top-[112px] lg:left-1/2 lg:mt-0 lg:w-[940px] lg:max-w-[calc(100%-24px)] lg:-translate-x-1/2 lg:flex-row lg:items-start lg:justify-between lg:text-left">
<h2 className="text-h2 text-brand-dark-green">{t('title')}</h2>
<SectionHeadingReveal className="text-h2 text-brand-dark-green">
{t('title')}
</SectionHeadingReveal>
<div className="text-mono-s mx-auto flex w-[230px] flex-col gap-4 text-brand-dark-green lg:mx-0 lg:w-[226px]">
<p>{t('kicker')}</p>
@@ -15,7 +15,7 @@ interface StatCard {
function StatCardView({ card }: { card: StatCard }) {
return (
<article className="flex h-[250px] w-full flex-col gap-[23px] rounded-[20px] border border-brand-dark-green bg-brand-off-white p-5 text-brand-dark-green">
<article className="flex min-h-[250px] w-full min-w-0 flex-col gap-[23px] rounded-[20px] border border-brand-dark-green bg-brand-off-white p-5 text-brand-dark-green">
<span className="inline-flex w-fit items-center rounded-[4px] border border-brand-dark-green px-[11px] py-1.5">
<span className="font-sans text-[12px] leading-[1.15] tracking-[-0.01em]">
{card.label}
@@ -26,9 +26,7 @@ function StatCardView({ card }: { card: StatCard }) {
{card.value}
</p>
<div className="mt-auto h-px w-full bg-brand-dark-green/30" />
<p className="font-sans text-[15px] leading-[1.15] tracking-[-0.01em]">
<p className="mt-auto max-w-full min-w-0 font-sans text-[15px] leading-[1.15] tracking-[-0.01em] [overflow-wrap:anywhere]">
{card.body}
</p>
</article>
@@ -73,7 +71,7 @@ export default function SocialProofSection({ stats }: SocialProofSectionProps) {
<section className="relative z-[2] -mt-[60px] overflow-hidden rounded-t-[36px] bg-brand-off-white">
<div className="mx-auto max-w-[1440px] px-3">
<div className="flex flex-col items-center gap-9 pt-[72px] pb-[64px] text-center lg:pt-[112px] lg:pb-[102px]">
<h2 className="text-h2 max-w-[853px] text-brand-dark-green">
<h2 className="text-h2 w-full max-w-[853px] text-brand-dark-green">
<span className="block">{t('headline1')}</span>
<span className="block text-[#848e88]">{t('headline2')}</span>
</h2>
@@ -85,7 +83,7 @@ export default function SocialProofSection({ stats }: SocialProofSectionProps) {
</Button>
</div>
<div className="mx-auto flex max-w-[1180px] flex-col gap-3 pb-[112px] lg:grid lg:grid-cols-4 lg:gap-4">
<div className="mx-auto grid w-full max-w-[1180px] min-w-0 grid-cols-1 gap-3 pb-[112px] md:grid-cols-2 xl:grid-cols-4 xl:gap-4">
{cards.map((card) => (
<StatCardView key={card.key} card={card} />
))}
@@ -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 (
<StackCard
className="relative z-[4] -mt-[112px] rounded-t-[40px] bg-gray-01 text-brand-dark-green lg:rounded-t-[100px]"
rise={180}
className="relative z-[4] -mt-[180px] rounded-t-[40px] bg-gray-01 text-brand-dark-green lg:rounded-t-[100px]"
>
<div className="mx-auto max-w-[1440px] px-3 py-[72px] lg:pt-[112px] lg:pb-[112px]">
<div className="mx-auto flex max-w-[1440px] flex-col items-center gap-10 pb-12 text-center lg:gap-12 lg:pb-[14px]">
<div className="flex w-full flex-col items-center gap-8 lg:gap-12">
<p className="text-mono-s w-full">{t('eyebrow')}</p>
<h2 className="text-h3-serif w-full whitespace-pre-line">
<SectionHeadingReveal className="text-h3-serif w-full whitespace-normal desktop:whitespace-pre-line">
{t('headline')}
</h2>
</SectionHeadingReveal>
</div>
<p className="text-mono-s w-full">{t('lambda')}</p>
</div>