본문 바로가기
SSURENT(풀스택)

[React + TS] 메인화면 구성하기 + Carousel

by CromArchive 2025. 11. 19.
반응형

이번에 구현한 부분은 메인 화면 부분이다.

제일 상단에 있는 부분부터 중단의 카드 3개까지는 Home.tsx로 구현했고

그 아래의 Carousel부분은 따로 Carousel.tsx로 분리해서 구현해주었다.

Carousel 아래 부분은 다음에 구현할 계획이다.

 

메인 화면에서의 가장 중요한 점은 대부분의 사용자는 모바일 화면에서 해당 사이트에 접속할 것이기 때문에 반응형으로 비율 조정이 필요하다는 점이다.

 

메인화면 구현하기

메인화면 구성하는것은 크게 어렵지 않다.

대부분 텍스트 기반이고, 그나마 button이랑 Card 배치와 비율때문에 css를 많이 건드렸지만, 오래걸리지는 않았다.

function Home() {
    return (
        <main className="main-content">
            <h1>학교에서 급하게 필요한</h1>
            <h1>물품이 있다면?</h1>
            <h2>지금 바로 SSURENT에서!</h2>
            <div className="button-container">
                <Link to="/rent">
                    <button type="button" className="btn btn-primary">물품대여 바로가기</button>
                </Link>
                <Link to="/return">
                    <button type="button" className="btn btn-primary">물품반납 바로가기</button>
                </Link>
            </div>
            <div className="middle-describes">
                <h2 >대여사업이 뭔가요</h2>
                <h4>
                    컴퓨터학부 학우분들의 보다 나은 생활을 위해<br/>
                    컴퓨터학부의 학생회비 납부자를 대상으로<br/>
                    물품 대여 사업을 진행하고 있습니다
                </h4>
            </div>
            <div className="info-cards-container">
                <div className="info-card">
                    <PersonFill className="info-icon" color="#000000" />
                    <br/>
                    <br/>
                    <h3>이용 대상</h3>
                    <p>컴퓨터학부 학생회비 납부자</p>
                </div>
                <div className="info-card">
                    <Building className="info-icon" color="#000000" />
                    <br/>
                    <br/>
                    <h3>대여 장소</h3>
                    <p>정보과학관 B1층 컴퓨터학부 학생회실</p>
                </div>
                <div className="info-card">
                    <Alarm className="info-icon" color="#000000" />
                    <br/>
                    <br/>
                    <h3>이용 시간</h3>
                    <p>평일 10:00 ~ 16:00</p>
                </div>
            </div>
            <div className= "bottom-info">
                <h3>학교생활 중 필요한 물건</h3>
                <h1>대부분 여기 다 있을걸요?</h1>
            </div>
            <section className="carousel-section">
                <CardCarousel />
            </section>
        </main>
    );
}

 

버튼의 경우 "btn btn-primary"로 클래스명을 지정해서 bootstrap스타일을 사용할 수 있게 했다.

info-card는 기존 디자인에서 사진으로 지정한 부분을 아이콘으로 변경해주었다.

마땅히 넣을만한 사진도 없고, 아이콘이 사진에 비해 랜더링 속도도 월등히 빠르기 때문에 불필요하다고 생각했기 때문이다.

또한 아이콘을 넣으면 전달력도 더 좋다.

 

CSS는 반응형으로 작성하기 위해서 @media로 일정 픽셀 구간별로 정의해줘야 할 뻔했지만

clamp() 메서드가 있다는 것을 알고 이 방법을 사용했다. 

.button-container .btn.btn-primary {
    font-size: clamp(0.8rem, 2vw, 1.2rem);      /* 글자 크기 */
    font-weight: bold;    /* 글자 굵기 (normal, bold, 700 등) */
    color: #ffffff;       /* 글자 색상 (하얀색) */
    padding: clamp(0.5rem, 1.5vw, 1rem) clamp(0.6rem, 2vw, 1.2rem);;
}

