AppSwiper

Interactive UI Explorer

아래에서 각 컴포넌트의 다양한 Variants States를 문서화된 형태로 테스트해볼 수 있습니다.

Live Preview

AppSwiper

Swiper 기반 래퍼입니다. AppSwiperSlide· AppSwiperSlideScene으로 슬라이드를 구성하고, pagination, navigation, marquee, showAutoplayProgress 등 props로 패턴을 바꿀 수 있습니다. 고급 설정은 swiperOptions로 Swiper 옵션을 병합합니다 (onSwiper 등 일부는 내부에서 먼저 호출 후 연결).

기본 · bullets · loop · autoplay

pagination="bullets", paginationClickable 기본 true

Source Code
<AppSwiper loop autoplay pagination="bullets">
  <AppSwiperSlide>...</AppSwiperSlide>
</AppSwiper>
Default
장면 A
기본 슬라이드 · 불릿 페이지네이션
Auto · Loop
장면 B
loop + autoplay
Navigation
장면 C
커스텀 네비게이션 조합
Variants
장면 D
progressbar / marquee 등 props 전환
Live Preview

페이드 · fraction 페이지네이션

effect="fade", pagination="fraction" (현재/전체)

Source Code
<AppSwiper effect="fade" loop autoplay pagination="fraction" />
Default
장면 A
기본 슬라이드 · 불릿 페이지네이션
Auto · Loop
장면 B
loop + autoplay
Navigation
장면 C
커스텀 네비게이션 조합
Variants
장면 D
progressbar / marquee 등 props 전환
Live Preview

Progressbar 페이지네이션

pagination="progressbar" — 상단 진행 막대

Source Code
<AppSwiper loop autoplay pagination="progressbar" />
Default
장면 A
기본 슬라이드 · 불릿 페이지네이션
Auto · Loop
장면 B
loop + autoplay
Navigation
장면 C
커스텀 네비게이션 조합
Variants
장면 D
progressbar / marquee 등 props 전환
Live Preview

내장 navigation (builtin)

navigation="builtin" — 좌우 화살표 버튼

Source Code
<AppSwiper navigation="builtin" loop pagination={false} />
Default
장면 A
Auto · Loop
장면 B
Navigation
장면 C
Variants
장면 D
Live Preview

커스텀 이전/다음 (navigation 렌더 prop)

navigation={(ctx) => …} — slidePrev / slideNext / slideTo / activeIndex / isBeginning / isEnd

Source Code
navigation={(ctx) => (
  <div className="flex gap-2">
    <Button onClick={() => ctx.slidePrev()} disabled={ctx.isBeginning}>이전</Button>
    <Button onClick={() => ctx.slideNext()} disabled={ctx.isEnd}>다음</Button>
  </div>
)}
Default
장면 A
Auto · Loop
장면 B
Navigation
장면 C
Variants
장면 D
Live Preview

Autoplay 진행 막대 (showAutoplayProgress)

onAutoplayTimeLeft 기반으로 하단 얇은 progress 표시

Source Code
<AppSwiper autoplay showAutoplayProgress pagination="bullets" />
Default
장면 A
기본 슬라이드 · 불릿 페이지네이션
Auto · Loop
장면 B
loop + autoplay
Navigation
장면 C
커스텀 네비게이션 조합
Variants
장면 D
progressbar / marquee 등 props 전환
Live Preview

Marquee (연속 롤링)

marquee prop — slidesPerView auto, loop, autoplay delay:0 + linear timing 으로 멈춤 없이 흐름

Source Code
<AppSwiper marquee>
  <AppSwiperSlide className="!w-56">...</AppSwiperSlide>
</AppSwiper>
장면 A
장면 B
장면 C
장면 D
장면 A
장면 B
장면 C
장면 D
장면 A
장면 B
장면 C
장면 D
Live Preview

AppSwiperSlideScene

제목·설명·본문이 있는 카드형 장면 레이아웃

