멋사 블로그 메인 페이지의 ShareSection, FAQSection, 그리고 Header 컴포넌트에 Tailwind Media Query를 적용하여 모바일 뷰를 구성했다.
우선 ShareSection에 반응형 디자인을 적용했다.
import { Button } from 'components/button';
import { SectionLayout } from './section-layout';
export const ShareSection = () => {
const shareCardInfo = [
{
title: 'STEP 1',
description: '오늘의 운세를 확인하세요',
img: '/images/capture1.png',
},
{
title: 'STEP 2',
description: '공유할 친구를 선택하세요',
img: '/images/capture2.png',
},
];
return (
<SectionLayout>
<div className="w-full h-full flex flex-col gap-[80px]">
<div className="w-full flex flex-col mobile:flex-row justify-between items-start mobile:items-center gap-6">
<div className="space-y-4">
<h3 className="text-left text-3xl mobile:text-4xl nanum-extra-bold text-neutral-800">
사주 공유하기
</h3>
<p className="text-lg mobile:text-xl font-bold text-neutral-800">
채팅으로 사주를 공유해보세요
</p>
</div>
<a href="/chat">
<Button className="w-full mobile:w-[250px] h-[50px]" isRounded={true}>
1:1 채팅 하러가기
</Button>
</a>
</div>
<div className="flex flex-col mobile:flex-row gap-6 mobile:gap-10 justify-center">
{shareCardInfo.map((card) => (
<ShareCard
key={card.title}
title={card.title}
description={card.description}
img={card.img}
/>
))}
</div>
</div>
</SectionLayout>
);
};
const ShareCard = ({ title, description, img }) => {
return (
<div className="flex flex-col rounded-xl shadow-md w-full max-w-[450px] overflow-hidden">
<img src={img} alt={title} className="w-full h-auto" />
<div className="p-5 flex flex-col items-start gap-1.5">
<h4 className="text-base font-normal text-neutral-800">{title}</h4>
<p className="text-lg mobile:text-xl font-extrabold text-neutral-800">
{description}
</p>
</div>
</div>
);
};
Button 컴포넌트의 경우 데스크톱에서는 고정된 너비(w-[250px])를 유지하지만, 모바일에서는 w-full을 추가하여 버튼이 화면 크기에 맞게 조정된다.
카드 레이아웃의 경우 flex flex-col mobile:flex-row로 모바일에서 세로로 정렬되고, 데스크톱에서는 가로로 정렬되도록 설정했다. gap 값을 데스크톱과 모바일에서 다르게 적용했다. (gap-6 -> mobile:gap-10)
텍스트 크기 조정을 위해 text-lg와 text-xl을 조합하여 모바일에서 적절한 크기로 보여질 수 있도록 했다.
FAQSection에도 반응형 디자인을 적용했다.
import { SectionLayout } from './section-layout';
import { useState } from 'react';
export const FAQSection = () => {
const faqAccordionInfo = [
{
question: 'Q. 사주 운세를 확인하고 싶은데, 비용은 무료인가요?',
answer:
'첫 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n첫 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
},
{
question: 'Q. 어떤 기술이 활용되었나요?',
answer:
'두 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n두 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
},
{
question:
'Q. 세 번째 질문입니다. 한 줄까지 들어갈 수 있습니다. 그 이상은 말줄임표 처리합니다. 바로 이렇게... 이렇게... 이렇게... 이렇게... 이렇게...',
answer:
'세 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.\n세 번째 질문에 대한 답변입니다. 답변 내용은 어쩌구저쩌구입니다.',
},
];
return (
<SectionLayout>
<div className="w-full h-full flex flex-col gap-[40px] mobile:gap-[60px]">
<h3 className="text-left text-3xl mobile:text-4xl nanum-extra-bold">
FAQs
</h3>
<div className="flex flex-col gap-[20px] mobile:gap-[30px] justify-center">
{faqAccordionInfo.map((accordion) => (
<FAQAccordion
key={accordion.question}
question={accordion.question}
answer={accordion.answer}
/>
))}
</div>
</div>
</SectionLayout>
);
};
const FAQAccordion = ({ question, answer }) => {
const [isOpen, setIsOpen] = useState(false);
return (
<div className="flex flex-col rounded-xl px-6 py-8 shadow-lg w-full gap-4 mobile:px-[50px] mobile:py-10 mobile:gap-5">
<div className="flex justify-between items-center gap-4">
<p className="text-base mobile:text-xl font-bold truncate">
{question}
</p>
<button
className="rounded-full shadow-lg transition"
onClick={() => {
setIsOpen(!isOpen);
}}
>
<svg
className={`transition transform ${isOpen ? '' : '-rotate-90'}`}
xmlns="http://www.w3.org/2000/svg"
width="36"
height="36"
viewBox="0 0 51 51"
fill="none"
>
<circle
className="transition"
cx="25.6691"
cy="25.3309"
r="25.3309"
fill={!isOpen ? '#FFFFFF' : '#6F6C90'}
/>
<path
className="transition"
d="M17.4125 22.2212L25.6691 30.4405L33.9257 22.2212"
stroke={!isOpen ? '#6F6C90' : '#FFFFFF'}
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
</button>
</div>
{isOpen && (
<p className="text-sm mobile:text-lg w-full text-left">{answer}</p>
)}
</div>
);
};
padding, gap, text 크기를 조정하여 모바일 환경에서도 적절한 공간이 생기도록 했다. px-6 py-8를 모바일에서 px-[50px] py-10로 확장했다. 텍스트 크기도 조정하기 위해 질문과 답변의 폰트 크기를 text-base에서 mobile:text-xl로, 답변은 text-sm에서 mobile:text-lg로 조정했다. SVG 파일의 크기도 조정해 버튼 내 아이콘의 크기를 모바일에서 더 작게(width="36" height="36") 조정했다. 간격 및 여백에 대해서도 데스크톱에서는 기존 여백(gap-5 등)을 유지하고, 모바일에서는 여백이 조금 더 컴팩트하게 줄어들도록 설정했다.
Header 컴포넌트에도 반응형 디자인을 적용했다.
import React, { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { PointModal } from './modals/point-modal';
import coin from '../assets/icons/coin.png';
import { removeCookie } from '../utils/cookie';
import { signOut } from '../apis/api';
import { useSelector, useDispatch } from 'react-redux';
import { setLoginState, setUserProfile } from '../redux/user-slice';
import { ProfileImage } from '../components/profile-image';
export const Header = () => {
const [isLogin, setIsLogin] = useState(true);
const location = useLocation();
const [showProfile, setShowProfile] = useState(false);
const [isPointModalOpen, setIsPointModalOpen] = useState(false);
const nickname = useSelector((state) => state.user.nickname);
const point = useSelector((state) => state.user.remaining_points);
const profileImgIndex = useSelector((state) => state.user.profilepic_id);
const loggedIn = useSelector((state) => state.user.isLogin);
const dispatch = useDispatch();
useEffect(() => {
setIsLogin(loggedIn);
}, []);
const linkStyle =
'text-base mobile:text-xl font-bold text-[#14142B] leading-6 hover:font-extrabold hover:text-[#4A3AFF] hover:cursor-pointer';
const activeLinkStyle =
'text-base mobile:text-xl font-extrabold text-[#4A3AFF] leading-6';
const onClickPoint = () => {
setIsPointModalOpen(true);
};
const onClickLogout = async () => {
const res = await signOut();
if (res !== null) {
removeCookie('access_token');
removeCookie('refresh_token');
dispatch(setLoginState(false));
dispatch(
setUserProfile({
user: null,
nickname: null,
profilepic_id: null,
remaining_points: null,
}),
);
window.location.href = '/';
}
};
return (
<div className="w-full flex flex-col mobile:flex-row items-center justify-between bg-white drop-shadow h-auto mobile:h-[80px] px-4 mobile:px-[68px] z-[999]">
<Link
to="/"
className="text-lg mobile:text-[26px] font-extrabold text-[#14142B] leading-7 mobile:leading-9 tracking-tighter mb-4 mobile:mb-0"
>
멋쟁이 사주처럼
</Link>
<div className="flex flex-col mobile:flex-row items-center gap-4 mobile:gap-[50px]">
<Link
to="/saju"
className={
location.pathname === '/saju' ? activeLinkStyle : linkStyle
}
>
사주
</Link>
<Link
to="/chat"
className={
location.pathname === '/chat' ? activeLinkStyle : linkStyle
}
>
채팅
</Link>
{isLogin ? (
<div
className="relative"
onMouseOver={() => setShowProfile(true)}
onMouseLeave={() => setShowProfile(false)}
>
<span className="text-base mobile:text-xl font-bold text-[#14142B] leading-6 hover:font-extrabold hover:text-[#4A3AFF] hover:cursor-pointer">
프로필
</span>
{showProfile && (
<div className="absolute top-[50px] mobile:top-[25px] right-0 bg-white drop-shadow w-[221px] p-4 mobile:p-[25px] rounded-[12px] flex flex-col gap-4 mobile:gap-5">
{profileImgIndex && (
<div className="flex flex-row gap-4 mobile:gap-[10px] items-center">
<ProfileImage
profileImageId={profileImgIndex}
additionalClassName={'w-[30px] h-[30px]'}
/>
<span className="text-base mobile:text-lg font-bold text-[#170F49] leading-6">
{nickname}
</span>
</div>
)}
<div className="flex flex-row items-center justify-between">
<div className="flex flex-row gap-2 mobile:gap-[10px] items-center">
<img src={coin} alt="coin" className="w-[20px] mobile:w-[30px] h-[20px] mobile:h-[30px]" />
<span className="text-base mobile:text-lg font-bold text-[#170F49] leading-6">
포인트
</span>
</div>
<span className="text-base mobile:text-lg font-bold text-[#4A3AFF] leading-6">
{point}
<span className="text-[#160F49]">P</span>
</span>
</div>
<button
onClick={onClickPoint}
className="bg-[#160F49] text-white text-base mobile:text-bases font-semibold leading-6 rounded-[50px] px-4 mobile:px-6 py-2 mobile:py-[6px]"
>
충전하기
</button>
<span
onClick={onClickLogout}
className="text-sm mobile:text-base font-normal underline text-[#160F49] self-start cursor-pointer"
>
로그아웃
</span>
</div>
)}
</div>
) : (
<Link
to="login"
className="text-sm mobile:text-xl font-bold text-[#4A3AFF] leading-6 bg-[#F3F1FF] px-4 mobile:px-7 py-[10px] mobile:py-[17px] rounded-[50px]"
>
로그인
</Link>
)}
</div>
{isPointModalOpen && <PointModal setIsModalOpen={setIsPointModalOpen} />}
</div>
);
};
전체 Header를 flex-col mobile:flex-row로 설정하여 모바일에서는 위아래로 쌓이고, 데스크톱에서는 좌우로 정렬되게 했다. 모바일에서는 gap-4, 데스크톱에서는 gap-[50px]로 간격을 설정했다. 로고와 링크의 텍스트 크기를 text-lg에서 mobile:text-[26px] 또는 text-base에서 mobile:text-xl로 조정했다. Profile 모달의 패딩, 여백, 폰트 크기를 모바일에서 작게 설정했고(p-4), 데스크톱에서 여유롭게 설정했다(p-[25px]). 로그인 버튼의 크기도 조정하기 위해 px-4 및 py-[10px]로 모바일에서 작게, 데스크톱에서 더 큰 크기(px-7, py-[17px])로 설정했다.
메인페이지의 좌측 텍스트 섹션에 대해서도 스크롤 이벤트에 따라 왼쪽에서 오른쪽으로 튀어나오는 애니메이션을 적용했다. 해당 텍스트를 담고 있는 div에 대해 Ref를 적용했다.
import { Button } from 'components/button';
import { SectionLayout } from './section-layout';
import { useEffect, useRef } from 'react';
import gsap from 'gsap';
const GRADIENT_TOP_START_COLOR = '#170F49';
const GRADIENT_TOP_END_COLOR = '#E3E6F7';
const GRADIENT_BOTTOM_START_COLOR = '#6F6C8F';
const GRADIENT_BOTTOM_END_COLOR = '#F7F7F7';
export const MainSection = () => {
const sectionRef = useRef(null);
const designOuterRef = useRef(null);
const designInnerRef = useRef(null);
const welcomeMsgRef = useRef(null);
const card1Ref = useRef(null);
const card2Ref = useRef(null);
const card3Ref = useRef(null);
const card4Ref = useRef(null);
const lionRef = useRef(null);
const h1Ref = useRef(null);
const maxScroll = window.innerHeight * 5;
useEffect(() => {
interpolateBackground(0);
interpolateDesignPosition(0);
interpolateH1Animation(0);
const handleScroll = () => {
interpolateBackground(window.scrollY);
interpolateDesignPosition(window.scrollY);
interpolateH1Animation(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
const interpolateBackground = (scrollY) => {
const factor = Math.min(scrollY / maxScroll, 1);
if (sectionRef.current) {
gsap.to(sectionRef.current, {
background: `linear-gradient(to bottom, ${gsap.utils.interpolate(
GRADIENT_TOP_START_COLOR,
GRADIENT_TOP_END_COLOR,
factor
)}, ${gsap.utils.interpolate(
GRADIENT_BOTTOM_START_COLOR,
GRADIENT_BOTTOM_END_COLOR,
factor
)})`,
duration: 0,
ease: 'none',
});
}
};
const interpolateDesignPosition = (scrollY) => {
const factor1 =
scrollY < maxScroll / 2
? 0
: Math.min(((scrollY - maxScroll / 2) * 2) / maxScroll, 1);
const originalX = designOuterRef.current.getBoundingClientRect().x;
const offsetXFromMiddle = window.innerWidth / 2 - (originalX + 170);
if (designInnerRef.current) {
gsap.to(designInnerRef.current, {
x: offsetXFromMiddle * (1 - factor1),
});
}
const factor2 = Math.min(scrollY / (maxScroll / 2), 1);
const viewportYMiddle = window.innerHeight / 2;
if (welcomeMsgRef.current) {
gsap.to(welcomeMsgRef.current, {
y: -50,
opacity: 1 - factor2,
});
}
if (card1Ref.current) {
gsap.to(card1Ref.current, {
x: -180 + 30 * factor2,
y: -80 * factor2,
rotate: -45 * factor2,
});
}
if (card2Ref.current) {
gsap.to(card2Ref.current, {
x: -60,
y: -160 * factor2,
rotate: -15 * factor2,
});
}
if (card3Ref.current) {
gsap.to(card3Ref.current, {
x: 60,
y: -160 * factor2,
rotate: 15 * factor2,
});
}
if (card4Ref.current) {
gsap.to(card4Ref.current, {
x: 180 - 30 * factor2,
y: -80 * factor2,
rotate: 45 * factor2,
});
}
if (lionRef.current) {
gsap.to(lionRef.current, {
y: viewportYMiddle * 1.5 * (1 - factor2),
});
}
};
const interpolateH1Animation = (scrollY) => {
const factor = Math.min(scrollY / (window.innerHeight * 1), 1);
if (h1Ref.current) {
gsap.to(h1Ref.current, {
x: `${-200 + 200 * factor}px`,
opacity: factor,
duration: 0.5,
ease: 'power2.out',
});
}
};
return (
<SectionLayout
outerLayerClassName={'h-[500vh] flex items-start'}
innerLayerClassName={`sticky top-[80px] h-[calc(100vh-80px)]`}
innerLayerRef={sectionRef}
>
<div className="relative flex flex-col w-full gap-8 items-start mobile:items-center">
<div className="flex flex-col items-start mobile:items-center gap-8" ref={h1Ref} >
<h1
className="text-[64px] mobile:text-[32px] leading-normal whitespace-pre-wrap text-left mobile:text-center nanum-extra-bold text-black dark:text-white transform translate-x-[-200px] opacity-0"
>
<span>멋쟁이</span>{' '}
<s className="text-gray-500 dark:text-gray-400">사자</s> {'\n'}
<span>사주처럼</span>
</h1>
<p className="text-lg mobile:text-sm text-left mobile:text-center mobile:whitespace-pre-wrap dark:text-white">
{'오늘의 사주 운세를 확인하고,\n친구에게 공유하자!'}
</p>
<a href="/saju">
<Button>멋사주 시작하기</Button>
</a>
</div>
<div
ref={designOuterRef}
className="absolute -bottom-10 right-0 size-[340px]"
>
<div
className="w-full h-full flex justify-center"
ref={designInnerRef}
>
<p
ref={welcomeMsgRef}
className="font-extrabold text-[44px] mobile:text-[28px] text-white text-nowrap w-fit opacity-0"
>
오늘의 운세가 궁금해?
</p>
<img
ref={lionRef}
src="/images/snulion.png"
className="absolute z-50 mobile:scale-[50%] object-contain"
alt="Main Illustration"
/>
<img
ref={card1Ref}
src="/images/card1.png"
className="absolute scale-[60%] mobile:scale-[30%] object-contain"
alt="Card1 Illustration"
/>
<img
ref={card2Ref}
src="/images/card2.png"
className="absolute scale-[60%] mobile:scale-[30%] object-contain"
alt="Card2 Illustration"
/>
<img
ref={card3Ref}
src="/images/card3.png"
className="absolute scale-[60%] mobile:scale-[30%] object-contain"
alt="Card3 Illustration"
/>
<img
ref={card4Ref}
src="/images/card4.png"
className="absolute scale-[60%] mobile:scale-[30%] object-contain"
alt="Card4 Illustration"
/>
</div>
</div>
</div>
</SectionLayout>
);
};
아래는 해당 레포지토리 링크이다.
'Dev & Study' 카테고리의 다른 글
| [git] rebase란? merge와의 차이점, rebase 후 push 방법 (0) | 2026.03.02 |
|---|---|
| 멋사 세미나 과제 5 (2) | 2024.12.05 |
| 멋사 세미나 과제 3 (3) | 2024.11.07 |
| Payment Gateway, Redux-toolkit (1) | 2024.10.10 |
| OAuth 2.0, Authorization Grant, Flux Architecture (2) | 2024.09.27 |