clamp는 최소, 선호, 최대값 순서로 지정되고 구간 범위를 넘어가지 않기 때문에 보다 편리하게 코드를 작성할 수 있었다.

CSS파일의 용량이 작으면 랜더링 시 속도 개선 효과도 있다고 해서 괜찮은 선택이라고 생각한다.

반응형

Carousel 구현하기

Carousel이라는 용어가 따로 있는지는 몰랐다.

화면 밖으로 카드가 나가지만, 원형처럼 이어지게 보이는 이런 기능을 Carousel이라고 한다고 한다.

 

먼저, 카드는 아래코드처럼 구성해놓았다.

import React, {useState, useEffect, useCallback, useRef} from "react";
import './carousel.css'
import {ChevronLeft, ChevronRight} from "react-bootstrap-icons";

interface Card{
    id: number;
    title:string;
    description : string;
}

const CardCarousel: React.FC = () =>{
    const cards: Card[] = [];

 

cards에 들어갈 데이터는 리스트 안에 하드코딩해놓았지만, 나중에 백엔드에서 데이터를 받아오는 식으로 구현하려고 한다.

 

const [currentIndex, setCurrentIndex] = useState(cards.length);
const [isTransitioning, setIsTransitioning] = useState(false);
const [isAutoPlay, setIsAutoPlay] = useState(true);
const [cardsPerView, setCardsPerView] = useState(3);
const trackRef = useRef<HTMLDivElement>(null);
const timeoutRef = useRef<NodeJS.Timeout | null>(null);

 

위는 상태 관리 코드인데, currentIndex는 현재 인덱스를 나타내는 변수, isTransitioning은 현재 카드가 움직이는 중인지 판단하는 변수, isAutoPlay는 자동으로 다음 슬라이드로 옮기기 위해 초를 세는 변수, cardsPerView는 화면에 보이게 하는 카드 개수이다.

 

cardsPerView는 나중에 비율 조절하다보니 사실상 의미가 없어진거같다. 아마 화면 밖에서 미리 데이터를 랜더링해놓는 느낌인거 같다.

 

trackRef는 슬라이드트랙을 참조하는 역할이고 카드의 실제 너비를 계산하기 위해서 필요하다.

timeoutRef는 자동 재생 재시작을 위해 5초를 세는 역할을 한다.

 

const extendedCards = [...cards, ...cards, ...cards];

원형큐처럼 보이게 하는 방법은 위 코드에 있다.

위 코드는 원래 카드를 3배로 복사해서 앞, 뒤로 이동할 때 자연스럽게 보이도록 한다.

 

useEffect(() => {
    const updateCardsPerView = () => {
        if (window.innerWidth <= 992) {
            setCardsPerView(2);  // 작은 화면: 2개
        } else {
            setCardsPerView(3);  // 큰 화면: 3개
        }
    };

    updateCardsPerView();
    window.addEventListener('resize', updateCardsPerView);
    return () => window.removeEventListener('resize', updateCardsPerView);
}, []);

이 코드가 바로 내가 아까 말했던 cardsPerView가 의미가 없어진거 같다고 한 이유이다.

모바일처럼 작은 화면에서는 2개씩, 큰 화면에서는 3개씩으로 set하기 때문이다.

마지막에 있는 removeEventLisner는 화면 크기를 바꾸고 함수가 종료될 때 메모리 누수를 방지하는 함수이다.

 

이동버튼 구현하기

const nextSlide = useCallback(() => {
    if(isTransitioning) return;  // 이동 중이면 무시
    setIsTransitioning(true);    // 이동 시작
    setCurrentIndex((prevIndex) => prevIndex+1);  // 인덱스 +1
}, [isTransitioning]);

const prevSlide = useCallback(() => {
    if(isTransitioning) return;  // 이동 중이면 무시
    setIsTransitioning(true);    // 이동 시작
    setCurrentIndex((prevIndex) => prevIndex-1);  // 인덱스 -1
}, [isTransitioning]);

 

<, > 표시로 있던 버튼을 구현하기 위해서 nextSlide와 prevSlide함수를 구현해줬다.

복잡하지 않게 이동중이면 비활성화 하고, 아닌 경우 이동시 인덱스를 + -하도록 구현했다.

 

무한루프 구현하기

useEffect(() => {
    if(!isTransitioning) return;
    
    const timeout = setTimeout(() => {
        setIsTransitioning(false);  // 애니메이션 종료
        
        // 끝에 도달하면 (인덱스 10 이상)
        if(currentIndex >= cards.length*2){
            setCurrentIndex(cards.length);  // 중간(5)으로 점프
        }
        // 처음에 도달하면 (인덱스 0 이하)
        else if (currentIndex <= 0){
            setCurrentIndex(cards.length);  // 중간(5)으로 점프
        }
    }, 500);  // 0.5초 후 (애니메이션 시간과 동일)

    return () => clearTimeout(timeout);
}, [currentIndex, isTransitioning, cards.length]);

 

CSS 애니메이션이 끝나고 타임 아웃이 발생하면 애니메이션이 종료되고 양 끝단 인덱스인지 확인하고 양 끝 인덱스이면 다시 중간으로 점프하도록 구현해주었다.

 

자동 재생 구현하기

useEffect(() => {
    if(!isAutoPlay) return;  // 자동재생 꺼져있으면 무시
    
    const interval = setInterval(() => {
        nextSlide();  // 3초마다 다음 슬라이드
    }, 3000);
    
    return () => clearInterval(interval);  // cleanup
}, [isAutoPlay, nextSlide]);

 

자동 재생은 3초마다 다음 슬라이드로 넘어가도록 하고, 만약 버튼을 눌러서 자동재생이 비활성화 된 상태면 활성화 되기 전까지는 무시하는 코드이다. 만약 isAutoPlay를 체크하지 않으면 내가 다음 버튼을 누르거나 이전 버튼을 눌렀을 때, 원하는 인덱스 +1이 될것이다.

 

버튼 클릭 처리

const handlePrevClick = () => {
    setIsAutoPlay(false);  // 자동재생 멈춤
    prevSlide();           // 이전으로 이동
    
    if (timeoutRef.current) {
        clearTimeout(timeoutRef.current);  // 기존 타이머 취소
    }

    timeoutRef.current = setTimeout(() => {
        setIsAutoPlay(true);  // 5초 후 자동재생 재시작
    }, 5000);
};

 

버튼 클릭시에는 자동재생을 비활성화 시키고, 기존 돌아가던 타이머도 취소한다. 그리고 5초가 지나면 자동 재생을 재시작하도록 구현했다.

 

이동거리 계산하기

const getTranslateValue = () => {
    if (!trackRef.current) return 0;

    const firstCard = trackRef.current.querySelector('.card') as HTMLElement;
    if (!firstCard) return 0;

    // 카드 너비 + gap
    const cardWidth = firstCard.offsetWidth;
    const gap = cardsPerView === 3 ? 1.5 : 
                cardsPerView === 2 ? 
                (window.innerWidth <= 768 ? 
                    (window.innerWidth <= 576 ? 0.75 : 1) : 1.5) : 1.5;
    const gapPx = gap * 16; // rem to px

    return currentIndex * (cardWidth + gapPx);
};

 

이 함수는 카드를 한칸 이동시킬 때 얼마나 이동시켜야 하는지 계산하는 코드이다.

공식은 이동 거리 = 현재 인덱스 × (카드 너비 + 간격) 이렇게 계산했다.

이 함수는 화면 크기별로 조절하기가 까다로워서 AI의 도움을 받아 작성했다.

 

 

JSX 렌더링

home.tsx에 직접 구현한 것이 아니기에 적용하기 위해 반환값을 JSX로 작성해주었다.

상단에는 좌우이동 버튼을, 하단에는 카드 캐러셀을 배치해주었다.

여기까지는 크게 어렵지 않았는데, CSS로 화면에 맞게 크기조절하고 배치하고 하단 스크롤 바 없애는 등의 작업을 거쳐주어 아래처럼 완성했다.

실제 구현이미지

return(
        <div className="carousel-container">
            <div className="carousel-header">
                <div className="carousel-controls" >
                    <button className="btn btn-outline-dark me-2"
                            onClick={handlePrevClick}
                            aria-label="이전"
                    >
                        <ChevronLeft size={20} />
                        <span className="d-none d-sm-inline ms-1">이전</span>
                    </button>
                    <button className="btn btn-outline-dark"
                            onClick={handleNextClick}
                            aria-label="다음">
                        <span className="d-none d-sm-inline me-1">다음</span>
                        <ChevronRight size={20} />
                    </button>
                </div>
            </div>
            <div className="carousel-viewport">
                <div className="carousel-track"
                     ref={trackRef}
                     style={{
                         transform:`translateX(-${getTranslateValue()}px)`,
                         transition: isTransitioning ? 'transform 0.5s ease-in-out' : 'none'
                     }}
                >
                    {extendedCards.map((card, index) => (
                        <div key={`${card.id}-${index}`} className="card">
                            <div className="card-image"></div>
                            <div className="card-content">
                                <h3 className="card-title">{card.title}</h3>
                                <p className="card-description">{card.description}</p>
                            </div>
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );

 

CSS파일은 일일히 설명하기 애매하기 때문에 궁금하다면 직접 깃허브 들어가서 보기 바란다

https://github.com/CromNEXT/SSURENT

 

GitHub - CromNEXT/SSURENT: 숭실대 컴퓨터학부 대여사업 자동화 웹 프로그램

숭실대 컴퓨터학부 대여사업 자동화 웹 프로그램. Contribute to CromNEXT/SSURENT development by creating an account on GitHub.

github.com

전체 코드

import React, {useState, useEffect, useCallback, useRef} from "react";
import './carousel.css'
import {ChevronLeft, ChevronRight} from "react-bootstrap-icons";

interface Card{
    id: number;
    title:string;
    description : string;
}

const CardCarousel: React.FC = () =>{
    const cards: Card[] = [
        {
            id: 1,
            title: '우산',
            description: 'Some quick example text to build on the card title and make up the bulk of the card\'s content.'
        },
        {
            id: 2,
            title: '보조배터리',
            description: 'Some quick example text to build on the card title and make up the bulk of the card\'s content.'
        },
        {
            id: 3,
            title: '충전기 선',
            description: 'Some quick example text to build on the card title and make up the bulk of the card\'s content.'
        },
        {
            id: 4,
            title: '노트북 거치대',
            description: 'Some quick example text to build on the card title and make up the bulk of the card\'s content.'
        },
        {
            id: 5,
            title: '스테이플러',
            description: 'Some quick example text to build on the card title and make up the bulk of the card\'s content.'
        }
    ];

    const [currentIndex, setCurrentIndex] = useState(cards.length);
    const [isTransitioning, setIsTransitioning] = useState(false);
    const [isAutoPlay, setIsAutoPlay] = useState(true);
    const [cardsPerView, setCardsPerView] = useState(3);
    const trackRef = useRef<HTMLDivElement>(null);
    const timeoutRef = useRef<NodeJS.Timeout | null>(null);

    const extendedCards = [...cards, ...cards, ...cards];

    // 화면 크기에 따라 보이는 카드 개수 조정
    useEffect(() => {
        const updateCardsPerView = () => {
            if (window.innerWidth <= 992) {
                setCardsPerView(2);
            } else {
                setCardsPerView(3);
            }
        };

        updateCardsPerView();
        window.addEventListener('resize', updateCardsPerView);
        return () => window.removeEventListener('resize', updateCardsPerView);
    }, []);

    //다음 슬라이드로 이동하기
    const nextSlide = useCallback(() => {
        if(isTransitioning) return;
        setIsTransitioning(true);
        setCurrentIndex((prevIndex) => prevIndex+1);
    }, [isTransitioning]);

    //이전 슬라이드로 이동하기
    const prevSlide = useCallback(() => {
        if(isTransitioning) return;
        setIsTransitioning(true);
        setCurrentIndex((prevIndex) => prevIndex-1);
    }, [isTransitioning]);

    useEffect(() => {
        if(!isTransitioning) return;
        const timeout = setTimeout(() => {
            setIsTransitioning(false);
            //끝에 도달하면 transition 없이 처음으로
            if(currentIndex >= cards.length*2){
                setCurrentIndex(cards.length);
            }
            // 처음에 도달하면 transition 없이 끝으로
            else if (currentIndex <= 0){
                setCurrentIndex(cards.length);
            }
        }, 500);

        return () => clearTimeout(timeout);
    }, [currentIndex,isTransitioning,cards.length]);

    //자동 재생 효과
    useEffect(() => {
        if(!isAutoPlay) return;
        const interval = setInterval(() => {
            nextSlide();
        },3000);
        return () => clearInterval(interval);
    }, [isAutoPlay, nextSlide]);

    const handlePrevClick = () => {
        setIsAutoPlay(false);
        prevSlide();
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }

        timeoutRef.current = setTimeout(() => {
            setIsAutoPlay(true);
        }, 5000);
    };

    const handleNextClick = () => {
        setIsAutoPlay(false);
        nextSlide();
        if (timeoutRef.current) {
            clearTimeout(timeoutRef.current);
        }

        timeoutRef.current = setTimeout(() => {
            setIsAutoPlay(true);
        }, 5000);
    };

    // 슬라이드 이동 거리 계산 (픽셀 단위)
    const getTranslateValue = () => {
        if (!trackRef.current) return 0;

        const firstCard = trackRef.current.querySelector('.card') as HTMLElement;
        if (!firstCard) return 0;

        // 카드 너비 + gap
        const cardWidth = firstCard.offsetWidth;
        const gap = cardsPerView === 3 ? 1.5 : cardsPerView === 2 ?
            (window.innerWidth <= 768 ? (window.innerWidth <= 576 ? 0.75 : 1) : 1.5) : 1.5;
        const gapPx = gap * 16; // rem to px (assuming 1rem = 16px)

        return currentIndex * (cardWidth + gapPx);
    };

    return(
        <div className="carousel-container">
            <div className="carousel-header">
                <div className="carousel-controls" >
                    <button className="btn btn-outline-dark me-2"
                            onClick={handlePrevClick}
                            aria-label="이전"
                    >
                        <ChevronLeft size={20} />
                        <span className="d-none d-sm-inline ms-1">이전</span>
                    </button>
                    <button className="btn btn-outline-dark"
                            onClick={handleNextClick}
                            aria-label="다음">
                        <span className="d-none d-sm-inline me-1">다음</span>
                        <ChevronRight size={20} />
                    </button>
                </div>
            </div>
            <div className="carousel-viewport">
                <div className="carousel-track"
                     ref={trackRef}
                     style={{
                         transform:`translateX(-${getTranslateValue()}px)`,
                         transition: isTransitioning ? 'transform 0.5s ease-in-out' : 'none'
                     }}
                >
                    {extendedCards.map((card, index) => (
                        <div key={`${card.id}-${index}`} className="card">
                            <div className="card-image"></div>
                            <div className="card-content">
                                <h3 className="card-title">{card.title}</h3>
                                <p className="card-description">{card.description}</p>
                            </div>
                        </div>
                    ))}
                </div>
            </div>
        </div>
    );
};

export default CardCarousel;

 

728x90
반응형