[React] Swiper 라이브러리 커스텀하는 법 (TypeScript)

@eunjios·2025년 8월 6일
post-thumbnail

왜 Swiper 를 사용했을까?

프로젝트를 하다보면 캐러셀, 슬라이더 UI 를 구현할 일이 있다. 물론 직접 구현한다는 선택지도 있겠지만 이미 잘 만들어진 패키지를 활용하는게 효율성이나 완성도 측면에서도 더 좋다고 생각했다.

캐러셀을 쉽게 구현할 수 있도록 도와주는 라이브러리가 많다. 아래 라이브러리 외에도 다양한 선택지가 있다.

React Slick 은 예전에 사용해 본 경험이 있어서 이번에도 가장 먼저 고려했던 라이브러리인데 이번 프로젝트와는 맞지 않는 것 같아 선택하지 않았다.

이전에는 버튼으로만 슬라이드를 이동시켜서 몰랐는데 스와이프 제스처를 할 때 부자연스럽게 느껴지는 부분이 있었고, 모바일이나 데스크탑 환경에서 모두 쉽게 사용할 수 있는 UI 가 중요했기 때문에 React Slick 을 사용하지 않기로 했다.

다른 웹 페이지에서는 어떤 라이브러리를 사용하는지 궁금해서 Chrome DevTools 를 찾아봤고, Swiper 를 사용하고 있음을 알게되었다. 마침 원하는 감도의 캐러셀이었기 때문에 Swiper 를 사용하기로 결정했다.

참고로 fullPage.js 는 위 라이브러리들과 조금 결이 다른데 전체 페이지가 하나의 캐러셀이 되어 스크롤에 따라 슬라이드가 움직이도록 하는 라이브러리다. 기업 소개 페이지나 랜딩 페이지에서 많이 볼 수 있는 UI 를 쉽게 구현 가능하다. 이런 걸 (출처: @tutsplus) 쉽게 만들 수 있다.


Swiper 기본 구조

Installation

npm i swiper

Usage

Swiper 는 컨테이너 역할을 하는 <Swiper /> 컴포넌트와 각 아이템 역할을 하는 <SwiperSlide /> 컴포넌트로 구성된다.

import { Swiper, SwiperSlide } from 'swiper/react';

import 'swiper/css';