Source Code
<AppSwiperSlideScene title="타이틀" description="설명">콘텐츠</AppSwiperSlideScene>
소개

Scene 프리셋으로 히어로·온보딩 카드 등에 활용할 수 있습니다.

본문 영역에 폼·이미지·CTA를 넣습니다.

기술 스택

로고 그리드나 리스트를 배치합니다.

Next.jsTypeScriptTailwind
마무리

다음 단계 안내

Live Preview
Implementation

제작 코드

이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.

AppSwipertypescript
"use client";

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Swiper as SwiperClass } from "swiper";
import { Autoplay, EffectFade, FreeMode, Navigation, Pagination, Scrollbar } from "swiper/modules";
import { Swiper } from "swiper/react";
import { cn } from "@/lib/cn";
import { ArrowIcon } from "@/components/Icon/Arrow";
import type { AppSwiperNavRenderContext, AppSwiperProps } from "./types";

import "swiper/css";
import "swiper/css/navigation";
import "swiper/css/pagination";
import "swiper/css/scrollbar";
import "swiper/css/effect-fade";
import "swiper/css/free-mode";

function buildNavContext(
  swiper: SwiperClass | null,
  snap: { activeIndex: number; isBeginning: boolean; isEnd: boolean },
): AppSwiperNavRenderContext {
  return {
    slidePrev: () => swiper?.slidePrev(),
    slideNext: () => swiper?.slideNext(),
    slideTo: (index, speed) => swiper?.slideTo(index, speed),
    activeIndex: snap.activeIndex,
    isBeginning: snap.isBeginning,
    isEnd: snap.isEnd,
    swiper,
  };
}

