mirror of
https://github.com/logos-co/logos-web.git
synced 2026-08-27 12:11:14 +00:00
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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user