export const Example = () => {
  return (
    <Swiper>
      <SwipeSlide>슬라이드 1</SwipeSlide>
      <SwipeSlide>슬라이드 2</SwipeSlide>
      <SwipeSlide>슬라이드 3</SwipeSlide>
    </Swiper>
}

Swiper 커스텀

Swiper - Demos

위 데모 페이지에서 여러 예시를 볼 수 있다. 특히 각 예시마다 CodeSandbox 가 있어서 React, Vue 등에서 어떻게 구현하는지 코드로 볼 수 있는데, 이 예시를 보면 어떻게 원하는 UI 를 구현할지 쉽게 감을 잡을 수 있다.

Swiper - Modules

Swiper 공식 문서를 보면 알 수 있지만, Swiper 는 다양한 모듈을 제공한다. 그 중 화살표 버튼으로 슬라이더를 컨트롤 할 수 있는 모듈이 바로 Navigation 모듈이다.

import { Navigation } from 'swiper/modules';

import 'swiper/css/navigation';
<Swiper modules={[Navigation]}>

위처럼 swiper/modules 에서 원하는 모듈을 import 해서 <Swiper> modules 로 지정해 주면 된다.

또한 swiper/css/navigation 을 import 해서 사용한다면 기본 스타일을 그대로 사용할 수 있다. 만약 네비게이션 버튼을 커스텀해서 사용한다면 해당 파일은 import 할 필요가 없다. (단, 레이아웃을 결정하는 'swiper/css' 파일은 import 해야 한다.)

네비게이션 버튼을 커스텀 하는 방법은 관련 클래스의 스타일을 재정의하는 방법도 있지만, 완전히 커스텀하고 싶다면 별도의 버튼 컴포넌트를 만들어 ref 로 연결하는 방법을 추천한다. 이렇게 하면 기본 네비게이션 버튼 스타일에 완전히 독립적이게 버튼을 구성할 수 있다.

타입스크립트 사용 시 다음과 같이 swiperRef 타입을 지정해 준다.

import { SwiperClass } from 'swiper/react';

interface Props {
  swiperRef: React.RefObject<SwiperClass | null>;
}

방향을 props 로 받아 다음과 같이 재사용 가능한 컴포넌트를 만들자.

import { SwiperClass } from 'swiper/react';

interface Props {
  swiperRef: React.RefObject<SwiperClass | null>;
  direction: 'prev' | 'next';
}

export const NavigationButton = ({ sliderRef, direction }: Props) => {
  const isPrev = direction === 'prev';

  const onClick = () => {
    if (isPrev) {
      swiperRef.current?.slidePrev();
    } else {
      swiperRef.current?.slideNext();
    }
  };

  return (
    <div className={/* 스타일 커스텀 */}>
      <button
        className={/* 스타일 커스텀 */}
        onClick={onClick}
      >
        {/* 버튼 컨텐츠 */}
      </button>
    </div>
  );
};

위 코드의 포인트는 swiperRef.current 로 swiper 객체의 프로퍼티와 메서드에 접근할 수 있다는 점이다. 이를 활용하면 현재 슬라이더가 beginning 상태인지, end 상태인지도 알 수 있다.

참고로 NavigationButton 의 컨테이너는 absolute 로 설정하고 Swiper 컨테이너를 relative 로 설정하여 버튼을 원하는 곳에 위치시킬 수 있다.

// 스타일링 예시 (tailwind css)

<div
  className={`
	absolute top-0 bottom-0 ${isPrev ? 'left-1' : 'right-1'}
	z-20 flex justify-center items-center
  `}
  >
  <button
    className="rounded-full p-2 bg-white shadow-sm cursor-pointer"
    onClick={onClick}
    >
    {isPrev ? <FaChevronLeft /> : <FaChevronRight />}
  </button>
</div>

가장 중요한 <Swiper /> 와 버튼을 연결하는 부분이다. <Swiper />onSwiper 를 이용해서 해당 컴포넌트가 마운트 될 때 swiperRef.current 를 다음과 같이 연결한다.

const swiperRef = useRef<SwiperClass>(null);

const onSwiper = (swiper: SwiperClass) => {
  sliderRef.current = swiper;
};

return (
  <div className="relative">
    <NavigationButton
      direction="prev"
      swiperRef={swiperRef}
    />
    <Swiper
      onSwiper={onSwiper}
      navigation={true}
      modules={[Navigation]}
    >
      {children}
    </Swiper>
    <NavigationButton
      direction="next"
      swiperRef={swiperRef}
     />
  </div>
);

Hover 시 Navigation Button 보여주기

마우스가 슬라이더 위에 올라갈 때만 네비게이션 버튼을 보여주고 싶다면 컨테이너에 onMouseEnter onMouseLeave 이벤트 핸들러를 지정하여 isHover 상태를 관리하면 된다. 그리고 위에서 커스텀했던 네비게이션 버튼에서 isHover 상태를 받아 조건부 렌더링 할 수 있게 수정해 보자.

export const HoverSwiper = ({ children }: {children: React.ReactNode}) => {
  const [isHover, setIsHover] = useState(false);
  
  const onMouseEnter = () => {
    setIsHover(true);
  };
  
  const onMouseLeave = () => {
    setIsHover(false);
  };
  
  return (
    <div
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <NavigationButton
        direction="prev"
        swiperRef={swiperRef}
        isVisible={isHover}
      />
      {/* 기존 코드 */}
      <NavigationButton
        direction="next"
        swiperRef={swiperRef}
        isVisible={isHover}
      />
    </div>
  );
}
interface Props {
  swiperRef: React.RefObject<SwiperClass | null>;
  direction: 'prev' | 'next';
  isVisible: boolean;
}

export const NavigationButton = ({ sliderRef, direction, isVisible }: Props) => {
  if (!isVisible) return null;
  
  // 기존 코드
  // ...
};

이동 불가능한 방향 disabled 처리하기

이동할 수 없는 방향의 버튼은 클릭할 수 없도록 혹은 보이지 않도록 설정하여 사용성을 더 개선할 수 있다.

  • 첫 번째 슬라이드 : prev button 을 숨김
  • 마지막 슬라이드 : next button 을 숨김

즉, Swiper 의 상태에 따라 리렌더링 되어야 하기 때문에 isBeginningisEnd 를 state 로 관리하면 된다.

interface Props {
  children: React.ReactNode;
}

export const HoverSwiper = ({ children }: Props) => {
  const sliderRef = useRef<SwiperClass>(null);
  const [isHover, setIsHover] = useState(false);
  const [isBeginning, setIsBeginning] = useState(true);
  const [isEnd, setIsEnd] = useState(true);

  const onMouseEnter = () => {
    setIsHover(true);
  };

  const onMouseLeave = () => {
    setIsHover(false);
  };

  const onSwiper = (swiper: SwiperClass) => {
    sliderRef.current = swiper;
    // 마운트 시 isBeginning, isEnd 초기값 설정
    setIsBeginning(swiper.isBeginning);
    setIsEnd(swiper.isEnd);
  };
  
  // slide 가 바뀔 때마다 isBeginning, isEnd 업데이트
  const onSlideChange = (swiper: SwiperClass) => {
    setIsBeginning(swiper.isBeginning);
    setIsEnd(swiper.isEnd);
  };

  return (
    <div 
      className="relative"
      onMouseEnter={onMouseEnter}
      onMouseLeave={onMouseLeave}
    >
      <NavigationButton
        direction="prev"
        sliderRef={sliderRef}
        isVisible={isHover}
        disabled={isBeginning}
      />
      <Swiper
        onSwiper={onSwiper}
        onSlideChange={onSlideChange}
        navigation={true}
        modules={[Navigation]}
        slidesPerView={2}
        spaceBetween={16}
      >
        {children}
      </Swiper>
      <NavigationButton
        direction="next"
        sliderRef={sliderRef}
        isVisible={isHover}
        disabled={isEnd}
      />
    </div>
  );
};

disabled 에 따른 스타일 혹은 렌더링은 NavigationButton 에서 관리하면 된다. 간단히 렌더링을 안 하는 방식은 disabled 일 때 null 을 반환한다.

반응형 Swiper 만들기

Swiperbreakpoints prop 을 이용하면 다음과 같이 window width 에 따라 props 를 변경할 수 있다.

// 예시
<Swiper
  slidesPerView={1}
  spaceBetween={8}
  breakpoints={{
    // when window width is >= 480px
    480: {
      slidesPerView: 2,
      spaceBetween: 16,
    }
    // when window width is >= 768px
    768: {
      slidesPerView: 3,
      spaceBetween: 16,
    },
  }}
>

완성된 커스텀 Swiper

위 코드에서 스타일과 애니메이션만 조금 추가한 완성본이다.

swiper/react custom 1

<SwiperSlide> 가 감싸는 각 아이템이 어떻게 구현되어 있냐에 따라 차이가 있을 수 있는데 필자는 다음과 같이 .swiper-slide 클래스의 스타일을 수정했다.

/* globals.css*/

.swiper-slide {
  width: fit-content;
}

팁이라고 하기도 애매하지만(?) 만약 원하는대로 스타일링이 잘 안 된다면 Chrome DevTools 의 Elements 패널에서 swiper 의 기본 스타일을 직접 변경해보면서 swiper-slide 클래스를 수정할 것을 권한다.

Gradient

이 부분은 본문에서는 다루지 않았지만, Navigation 모듈 없이 <Swiper> 컨테이너에 gradient 효과만 추가한 것이다.

동작을 정의하자면 다음과 같다.

  • 슬라이더가 움직일 때 : 양쪽에 그래디언트
  • 움직이지 않을 때 : 슬라이드 아이템이 더 있는 쪽만 그래디언트

기존에 <InternalGradient> 를 개발해뒀기 때문에 다음과 같이 (1) 움직이는지 여부(2) 그래디언트가 생겨야 할 방향 만 state 로 추가했다.

export const GradientSwiper = ({ children }: Props) => {
  const [isMoving, setIsMoving] = useState(false);
  const [gradientDirs, setGradientDirs] = useState<('left' | 'right')[]>([]);

  const updateGradientDirs = (swiper: SwiperClass) => {
    const { isBeginning, isEnd } = swiper;

    if (isBeginning && isEnd) {
      setGradientDirs([]);                // 외부 슬라이드 없음
    } else if (isBeginning) {
      setGradientDirs(['right']);         // 오른쪽만 슬라이드 있음
    } else if (isEnd) {
      setGradientDirs(['left']);          // 왼쪽만 슬라이드 있음
    } else {
      setGradientDirs(['left', 'right']); // 왼쪽, 오른쪽 모두 슬라이드 있음
    }
  };

  // 초기 그래디언트 방향
  const onSwiper = (swiper: SwiperClass) => {
    updateGradientDirs(swiper);
  };

  // 슬라이드 변함
  const onSlideChange = (swiper: SwiperClass) => {
    updateGradientDirs(swiper);
    setIsMoving(false);
  };

  // 슬라이더 움직임
  const onSliderMove = () => {
    setIsMoving(true);
  };

  useEffect(() => {
    if (gradientDirs.length === 0) {
      return;
    }

    // isMoving -> gradient 양쪽
    if (isMoving) {
      setGradientDirs(['left', 'right']);
    }
  }, [gradientDirs.length, isMoving]);

  return (
    <InternalGradient directions={gradientDirs}>
      <Swiper
        slidesPerView={'auto'}
        spaceBetween={8}
        onSwiper={onSwiper}
        onSlideChange={onSlideChange}
        onSliderMove={onSliderMove}
      >
        {children}
      </Swiper>
    </InternalGradient>
  );
};


References

profile
🗒️

0개의 댓글