export function AppSwiper({
  children,
  className,
  swiperClassName,
  loop = false,
  autoplay,
  pagination = false,
  paginationClickable = true,
  paginationDynamicBullets = false,
  navigation = false,
  scrollbar = false,
  marquee = false,
  freeMode,
  slidesPerView = 1,
  spaceBetween = 0,
  speed,
  effect = "slide",
  showAutoplayProgress = false,
  onSwiperReady,
  swiperOptions,
}: AppSwiperProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const prevRef = useRef<HTMLButtonElement>(null);
  const nextRef = useRef<HTMLButtonElement>(null);
  const swiperRef = useRef<SwiperClass | null>(null);
  const delayedRefreshTimers = useRef<ReturnType<typeof setTimeout>[]>([]);
  const [navSnap, setNavSnap] = useState({ activeIndex: 0, isBeginning: true, isEnd: false });
  const [apPct, setApPct] = useState(0);

  const enableAutoplay = Boolean(marquee || autoplay);

  const resolvedSpeed = useMemo(() => {
    if (speed != null) return speed;
    if (marquee) return 6000;
    return 400;
  }, [speed, marquee]);

  const resolvedSlidesPerView = useMemo(() => {
    if (marquee) return "auto";
    return slidesPerView;
  }, [marquee, slidesPerView]);

  const resolvedSpaceBetween = useMemo(() => {
    if (marquee && spaceBetween === 0) return 16;
    return spaceBetween;
  }, [marquee, spaceBetween]);

  const resolvedAutoplay = useMemo(() => {
    if (!enableAutoplay) return false;
    if (marquee) {
      return {
        delay: 0,
        disableOnInteraction: false,
        pauseOnMouseEnter: false,
        ...(typeof autoplay === "object" ? autoplay : {}),
      };
    }
    if (autoplay === true) {
      return { delay: 4000, disableOnInteraction: false, pauseOnMouseEnter: true };
    }
    return autoplay as Exclude<typeof autoplay, boolean | undefined>;
  }, [enableAutoplay, marquee, autoplay]);

  const resolvedEffect = marquee ? "slide" : effect;

  const modules = useMemo(() => {
    const m = [];
    if (pagination) m.push(Pagination);
    if (navigation === "builtin") m.push(Navigation);
    if (scrollbar) m.push(Scrollbar);
    if (enableAutoplay) m.push(Autoplay);
    if (resolvedEffect === "fade") m.push(EffectFade);
    if (marquee || freeMode) m.push(FreeMode);
    return m;
  }, [pagination, navigation, scrollbar, enableAutoplay, resolvedEffect, marquee, freeMode]);

  const paginationConfig = useMemo(() => {
    if (!pagination) return undefined;
    return {
      clickable: paginationClickable,
      ...(pagination === "bullets" && paginationDynamicBullets ? { dynamicBullets: true } : {}),
      type: pagination as "bullets" | "fraction" | "progressbar",
    };
  }, [pagination, paginationClickable, paginationDynamicBullets]);

  const freeModeConfig = useMemo(() => {
    if (marquee) return false;
    if (freeMode === undefined) return undefined;
    return freeMode;
  }, [marquee, freeMode]);

  const syncNav = useCallback((swiper: SwiperClass) => {
    setNavSnap({
      activeIndex: swiper.realIndex ?? swiper.activeIndex,
      isBeginning: swiper.isBeginning,
      isEnd: swiper.isEnd,
    });
  }, []);

  /** 탭 전환·쇼케이스 애니메이션·스크롤 직후 크기가 늦게 잡히는 경우 재측정 */
  const layoutRefresh = useCallback(() => {
    const s = swiperRef.current;
    if (!s) return;
    s.update();
    if (navigation === "builtin") {
      s.navigation?.update();
    }
    if (typeof s.pagination?.update === "function") {
      s.pagination.update();
    }
    if (enableAutoplay && s.autoplay && !s.autoplay.running) {
      s.autoplay.start();
    }
  }, [enableAutoplay, navigation]);

  const scheduleLayoutRefresh = useCallback(() => {
    layoutRefresh();
    queueMicrotask(layoutRefresh);
    requestAnimationFrame(() => {
      requestAnimationFrame(layoutRefresh);
    });
    for (const t of delayedRefreshTimers.current) {
      clearTimeout(t);
    }
    delayedRefreshTimers.current = [150, 400].map((ms) =>
      setTimeout(() => {
        layoutRefresh();
      }, ms),
    );
  }, [layoutRefresh]);

  useEffect(() => {
    return () => {
      for (const t of delayedRefreshTimers.current) {
        clearTimeout(t);
      }
    };
  }, []);

  useEffect(() => {
    const root = containerRef.current;
    if (!root || typeof IntersectionObserver === "undefined") return;
    const io = new IntersectionObserver(
      (entries) => {
        if (entries.some((e) => e.isIntersecting)) {
          scheduleLayoutRefresh();
        }
      },
      { root: null, threshold: [0, 0.02, 0.1], rootMargin: "32px 0px 32px 0px" },
    );
    io.observe(root);
    return () => io.disconnect();
  }, [scheduleLayoutRefresh]);

  useEffect(() => {
    const root = containerRef.current;
    if (!root || typeof ResizeObserver === "undefined") return;
    const ro = new ResizeObserver(() => {
      queueMicrotask(layoutRefresh);
    });
    ro.observe(root);
    return () => ro.disconnect();
  }, [layoutRefresh]);

  const optOnSwiper = swiperOptions?.onSwiper;
  const optOnSlideChange = swiperOptions?.onSlideChange;
  const optOnBeforeInit = swiperOptions?.onBeforeInit;
  const restSwiperOptions = useMemo(() => {
    if (!swiperOptions) return {};
    const { onSwiper: o1, onSlideChange: o2, onBeforeInit: o3, ...rest } = swiperOptions;
    void o1;
    void o2;
    void o3;
    return rest;
  }, [swiperOptions]);

  const handleSwiper = useCallback(
    (swiper: SwiperClass) => {
      optOnSwiper?.(swiper);
      swiperRef.current = swiper;
      syncNav(swiper);
      onSwiperReady?.(swiper);
      scheduleLayoutRefresh();
    },
    [onSwiperReady, optOnSwiper, scheduleLayoutRefresh, syncNav],
  );

  const navCtx = useMemo(
    () => buildNavContext(swiperRef.current, navSnap),
    [navSnap],
  );

  const customNav = typeof navigation === "function" ? navigation(navCtx) : null;

  return (
    <div ref={containerRef} className={cn("w-full", className)}>
      <div className="relative">
        {navigation === "builtin" ? (
          <>
            <button
              ref={prevRef}
              type="button"
              aria-label="이전 슬라이드"
              className={cn(
                "absolute left-0 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full",
                "border border-slate-200 bg-white/95 text-slate-700 shadow-md backdrop-blur-sm",
                "transition hover:bg-slate-50 disabled:pointer-events-none disabled:opacity-30",
              )}
            >
              <ArrowIcon className="h-5 w-5" direction="left" />
            </button>
            <button
              ref={nextRef}
              type="button"
              aria-label="다음 슬라이드"
              className={cn(
                "absolute right-0 top-1/2 z-10 flex h-10 w-10 -translate-y-1/2 items-center justify-center rounded-full",
                "border border-slate-200 bg-white/95 text-slate-700 shadow-md backdrop-blur-sm",
                "transition hover:bg-slate-50 disabled:pointer-events-none disabled:opacity-30",
              )}
            >
              <ArrowIcon className="h-5 w-5" direction="right" />
            </button>
          </>
        ) : null}

        <Swiper
          {...restSwiperOptions}
          className={cn("w-full min-w-0", marquee && "app-swiper-marquee", swiperClassName)}
          modules={modules}
          loop={marquee ? true : loop}
          slidesPerView={resolvedSlidesPerView}
          spaceBetween={resolvedSpaceBetween}
          speed={resolvedSpeed}
          effect={resolvedEffect}
          fadeEffect={resolvedEffect === "fade" ? { crossFade: true } : undefined}
          autoplay={resolvedAutoplay || undefined}
          pagination={paginationConfig}
          scrollbar={scrollbar ? { draggable: true } : undefined}
          freeMode={freeModeConfig}
          observer={restSwiperOptions.observer !== false}
          observeParents={restSwiperOptions.observeParents !== false}
          resizeObserver={restSwiperOptions.resizeObserver !== false}
          navigation={
            navigation === "builtin"
              ? {
                  prevEl: prevRef.current,
                  nextEl: nextRef.current,
                }
              : false
          }
          onBeforeInit={(swiper) => {
            if (navigation === "builtin" && prevRef.current && nextRef.current) {
              const nav = swiper.params.navigation;
              if (nav && typeof nav !== "boolean") {
                nav.prevEl = prevRef.current;
                nav.nextEl = nextRef.current;
              }
            }
            optOnBeforeInit?.(swiper);
          }}
          onSwiper={handleSwiper}
          onSlideChange={(swiper) => {
            optOnSlideChange?.(swiper);
            syncNav(swiper);
            setApPct(0);
          }}
          onAutoplayTimeLeft={
            showAutoplayProgress && enableAutoplay
              ? (_swiper, _timeLeft, percentage) => {
                  setApPct(Math.max(0, Math.min(100, percentage * 100)));
                }
              : undefined
          }
        >
          {children}
        </Swiper>
      </div>

      {showAutoplayProgress && enableAutoplay ? (
        <div
          className="mt-2 h-1 w-full overflow-hidden rounded-full bg-slate-200"
          role="progressbar"
          aria-valuenow={Math.round(apPct)}
          aria-valuemin={0}
          aria-valuemax={100}
          aria-label="슬라이드 자동 재생 진행"
        >
          <div
            className="h-full bg-blue-600 transition-[width] duration-75 ease-linear"
            style={{ width: `${apPct}%` }}
          />
        </div>
      ) : null}

      {customNav != null && customNav !== false ? (
        <div className="mt-3 flex flex-wrap items-center justify-center gap-2">{customNav}</div>
      ) : null}
    </div>
  );
}