One Calendar is a privacy-first calendar web app built with Next.js. It has modern security features, including e2ee, password-protected sharing, and self-destructing share links ๐Ÿ“… calendar.xyehr.cn

Expand landing content and trigger title animations on scroll

+188 -137
+12 -4
app/globals.css
··· 11 11 text-wrap: balance; 12 12 } 13 13 14 - .landing-title-reveal { 15 - animation: landing-title-reveal 1600ms cubic-bezier(0.22, 1, 0.36, 1) both; 16 - } 17 14 } 18 15 19 - @keyframes landing-title-reveal { 16 + @keyframes landing-title-in-view { 20 17 from { 21 18 opacity: 0; 22 19 filter: blur(12px); ··· 27 24 filter: blur(0); 28 25 transform: translateY(0); 29 26 } 27 + } 28 + 29 + 30 + .landing-title { 31 + opacity: 0; 32 + filter: blur(12px); 33 + transform: translateY(10px); 34 + } 35 + 36 + .landing-title-visible { 37 + animation: landing-title-in-view 1300ms cubic-bezier(0.22, 1, 0.36, 1) both; 30 38 } 31 39 32 40 @layer components {
+2
components/landing/index.ts
··· 7 7 export { LandingFaq } from "./landing-faq"; 8 8 export { LandingCta } from "./landing-cta"; 9 9 export { LandingFooter } from "./landing-footer"; 10 + 11 + export { LandingTitle } from "./landing-title";
+28 -20
components/landing/landing-comparison.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 1 3 const rows = [ 2 4 { label: "End-to-end encryption (E2EE)", one: "โœ…", google: "โŒ", proton: "โœ…" }, 3 5 { label: "No analytics by default", one: "โœ…", google: "โŒ", proton: "โœ…" }, ··· 8 10 9 11 export function LandingComparison() { 10 12 return ( 11 - <section className="flex min-h-screen items-center border-b border-white/10 py-12"> 12 - <div className="w-full"> 13 - <h2 className="landing-title-reveal text-3xl font-semibold text-white md:text-5xl">Feature comparison snapshot</h2> 14 - <p className="mt-4 max-w-3xl text-base text-[var(--landing-muted)] md:text-lg">Based on the comparison table documented in this repository README.</p> 15 - 16 - <div className="mt-8 overflow-hidden rounded-2xl border border-white/10"> 17 - <div className="grid border-b border-white/10 bg-white/[0.02] text-xs uppercase tracking-[0.18em] text-[var(--landing-subtle)] md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr]"> 18 - <div className="p-4">Feature</div> 19 - <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">One Calendar</div> 20 - <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Google</div> 21 - <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Proton</div> 22 - </div> 13 + <section className="border-b border-white/10 py-24 md:py-28"> 14 + <div className="grid gap-10 lg:grid-cols-[1fr_1fr]"> 15 + <LandingTitle as="h2" className="text-3xl font-semibold text-white md:text-5xl"> 16 + Privacy and control 17 + <br /> 18 + at a glance. 19 + </LandingTitle> 20 + <p className="max-w-xl text-base text-[var(--landing-muted)] md:text-lg"> 21 + A quick snapshot from the repository comparison table, focused on encryption, tracking defaults, and data portability. 22 + </p> 23 + </div> 23 24 24 - {rows.map((row) => ( 25 - <div key={row.label} className="grid text-sm md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr] md:text-base"> 26 - <div className="border-b border-white/10 p-4 text-white">{row.label}</div> 27 - <div className="border-b border-white/10 p-4 text-white md:border-l">{row.one}</div> 28 - <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.google}</div> 29 - <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.proton}</div> 30 - </div> 31 - ))} 25 + <div className="mt-10 overflow-hidden rounded-2xl border border-white/10"> 26 + <div className="grid border-b border-white/10 bg-white/[0.02] text-xs uppercase tracking-[0.18em] text-[var(--landing-subtle)] md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr]"> 27 + <div className="p-4">Feature</div> 28 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">One Calendar</div> 29 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Google</div> 30 + <div className="border-t border-white/10 p-4 md:border-l md:border-t-0">Proton</div> 32 31 </div> 32 + 33 + {rows.map((row) => ( 34 + <div key={row.label} className="grid text-sm md:grid-cols-[1.6fr_0.6fr_0.6fr_0.6fr] md:text-base"> 35 + <div className="border-b border-white/10 p-4 text-white">{row.label}</div> 36 + <div className="border-b border-white/10 p-4 text-white md:border-l">{row.one}</div> 37 + <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.google}</div> 38 + <div className="border-b border-white/10 p-4 text-[var(--landing-muted)] md:border-l">{row.proton}</div> 39 + </div> 40 + ))} 33 41 </div> 34 42 </section> 35 43 );
+25 -25
components/landing/landing-cta.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 1 3 export function LandingCta() { 2 4 return ( 3 - <section className="flex min-h-screen items-center py-12 text-center"> 4 - <div className="w-full"> 5 - <p className="text-xs uppercase tracking-[0.28em] text-[var(--landing-subtle)]">Ready to simplify planning</p> 6 - <h2 className="landing-title-reveal mx-auto mt-4 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl"> 7 - Your time. Your data. Yours. 8 - </h2> 9 - <p className="mx-auto mt-4 max-w-2xl text-sm text-[var(--landing-muted)] md:text-base"> 10 - Keep your schedule clear with privacy-first defaults, portable formats, and dependable sync. 11 - </p> 12 - <div className="mt-8 flex flex-wrap justify-center gap-3"> 13 - <a 14 - href="/sign-up" 15 - aria-label="Create your free account" 16 - className="rounded-md bg-white px-6 py-2.5 text-sm font-medium text-black transition duration-200 hover:-translate-y-0.5 hover:brightness-110" 17 - > 18 - Start free 19 - </a> 20 - <a 21 - href="https://docs.xyehr.cn/docs/one-calendar" 22 - aria-label="Read product documentation" 23 - className="rounded-md border border-white/20 px-6 py-2.5 text-sm text-[var(--landing-muted)] transition duration-200 hover:-translate-y-0.5 hover:border-white/35 hover:text-white" 24 - > 25 - Read docs 26 - </a> 27 - </div> 5 + <section className="py-24 text-center md:py-28"> 6 + <p className="text-xs uppercase tracking-[0.28em] text-[var(--landing-subtle)]">Ready to simplify planning</p> 7 + <LandingTitle as="h2" className="mx-auto mt-4 max-w-3xl text-4xl font-semibold leading-tight text-white md:text-6xl"> 8 + Your time. Your data. Yours. 9 + </LandingTitle> 10 + <p className="mx-auto mt-4 max-w-2xl text-sm text-[var(--landing-muted)] md:text-base"> 11 + Keep your schedule clear with privacy-first defaults, portable formats, and dependable sync. 12 + </p> 13 + <div className="mt-8 flex flex-wrap justify-center gap-3"> 14 + <a 15 + href="/sign-up" 16 + aria-label="Create your free account" 17 + className="rounded-md bg-white px-6 py-2.5 text-sm font-medium text-black transition duration-200 hover:-translate-y-0.5 hover:brightness-110" 18 + > 19 + Start free 20 + </a> 21 + <a 22 + href="https://docs.xyehr.cn/docs/one-calendar" 23 + aria-label="Read product documentation" 24 + className="rounded-md border border-white/20 px-6 py-2.5 text-sm text-[var(--landing-muted)] transition duration-200 hover:-translate-y-0.5 hover:border-white/35 hover:text-white" 25 + > 26 + Read docs 27 + </a> 28 28 </div> 29 29 </section> 30 30 );
+14 -8
components/landing/landing-data-showcase.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 1 3 const metrics = [ 2 4 { label: "Locale packs", value: "35" }, 3 5 { label: "Theme options", value: "5" }, ··· 13 15 14 16 export function LandingDataShowcase() { 15 17 return ( 16 - <section id="data" className="flex min-h-screen items-center border-b border-white/10 py-12"> 17 - <div className="w-full"> 18 - <h2 className="landing-title-reveal text-3xl font-semibold leading-tight text-white md:text-5xl">Built from real One Calendar capabilities</h2> 19 - <p className="mt-4 max-w-3xl text-base text-[var(--landing-muted)] md:text-lg"> 20 - Privacy-first, planning-focused, and designed to stay understandable while scaling to team workflows. 21 - </p> 18 + <section id="data" className="border-b border-white/10 py-24 md:py-28"> 19 + <LandingTitle as="h2" className="text-3xl font-semibold leading-tight text-white md:text-5xl"> 20 + Data and architecture 21 + <br /> 22 + you can actually trust. 23 + </LandingTitle> 24 + <p className="mt-4 max-w-3xl text-base text-[var(--landing-muted)] md:text-lg"> 25 + Practical metrics and straightforward infrastructure choices, without black-box behavior. 26 + </p> 22 27 23 - <div className="mt-10 grid gap-6 md:grid-cols-3"> 28 + <div className="mt-12 grid gap-10 lg:grid-cols-[1.2fr_1fr]"> 29 + <div className="grid gap-6 md:grid-cols-3"> 24 30 {metrics.map((metric) => ( 25 31 <div key={metric.label} className="border-b border-white/15 pb-4"> 26 32 <p className="text-4xl font-semibold text-white">{metric.value}</p> ··· 29 35 ))} 30 36 </div> 31 37 32 - <div className="mt-10 grid gap-4 md:grid-cols-2"> 38 + <div className="space-y-4"> 33 39 {stack.map((item, idx) => ( 34 40 <div key={item} className="flex items-start gap-3 border-l border-white/15 pl-4"> 35 41 <span className="mt-0.5 text-xs text-[var(--landing-subtle)]">0{idx + 1}</span>
+4 -3
components/landing/landing-faq.tsx
··· 4 4 AccordionItem, 5 5 AccordionTrigger, 6 6 } from "@/components/ui/accordion"; 7 + import { LandingTitle } from "./landing-title"; 7 8 8 9 const faqItems = [ 9 10 { ··· 26 27 27 28 export function LandingFaq() { 28 29 return ( 29 - <section id="faq" className="flex min-h-screen items-center border-b border-white/10 py-12"> 30 - <div className="grid w-full gap-8 md:grid-cols-[240px_1fr]"> 31 - <h2 className="landing-title-reveal text-3xl font-semibold text-white md:text-5xl">FAQ</h2> 30 + <section id="faq" className="border-b border-white/10 py-24 md:py-28"> 31 + <div className="grid w-full gap-8 md:grid-cols-[300px_1fr]"> 32 + <LandingTitle as="h2" className="text-3xl font-semibold text-white md:text-5xl">FAQ</LandingTitle> 32 33 <div className="px-0 md:px-2"> 33 34 <Accordion type="single" collapsible className="w-full"> 34 35 {faqItems.map((item, idx) => (
+23 -17
components/landing/landing-features.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 1 3 const features = [ 2 4 { 3 5 title: "Fast planning", ··· 18 20 19 21 export function LandingFeatures() { 20 22 return ( 21 - <section id="features" className="flex min-h-screen items-center border-y border-white/10 py-12"> 22 - <div className="w-full"> 23 - <div className="mb-10 max-w-3xl"> 24 - <p className="text-xs uppercase tracking-[0.24em] text-[var(--landing-subtle)]">Core capabilities</p> 25 - <h2 className="landing-title-reveal mt-3 text-3xl font-semibold leading-tight text-white md:text-5xl">Designed for clarity, control, and speed</h2> 26 - </div> 23 + <section id="features" className="border-y border-white/10 py-24 md:py-28"> 24 + <div className="grid gap-10 lg:grid-cols-[1fr_1fr] lg:items-start"> 25 + <LandingTitle as="h2" className="text-3xl font-semibold leading-tight text-white md:text-5xl"> 26 + Built to move planning forward 27 + <br /> 28 + without adding noise. 29 + </LandingTitle> 30 + <p className="max-w-xl text-base text-[var(--landing-muted)] md:text-lg"> 31 + Every interaction is designed to keep flow intact: fast edits, secure defaults, and formats that remain portable across tools. 32 + </p> 33 + </div> 27 34 28 - <div className="grid gap-8 md:grid-cols-3 md:gap-0"> 29 - {features.map((feature, index) => ( 30 - <article key={feature.title} className={`px-0 md:px-8 ${index !== 2 ? "md:border-r md:border-white/10" : ""}`}> 31 - <svg viewBox="0 0 32 32" aria-hidden="true" className="mb-5 h-9 w-9 stroke-white" fill="none" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> 32 - {feature.icon} 33 - </svg> 34 - <h3 className="landing-title-reveal text-xl font-medium text-white">{feature.title}</h3> 35 - <p className="mt-3 text-sm leading-relaxed text-[var(--landing-subtle)]">{feature.description}</p> 36 - </article> 37 - ))} 38 - </div> 35 + <div className="mt-14 grid gap-8 md:grid-cols-3 md:gap-0"> 36 + {features.map((feature, index) => ( 37 + <article key={feature.title} className={`px-0 md:px-8 ${index !== 2 ? "md:border-r md:border-white/10" : ""}`}> 38 + <svg viewBox="0 0 32 32" aria-hidden="true" className="mb-5 h-9 w-9 stroke-white" fill="none" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"> 39 + {feature.icon} 40 + </svg> 41 + <LandingTitle as="h3" className="text-xl font-medium text-white">{feature.title}</LandingTitle> 42 + <p className="mt-3 text-sm leading-relaxed text-[var(--landing-subtle)]">{feature.description}</p> 43 + </article> 44 + ))} 39 45 </div> 40 46 </section> 41 47 );
+2 -1
components/landing/landing-footer.tsx
··· 1 1 import Image from "next/image"; 2 + import { LandingTitle } from "./landing-title"; 2 3 3 4 const footerColumns = [ 4 5 { title: "Product", links: [{ label: "Overview", href: "#features" }, { label: "About", href: "/about" }] }, ··· 34 35 35 36 {footerColumns.map((column) => ( 36 37 <div key={column.title}> 37 - <p className="landing-title-reveal text-sm font-medium text-white">{column.title}</p> 38 + <LandingTitle as="p" className="text-sm font-medium text-white">{column.title}</LandingTitle> 38 39 <ul className="mt-4 space-y-3 text-sm text-[var(--landing-muted)]"> 39 40 {column.links.map((link) => ( 40 41 <li key={link.label}>
+14 -43
components/landing/landing-hero-demo.tsx
··· 2 2 3 3 import { Fragment } from "react"; 4 4 import { cn } from "@/lib/utils"; 5 + import { LandingTitle } from "./landing-title"; 5 6 6 7 type DemoEvent = { 7 8 id: string; ··· 13 14 }; 14 15 15 16 const demoEvents: DemoEvent[] = [ 16 - { 17 - id: "1", 18 - title: "Design review", 19 - start: "10:00", 20 - end: "10:45", 21 - tone: "bg-[#1a2430]", 22 - accent: "#6ea8ff", 23 - }, 24 - { 25 - id: "2", 26 - title: "Roadmap sync", 27 - start: "13:30", 28 - end: "14:15", 29 - tone: "bg-[#1a2a22]", 30 - accent: "#5bcf9a", 31 - }, 32 - { 33 - id: "3", 34 - title: "Focus block", 35 - start: "15:00", 36 - end: "17:00", 37 - tone: "bg-[#261f33]", 38 - accent: "#b18cff", 39 - }, 17 + { id: "1", title: "Design review", start: "10:00", end: "10:45", tone: "bg-[#1a2430]", accent: "#6ea8ff" }, 18 + { id: "2", title: "Roadmap sync", start: "13:30", end: "14:15", tone: "bg-[#1a2a22]", accent: "#5bcf9a" }, 19 + { id: "3", title: "Focus block", start: "15:00", end: "17:00", tone: "bg-[#261f33]", accent: "#b18cff" }, 20 + { id: "4", title: "Release review", start: "17:30", end: "18:00", tone: "bg-[#31201f]", accent: "#ffab8a" }, 40 21 ]; 41 22 42 23 const encryptedRows = [ 43 24 { left: "2nd49snxieNwi29Dnejs", right: "4fK29xneJ2qLs09PzVaa" }, 44 25 { left: "A0zX19pwQm7RtL2he81n", right: "n0Mqe28XvLp31sTTad90" }, 45 26 { left: "dN7qa21PoxM44jvR8tyk", right: "Qv4mL2zPaa11Nwe8sX0t" }, 27 + { left: "T7ePq82sLmN4xR3vA11f", right: "zP8wN2kLmQ1vD45sA0xe" }, 46 28 ]; 47 29 48 30 function WeekViewEventBlock({ event }: { event: DemoEvent }) { ··· 50 32 <div className={cn("relative overflow-hidden rounded-lg p-2 text-sm", event.tone)}> 51 33 <div className="absolute left-0 top-0 h-full w-1 rounded-l-md" style={{ backgroundColor: event.accent }} /> 52 34 <div className="pl-1"> 53 - <p className="font-medium leading-tight" style={{ color: event.accent }}> 54 - {event.title} 55 - </p> 56 - <p className="text-xs" style={{ color: event.accent }}> 57 - {event.start} - {event.end} 58 - </p> 35 + <p className="font-medium leading-tight" style={{ color: event.accent }}>{event.title}</p> 36 + <p className="text-xs" style={{ color: event.accent }}>{event.start} - {event.end}</p> 59 37 </div> 60 38 </div> 61 39 ); ··· 63 41 64 42 export function LandingHeroDemo() { 65 43 return ( 66 - <div className="mt-6 grid gap-4 lg:grid-cols-[1.2fr_1fr]"> 44 + <div className="mt-8 grid gap-4 lg:grid-cols-[1.2fr_1fr]"> 67 45 <div className="rounded-xl border border-white/10 bg-[var(--landing-panel)] p-4"> 68 - <p className="landing-title-reveal mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Week View</p> 46 + <LandingTitle as="p" className="mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Week View</LandingTitle> 69 47 <div className="space-y-2"> 70 - {demoEvents.map((event) => ( 71 - <WeekViewEventBlock key={event.id} event={event} /> 72 - ))} 48 + {demoEvents.map((event) => <WeekViewEventBlock key={event.id} event={event} />)} 73 49 </div> 74 50 </div> 75 51 76 52 <div className="rounded-xl border border-white/10 bg-[var(--landing-panel)] p-4"> 77 - <p className="landing-title-reveal mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Encrypted vs Encrypted</p> 53 + <LandingTitle as="p" className="mb-3 text-xs uppercase tracking-[0.14em] text-[var(--landing-subtle)]">Encrypted vs Encrypted</LandingTitle> 78 54 <div className="grid grid-cols-[1fr_auto_1fr] gap-3 text-xs text-[var(--landing-muted)]"> 79 55 <div className="mb-1 uppercase tracking-[0.12em]">Cipher A</div> 80 56 <div className="opacity-0">|</div> 81 57 <div className="mb-1 uppercase tracking-[0.12em]">Cipher B</div> 82 - 83 58 {encryptedRows.map((row) => ( 84 59 <Fragment key={row.left}> 85 - <div className="rounded-md border border-white/10 bg-black/20 px-2 py-2 font-mono text-[11px] text-white/75"> 86 - {row.left} 87 - </div> 60 + <div className="rounded-md border border-white/10 bg-black/20 px-2 py-2 font-mono text-[11px] text-white/75">{row.left}</div> 88 61 <div className="w-px bg-white/20" /> 89 - <div className="rounded-md border border-white/10 bg-black/10 px-2 py-2 font-mono text-[11px] text-white/75"> 90 - {row.right} 91 - </div> 62 + <div className="rounded-md border border-white/10 bg-black/10 px-2 py-2 font-mono text-[11px] text-white/75">{row.right}</div> 92 63 </Fragment> 93 64 ))} 94 65 </div>
+5 -4
components/landing/landing-hero.tsx
··· 1 1 import Image from "next/image"; 2 2 import bannerDark from "@/public/Banner-dark.jpg"; 3 3 import { LandingHeroDemo } from "./landing-hero-demo"; 4 + import { LandingTitle } from "./landing-title"; 4 5 5 6 export function LandingHero() { 6 7 return ( 7 - <section id="top" className="flex min-h-[calc(100svh-72px)] flex-col justify-center py-10"> 8 + <section id="top" className="py-16 md:py-24"> 8 9 <div className="mx-auto max-w-4xl text-center"> 9 - <h1 className="landing-title-reveal text-4xl font-semibold leading-tight tracking-tight text-white md:text-[56px]"> 10 + <LandingTitle as="h1" className="text-4xl font-semibold leading-tight tracking-tight text-white md:text-[56px]"> 10 11 The calendar that keeps 11 12 <br /> 12 13 your life private 13 - </h1> 14 + </LandingTitle> 14 15 <p className="mx-auto mt-5 max-w-2xl text-sm text-[var(--landing-muted)] md:text-base"> 15 16 Secure by design. Powerful by default. 16 17 </p> ··· 24 25 </div> 25 26 </div> 26 27 27 - <div className="mt-10 overflow-hidden rounded-2xl border border-white/10 bg-[var(--landing-panel)] p-3 shadow-[0_24px_60px_rgba(0,0,0,0.45)]"> 28 + <div className="mt-12 overflow-hidden rounded-2xl border border-white/10 bg-[var(--landing-panel)] p-3 shadow-[0_24px_60px_rgba(0,0,0,0.45)]"> 28 29 <Image 29 30 src={bannerDark} 30 31 alt="One Calendar dark preview"
+16 -12
components/landing/landing-testimonials.tsx
··· 1 + import { LandingTitle } from "./landing-title"; 2 + 1 3 const feedback = [ 2 4 { 3 5 title: "Clarity over complexity", 4 - detail: "The project vision emphasizes a calm planning experience instead of overloaded automation and noisy analytics.", 6 + detail: "The product vision emphasizes a calm planning experience instead of overloaded automation and noisy analytics.", 5 7 }, 6 8 { 7 9 title: "Privacy-first defaults", 8 10 detail: "No analytics by default and optional encrypted handling are core principles, not afterthoughts.", 9 11 }, 12 + { 13 + title: "Portable workflows", 14 + detail: "Import/export support keeps calendar data usable across ecosystems without lock-in.", 15 + }, 10 16 ]; 11 17 12 18 export function LandingTestimonials() { 13 19 return ( 14 - <section className="flex min-h-screen items-center border-b border-white/10 py-12"> 15 - <div className="w-full"> 16 - <h2 className="landing-title-reveal text-3xl font-semibold text-white md:text-5xl">Why users choose One Calendar</h2> 17 - <div className="mt-8 grid gap-4 md:grid-cols-2"> 18 - {feedback.map((item) => ( 19 - <article key={item.title} className="rounded-2xl border border-white/10 bg-[var(--landing-panel)] p-6"> 20 - <h3 className="landing-title-reveal text-xl font-medium text-white md:text-2xl">{item.title}</h3> 21 - <p className="mt-3 text-sm leading-relaxed text-[var(--landing-muted)] md:text-base">{item.detail}</p> 22 - </article> 23 - ))} 24 - </div> 20 + <section className="border-b border-white/10 py-24 md:py-28"> 21 + <LandingTitle as="h2" className="text-3xl font-semibold text-white md:text-5xl">Why users choose One Calendar</LandingTitle> 22 + <div className="mt-10 grid gap-4 md:grid-cols-3"> 23 + {feedback.map((item) => ( 24 + <article key={item.title} className="rounded-2xl border border-white/10 bg-[var(--landing-panel)] p-6"> 25 + <LandingTitle as="h3" className="text-lg font-medium text-white md:text-xl">{item.title}</LandingTitle> 26 + <p className="mt-3 text-sm leading-relaxed text-[var(--landing-muted)] md:text-base">{item.detail}</p> 27 + </article> 28 + ))} 25 29 </div> 26 30 </section> 27 31 );
+43
components/landing/landing-title.tsx
··· 1 + "use client"; 2 + 3 + import { cn } from "@/lib/utils"; 4 + import { type ElementType, type ReactNode, useEffect, useRef, useState } from "react"; 5 + 6 + type LandingTitleProps<T extends ElementType> = { 7 + as?: T; 8 + className?: string; 9 + children: ReactNode; 10 + }; 11 + 12 + export function LandingTitle<T extends ElementType = "h2">({ 13 + as, 14 + className, 15 + children, 16 + }: LandingTitleProps<T>) { 17 + const Tag = (as ?? "h2") as ElementType; 18 + const ref = useRef<HTMLElement | null>(null); 19 + const [visible, setVisible] = useState(false); 20 + 21 + useEffect(() => { 22 + if (!ref.current) return; 23 + const observer = new IntersectionObserver( 24 + (entries) => { 25 + entries.forEach((entry) => { 26 + if (entry.isIntersecting) { 27 + setVisible(true); 28 + observer.disconnect(); 29 + } 30 + }); 31 + }, 32 + { threshold: 0.25, rootMargin: "0px 0px -10% 0px" }, 33 + ); 34 + observer.observe(ref.current); 35 + return () => observer.disconnect(); 36 + }, []); 37 + 38 + return ( 39 + <Tag ref={ref} className={cn("landing-title", visible && "landing-title-visible", className)}> 40 + {children} 41 + </Tag> 42 + ); 43 + }