μ ν¬ νλ‘μ νΈ νΉμ±μ μ²μ μ μνλ μ¬μ©μκ° μ¬μ©λ²μ μ΅νκΈ°μ μ΄λ €μμ΄ μμ κ²μ΄λΌ νλ¨νκ³ , μ¨λ³΄λ© νμ΄μ§κ° νμμ μΌλ‘ λ€μ΄κ°μΌκ² λ€κ³ μκ°νκ² λμμ΅λλ€....... νμ§λ§ μκ°μ΄ λ무 μμκΈ° λλ¬Έμ..... κ°μ₯ κ°λ¨νκ² (νμ§λ§ λΌμ΄λΈλ¬λ¦¬λ₯Ό μ¬μ©νμ§ μκ³ .....) ꡬννκΈ° μν΄ κ³ λ―Όνκ³ , λΉ λ₯΄κ² ꡬνν μ μμμ΅λλ€.
μ΄λ² νλ‘μ νΈμμλ λ‘컬 μ€ν 리μ§(Local Storage)λ₯Ό νμ©ν΄ ν λ² μ¨λ³΄λ©μ μλ£ν μ¬μ©μμκ²λ λ€μ μ¨λ³΄λ©μ 보μ¬μ£Όμ§ μλ κΈ°λ₯κ³Ό, κ°μ₯ κ°λ¨νκ³ λΉ λ₯΄κ² ꡬννλ©΄μλ λ°μνμ κ³ λ €ν UIλ‘ κ΅¬ννλ κ²μ μ€μνκ² μκ°νμ΅λλ€.
κ·Έλμ μ΄λ² ν¬μ€ν μμλ μ κ° μ¨λ³΄λ© νμ΄μ§λ₯Ό ꡬμ±νλ©΄μ κ²½ννλ λ΄μ©μ μμ±ν΄λ³΄λ €κ³ ν©λλ€!!
μ£Όμνκ² κ΅¬νν΄μΌ νλ, μκ°ν΄μΌ νλ μμλ μλ 4κ°μ§ μ λμμ΅λλ€.
μ ν¬ νλ‘μ νΈμλ μ¨λ³΄λ© νμ΄μ§λ³΄λ€ ν¨μ¬ μ€μν κΈ°λ₯ κ°μ μ΄λ UI κ°μ μ΄ λ§μκΈ° λλ¬Έμ, μ¨λ³΄λ© νμ΄μ§λ μ΅λν κ°λ¨νκ³ λΉ λ₯΄κ² ꡬνν΄μΌ νμ΅λλ€β¦β¦..
κ·Έλμ μ΅λν μ΄λ―Έ μ ν¬κ° ꡬνν΄λ μ¬μ΄νΈμ μΊ‘μ² μ΄λ―Έμ§λ₯Ό μ¬μ©νλ €κ³ νκ³ , κ·Έλμ μ΄λ° μμΌλ‘ λ°μ΄ν°λ₯Ό λ°λ‘ μ μ₯ν μ μλλ‘ νμΌ κ΅¬μ‘°λ₯Ό μ€κ³νμμ΅λλ€.
src/
βββ components/
β βββ Onboarding.tsx // μ¨λ³΄λ© UIλ₯Ό λ΄μ μ»΄ν¬λνΈ
βββ lib/
β βββ data/
β βββ onboardingData.ts // μ¨λ³΄λ© μ¬λΌμ΄λ λ°μ΄ν°
βββ App.tsx // μ¨λ³΄λ© μ»΄ν¬λνΈλ₯Ό νΈμΆνλ λ£¨νΈ μ»΄ν¬λνΈ
λ¨Όμ useState
λ₯Ό μ¬μ©ν΄ νμ¬ μ¬λΌμ΄λ(currentSlide
)λ₯Ό κ΄λ¦¬νκ³ , 'λ€μ' λ²νΌμ λλ₯΄λ©΄ μ¬λΌμ΄λκ° μ΄λνλλ‘ κ΅¬ννμ΅λλ€.
κ·Έλ¦¬κ³ λ§μ§λ§ μ¬λΌμ΄λμμλ μλμΌλ‘ μ¨λ³΄λ©μ΄ μ’ λ£λ©λλ€.
const [currentSlide, setCurrentSlide] = useState(0);
const handleNext = () => {
if (currentSlide < onboardingData.length - 1) {
setCurrentSlide(currentSlide + 1);
} else {
onComplete(); // μ¨λ³΄λ© μ’
λ£
}
};
onComplete
ν¨μλ μ¨λ³΄λ©μ μ’
λ£νκ³ λ‘컬 μ€ν 리μ§μ onboardingCompleted
νλκ·Έλ₯Ό μ μ₯ν©λλ€.
μ΄λ₯Ό ν΅ν΄ μ¬μ©μκ° λ€μ λ°©λ¬Ένμ λ μ¨λ³΄λ©μ μλ΅νλλ‘ νμ΅λλ€.
(μ¬μ€ μ€μ μ½λμμλ localStorageμ μ μ₯νλ util ν¨μλ₯Ό λ§λ€μ΄λμκΈ° λλ¬Έμ μ‘°κΈ λ€λ₯΄μ§λ§, μ΄λ° μμΌλ‘ localStorageμ μ μ₯νλ€ μ λλ§ λ΄μ£Όμλ©΄ λ κ² κ°μ΅λλ€!!)
const onComplete = () => {
localStorage.setItem('onboardingCompleted', 'true');
// μ΄ν λ©μΈ νλ©΄μΌλ‘ μ΄λνκ±°λ μ¨λ³΄λ© μ’
λ£ μ²λ¦¬
};
μ¨λ³΄λ© νμ΄μ§λ μ΄λ―Έμ§μ μ€λͺ ν μ€νΈλ‘ ꡬμ±λμ΄ μκ³ , μ΄λ₯Ό Tailwind CSSλ‘ λ°°μΉνμ΅λλ€.
(μμμ μΈκΈνλ― μκ°μ΄ μμκΈ°μ μ΅λν κ°λ¨νκ² κ΅¬ννκ³ μ νκ³ , μ΄λ―Έμ§λ₯Ό νμ©νλ λ°©μμ΄ κ°μ₯ κ°λ¨νλ€κ³ νλ¨νμ΅λλ€.)
κ·Έλ¦¬κ³ μ΄λ―Έμ§μλ absolute
μ object-contain
μ μ¬μ©ν΄ ν¬κΈ°μ μμΉλ₯Ό λμ μΌλ‘ μ‘°μ ν΄μ£Όμμ΅λλ€.
(μ΄κ±΄ μλ§ μλμ μμ±λ κ²°κ³Ό νλ©΄μ 보μλ©΄ μ΄ν΄λμ€ κ² κ°μ΅λλ€!)
<img
src={`/assets/images/onboarding/slide${onboardingData[currentSlide].id}.png`}
alt={`Slide ${currentSlide + 1}`}
className="absolute top-10 mx-auto h-[75vh] object-contain"
/>
App.tsx
μμλ λ‘컬 μ€ν 리μ§μ onboardingCompleted
κ°μ νμΈνμ¬ μ¨λ³΄λ© μ¬λΆλ₯Ό κ²°μ νμ΅λλ€.
import React, { useState, useEffect } from 'react';
import { Onboarding } from './components/Onboarding';
const App = () => {
const [showOnboarding, setShowOnboarding] = useState(false);
useEffect(() => {
const completed = localStorage.getItem('onboardingCompleted');
if (!completed) {
setShowOnboarding(true);
}
}, []);
return (
<div>
{showOnboarding ? (
<Onboarding onComplete={() => setShowOnboarding(false)} />
) : (
<div>λ©μΈ μ½ν
μΈ νμ</div>
)}
</div>
);
};
export default App;
μ¨λ³΄λ© μ¬λΌμ΄λμ νμν λ΄μ©μ λ³λμ λ°μ΄ν° νμΌλ‘ κ΄λ¦¬νμ΅λλ€. μ΄λ₯Ό ν΅ν΄ μ»΄ν¬λνΈμ λ°μ΄ν°λ₯Ό λΆλ¦¬νκ³ μ μ§λ³΄μμ±μ λμμ΅λλ€.
export const onboardingData = [
{
id: 1,
content: 'μ ν¬ βμ λ°λΌ κΈΈλ°λΌβ μλΉμ€κ° μ²μμ΄μλΌκ³ μ? \nμ κ° μ¬μ©λ²μ μλ €λ릴κ²μ!',
},
{
id: 2,
content: 'μ±λμ μμ±νκ³ μΆμΌμλ€λ©΄, \nνμκ°μ
ν λ‘κ·ΈμΈμ ν΄μ£ΌμΈμ!',
},
{
id: 3,
content:
'λ‘κ·ΈμΈ ν λ³ΈμΈμ΄ μμ±ν΄λ μ±λλ€μ νμΈν μ μμ΅λλ€! \n곡μ νκΈ°λ₯Ό λλ¬ κ²μ€νΈ λ³λ‘ λ§ν¬λ₯Ό νμΈν μλ μμ΄μ!',
},
...
];
<div className="flex space-x-2">
{onboardingData.map((slide, index) => (
<div
key={slide.id}
className={`h-1 w-1 rounded-full ${
index === currentSlide ? 'bg-blueGray-200' : 'bg-gray-300'
}`}
/>
))}
</div>
height
μ€μ κ·Έλ°λ° λ°°ν¬ ν μ¨λ³΄λ© νμ΄μ§λ₯Ό λͺ¨λ°μΌ νκ²½μμ μ€ννλ©΄μ μμμΉ λͺ»ν λ¬Έμ κ° λ°μνμ΅λλ€.
νΉμ κΈ°κΈ°μμ height
κ°μ΄ window.innerHeight
μ μΌμΉνμ§ μμ λΆνμν μ€ν¬λ‘€μ΄ λ°μνλ νμμ΄μμ΅λλ€.
μ΄λ λͺ¨λ°μΌ λΈλΌμ°μ κ° λ·°ν¬νΈλ₯Ό κ³μ°νλ λ°©μμ΄ window.innerHeight
μ μΌμΉνμ§ μμ λ λ°μνλ λ¬Έμ λ‘, μ΄λ₯Ό ν΄κ²°νκΈ° μν΄ CSS
μ JavaScript
λ₯Ό κ²°ν©ν λ°©λ²μ μ μ©νμ΅λλ€.
λͺ¨λ°μΌ λΈλΌμ°μ λ μ£Όμ νμμ€κ³Ό κ°μ UI μμλ‘ μΈν΄ λ·°ν¬νΈμ λμ΄κ° λμ μΌλ‘ λ³κ²½λ μ μμ΅λλ€.
νΉν, vh
λ¨μλ λ·°ν¬νΈ λμ΄μ 1%λ₯Ό μλ―Ένμ§λ§, λͺ¨λ°μΌμμ λΈλΌμ°μ UIμ μν΄ μλͺ» κ³μ°λ κ°λ₯μ±μ΄ μμ΅λλ€.
κ·Έ κ²°κ³Ό μλνμ§ μμ μ€ν¬λ‘€μ΄ λ°μνμ΅λλ€.
useEffect
λ₯Ό μ¬μ©ν΄ λμ μΌλ‘ λμ΄λ₯Ό κ³μ°νκ³ μ΄λ₯Ό CSS λ³μλ‘ μ€μ νμ¬ λͺ¨λ νλ©΄μμ λμΌν λμμ 보μ₯νλλ‘ κ΅¬ννμ΅λλ€.
useEffect(() => {
const setVh = () => {
// μ€μ λμ΄λ₯Ό 1vh λ¨μλ‘ λ³ν
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVh();
// μ°½ ν¬κΈ°κ° λ³κ²½λ λλ§λ€ λμ΄λ₯Ό λ€μ κ³μ°
window.addEventListener('resize', setVh);
return () => {
window.removeEventListener('resize', setVh);
};
}, []);
μ΄ μ½λλ₯Ό ν΅ν΄ --vh
λΌλ CSS λ³μλ₯Ό μμ±νκ³ , μ΄λ₯Ό μ€νμΌμμ μ¬μ©νμ¬ μ νν λμ΄λ₯Ό 보μ₯νμ΅λλ€.
html, body {
height: calc(var(--vh, 1vh) * 100); /* --vhλ₯Ό κΈ°μ€μΌλ‘ μ 체 λμ΄ κ³μ° */
overflow: hidden; /* μ€ν¬λ‘€ μ κ±° */
}
height
μ€μ Onboarding.tsx
μ»΄ν¬λνΈμ μ΅μλ¨ μ»¨ν
μ΄λμ style
μμ±μ μ¬μ©νμ¬ λμ΄λ₯Ό μ€μ νμ΅λλ€.
<div
className="relative flex w-full flex-col items-center justify-center overflow-hidden bg-gray-100"
style={{ height: `calc(var(--vh, 1vh) * 100)` }}
>
{/* μ¨λ³΄λ© μ½ν
μΈ */}
</div>
μ΄ λ°©λ²μ ν΅ν΄μ μλμ κ°μ΄ λͺ¨λ°μΌ νκ²½μμλ μ€ν¬λ‘€ μμ΄ μ¨λ³΄λ© νμ΄μ§κ° μ€ν¬λ‘€ μμ΄ νμλ μ μμμ΅λλ€. (λ°°ν°λ¦¬λβ¦β¦λ¬΄μν΄μ£ΌμΈμβ¦β¦β¦β¦β¦..γ
γ
γ
γ
γ
γ
γ
νγ
γ
γ
γ
γ
)
μ΄λ² ꡬνμμ λ°μν λμμΈ, λ‘컬 μ€ν λ¦¬μ§ νμ©, λ°μ΄ν°μ UIμ λΆλ¦¬λ₯Ό ν΅ν΄ μ μ§λ³΄μμ±κ³Ό μ¬μ©μ κ²½νμ λͺ¨λ κ³ λ €ν μ€κ³λ₯Ό ν μ μμμ΅λλ€.
[Onboarding.tsx νμΌ μ 체 μ½λ]
import { useEffect, useState } from 'react';
import { onboardingData } from '@/lib/data/onboardingData.ts';
import { MdClear } from 'react-icons/md';
interface IOnboardingProps {
onComplete: () => void;
}
export const Onboarding = ({ onComplete }: IOnboardingProps) => {
const [currentSlide, setCurrentSlide] = useState(0);
useEffect(() => {
const setVh = () => {
const vh = window.innerHeight * 0.01;
document.documentElement.style.setProperty('--vh', `${vh}px`);
};
setVh();
window.addEventListener('resize', setVh);
return () => {
window.removeEventListener('resize', setVh);
};
}, []);
const handleNext = () => {
if (currentSlide < onboardingData.length - 1) {
setCurrentSlide(currentSlide + 1);
} else {
onComplete();
}
};
const handlePrev = () => {
if (currentSlide > 0) {
setCurrentSlide(currentSlide - 1);
}
};
return (
<div
className="relative flex w-full flex-col items-center justify-center overflow-hidden bg-gray-100"
style={{ height: window.innerHeight }}
>
<div className="absolute right-3 top-3 z-[6000] flex items-center gap-2">
<div className="text-sm text-gray-200">νν λ¦¬μΌ λλ΄κΈ°</div>
<button
onClick={onComplete}
className="flex h-[30px] w-[30px] items-center justify-center rounded-full bg-gray-200"
>
<MdClear size={18} color="grayscale-850" />
</button>
</div>
<div className="relative flex h-screen w-full items-center justify-center">
<img
src={`/assets/images/onboarding/slide${onboardingData[currentSlide].id}.png`}
alt={`Slide ${currentSlide + 1}`}
className="absolute top-10 mx-auto h-[75vh] object-contain"
/>
<div className="absolute inset-0 bg-black bg-opacity-30" />
</div>
<div className="absolute bottom-2 flex w-[95%] flex-col">
<div className="flex w-[100%] items-center justify-center text-white">
<img
src="/assets/images/onboarding/character.png"
alt="μΊλ¦ν°"
className="max-h-16 w-[15%] object-contain"
/>
<div
className="bg-blueGray-200 m-2 flex h-20 w-[85%] items-center justify-center whitespace-pre rounded-lg bg-opacity-[0.5] text-center text-sm leading-relaxed"
style={{ padding: '1rem 1rem' }}
>
{onboardingData[currentSlide].content}
</div>
</div>
<div className="flex items-center justify-between p-4">
<button
onClick={handlePrev}
disabled={currentSlide === 0}
className="rounded bg-gray-300 px-4 py-2 text-gray-700 disabled:opacity-50"
>
μ΄μ
</button>
<div className="flex space-x-2">
{onboardingData.map((slide, index) => (
<div
key={slide.id}
className={`h-1 w-1 rounded-full ${
index === currentSlide ? 'bg-blueGray-200' : 'bg-gray-300'
}`}
/>
))}
</div>
<button onClick={handleNext} className="bg-blueGray-200 rounded px-4 py-2 text-white">
{currentSlide === onboardingData.length - 1 ? 'μμ' : 'λ€μ'}
</button>
</div>
</div>
</div>
);
};