Desktop
diff --git a/src/components/landing/LandingMainSection.tsx b/src/components/landing/LandingMainSection.tsx
index 5b4e700..3975eab 100644
--- a/src/components/landing/LandingMainSection.tsx
+++ b/src/components/landing/LandingMainSection.tsx
@@ -8,7 +8,7 @@ export function LandingMainSection({ children, className }: LandingMainSectionPr
className={cn(
"mx-auto w-full max-w-[128rem] px-6 mt-30",
"space-y-[25rem]",
- "md:mt-45 lg:mt-70 md:space-y-[35rem]",
+ "md:mt-45 lg:mt-70 md:space-y-[50rem]",
className,
)}
>
diff --git a/src/components/landing/LandingSpecialFeatureGrid.tsx b/src/components/landing/LandingSpecialFeatureGrid.tsx
index 6623247..1116aa0 100644
--- a/src/components/landing/LandingSpecialFeatureGrid.tsx
+++ b/src/components/landing/LandingSpecialFeatureGrid.tsx
@@ -1,3 +1,4 @@
+// src/components/landing/LandingSpecialFeatureGrid.tsx
"use client";
import { SPECIAL_FEATURES } from "@/lib/constants";
@@ -6,23 +7,37 @@ import { Icon } from "@/shared/Icon";
import { SpecialFeatureCard } from "@/shared/SpecialFeatureCard";
import type { LandingSpecialFeatureGridProps } from "@/types/landing";
-export function LandingSpecialFeatureGrid({ className }: LandingSpecialFeatureGridProps) {
+export function LandingSpecialFeatureGrid({
+ className,
+ isInView = false,
+}: LandingSpecialFeatureGridProps) {
return (
- {SPECIAL_FEATURES.map((feature) => (
- }
- title={feature.title}
- description={feature.description}
- />
- ))}
+ {SPECIAL_FEATURES.map((feature, index) => {
+ const delayMs = index * 200;
+
+ return (
+ }
+ title={feature.title}
+ description={feature.description}
+ className={cn(
+ // 공통 트랜지션 + hover 효과
+ "transition-all duration-700 ease-out",
+ "hover:-translate-y-1 hover:shadow-[0_18px_40px_rgba(0,0,0,0.12)]",
+ // 스크롤 진입 시: 아래에서 위로 + 페이드 인
+ isInView ? "opacity-100 translate-y-0" : "opacity-0 translate-y-8",
+ )}
+ style={{ transitionDelay: `${delayMs}ms` }}
+ />
+ );
+ })}
);
}
diff --git a/src/components/landing/LandingSpecialFeaturesSection.tsx b/src/components/landing/LandingSpecialFeaturesSection.tsx
index d81c1dd..bd45d05 100644
--- a/src/components/landing/LandingSpecialFeaturesSection.tsx
+++ b/src/components/landing/LandingSpecialFeaturesSection.tsx
@@ -1,22 +1,30 @@
"use client";
import { LandingSpecialFeatureGrid } from "@/components/landing/LandingSpecialFeatureGrid";
+import { useInView } from "@/hooks/useInView";
import { cn } from "@/lib/utils";
import type { LandingSpecialFeaturesSectionProps } from "@/types/landing";
export function LandingSpecialFeaturesSection({ className }: LandingSpecialFeaturesSectionProps) {
+ const { ref, isInView } = useInView({ threshold: 0.5, once: false });
+
return (
-
- {/* 섹션 인트로: 제목 + 부제 */}
-
+
+ {/* 섹션 인트로: 제목 + 부제 (아래 → 위 슬라이드) */}
+
PlanMate의 특별한 기능들
@@ -25,8 +33,8 @@ export function LandingSpecialFeaturesSection({ className }: LandingSpecialFeatu
- {/* 하단 카드 그리드 */}
-
+ {/* 하단 카드 그리드 (isInView 전달) */}
+
);
diff --git a/src/hooks/useFullPageScroll.ts b/src/hooks/useFullPageScroll.ts
new file mode 100644
index 0000000..d43a86d
--- /dev/null
+++ b/src/hooks/useFullPageScroll.ts
@@ -0,0 +1,97 @@
+"use client";
+
+import { useEffect } from "react";
+
+export function useFullPageScroll() {
+ useEffect(() => {
+ const sections = document.querySelectorAll(".scroll-section");
+ if (!sections.length) return;
+
+ let isScrolling = false;
+ let startY = 0;
+
+ const getClosestSectionIndex = () => {
+ const scrollY = window.scrollY;
+ let closestIndex = 0;
+ let smallestDiff = Number.POSITIVE_INFINITY;
+
+ sections.forEach((section, index) => {
+ const diff = Math.abs(section.offsetTop - scrollY);
+ if (diff < smallestDiff) {
+ smallestDiff = diff;
+ closestIndex = index;
+ }
+ });
+ return closestIndex;
+ };
+
+ const scrollToSection = (index: number) => {
+ const target = sections[index];
+ if (!target) return;
+
+ const sectionTop = target.offsetTop;
+ const sectionHeight = target.offsetHeight;
+ const viewportHeight = window.innerHeight;
+
+ const lastIndex = sections.length - 1;
+ const isLast = index === lastIndex;
+
+ // 마지막 섹션만 위 정렬, 나머지는 중앙 정렬
+ const targetY = isLast ? sectionTop : sectionTop - (viewportHeight - sectionHeight) / 2;
+
+ window.scrollTo({
+ top: targetY,
+ behavior: "smooth",
+ });
+ };
+
+ const moveOneSection = (direction: 1 | -1) => {
+ if (isScrolling) return;
+ isScrolling = true;
+
+ const currentIndex = getClosestSectionIndex();
+ const nextIndex = Math.min(Math.max(currentIndex + direction, 0), sections.length - 1);
+
+ scrollToSection(nextIndex);
+
+ setTimeout(() => {
+ isScrolling = false;
+ }, 900);
+ };
+
+ const handleWheel = (event: WheelEvent) => {
+ event.preventDefault();
+ const deltaY = event.deltaY;
+ if (Math.abs(deltaY) < 10) return;
+
+ const direction: 1 | -1 = deltaY > 0 ? 1 : -1;
+ moveOneSection(direction);
+ };
+
+ const handleTouchStart = (e: TouchEvent) => {
+ startY = e.touches[0].clientY;
+ };
+
+ const handleTouchEnd = (e: TouchEvent) => {
+ const endY = e.changedTouches[0].clientY;
+ const deltaY = startY - endY;
+ if (Math.abs(deltaY) < 50) return;
+
+ const direction: 1 | -1 = deltaY > 0 ? 1 : -1;
+ moveOneSection(direction);
+ };
+
+ window.addEventListener("wheel", handleWheel, { passive: false });
+ window.addEventListener("touchstart", handleTouchStart, { passive: true });
+ window.addEventListener("touchend", handleTouchEnd, { passive: true });
+
+ // 첫 진입 시 Hero 섹션으로 스냅
+ scrollToSection(0);
+
+ return () => {
+ window.removeEventListener("wheel", handleWheel);
+ window.removeEventListener("touchstart", handleTouchStart);
+ window.removeEventListener("touchend", handleTouchEnd);
+ };
+ }, []);
+}
diff --git a/src/hooks/useInView.ts b/src/hooks/useInView.ts
new file mode 100644
index 0000000..e80b0fa
--- /dev/null
+++ b/src/hooks/useInView.ts
@@ -0,0 +1,36 @@
+import { UseInViewOptions, UseInViewResult } from "@/types/inView";
+import { useEffect, useRef, useState } from "react";
+
+export function useInView(options: UseInViewOptions = {}): UseInViewResult {
+ const { threshold = 0.2, rootMargin = "0px", once = true } = options;
+
+ const ref = useRef(null);
+ const [isInView, setIsInView] = useState(false);
+
+ useEffect(() => {
+ const node = ref.current;
+ if (!node) return;
+
+ const observer = new IntersectionObserver(
+ (entries) => {
+ entries.forEach((entry) => {
+ if (entry.isIntersecting) {
+ setIsInView(true);
+ if (once) {
+ observer.unobserve(entry.target);
+ }
+ } else if (!once) {
+ setIsInView(false);
+ }
+ });
+ },
+ { threshold, rootMargin },
+ );
+
+ observer.observe(node);
+
+ return () => observer.disconnect();
+ }, [threshold, rootMargin, once]);
+
+ return { ref, isInView };
+}
diff --git a/src/styles/globals.css b/src/styles/globals.css
index edf1940..baf37db 100644
--- a/src/styles/globals.css
+++ b/src/styles/globals.css
@@ -118,6 +118,12 @@
color-scheme: light;
} /* 라이트 고정 */
+ html,
+ body {
+ height: 100%;
+ scroll-behavior: smooth;
+ }
+
html {
font-size: 62.5%; /* 1rem = 10px */
font-family: var(--font-sans);
diff --git a/src/types/inView.ts b/src/types/inView.ts
new file mode 100644
index 0000000..2462cbd
--- /dev/null
+++ b/src/types/inView.ts
@@ -0,0 +1,12 @@
+import { RefObject } from "react";
+
+export interface UseInViewOptions {
+ threshold?: number;
+ rootMargin?: string;
+ once?: boolean;
+}
+
+export interface UseInViewResult {
+ ref: RefObject;
+ isInView: boolean;
+}
diff --git a/src/types/landing.ts b/src/types/landing.ts
index 34b4aef..5bce54d 100644
--- a/src/types/landing.ts
+++ b/src/types/landing.ts
@@ -80,7 +80,9 @@ export type LandingFeatureTextProps = WithClassName;
------------------------------------------------- */
export type LandingSpecialFeaturesSectionProps = WithClassName;
-export type LandingSpecialFeatureGridProps = WithClassName;
+export type LandingSpecialFeatureGridProps = WithClassName & {
+ isInView?: boolean;
+};
/* -------------------------------------------------
특별한 기능 섹션 — Feature 리스트
@@ -93,3 +95,10 @@ export interface SpecialFeatureItem {
description: string;
iconName: SpecialFeatureIconName;
}
+
+/* -------------------------------------------------
+ full page 컴포넌트
+ ------------------------------------------------- */
+export interface LandingFullPageWrapperProps {
+ children: React.ReactNode;
+}