Interactive UI Explorer
아래에서 각 컴포넌트의 다양한 Variants와 States를 문서화된 형태로 테스트해볼 수 있습니다.
AppSwiper
Swiper 기반 래퍼입니다. AppSwiperSlide· AppSwiperSlideScene으로 슬라이드를 구성하고, pagination, navigation, marquee, showAutoplayProgress 등 props로 패턴을 바꿀 수 있습니다. 고급 설정은 swiperOptions로 Swiper 옵션을 병합합니다 (onSwiper 등 일부는 내부에서 먼저 호출 후 연결).
기본 · bullets · loop · autoplay
pagination="bullets", paginationClickable 기본 true
페이드 · fraction 페이지네이션
effect="fade", pagination="fraction" (현재/전체)
Progressbar 페이지네이션
pagination="progressbar" — 상단 진행 막대
내장 navigation (builtin)
navigation="builtin" — 좌우 화살표 버튼
커스텀 이전/다음 (navigation 렌더 prop)
navigation={(ctx) => …} — slidePrev / slideNext / slideTo / activeIndex / isBeginning / isEnd
Autoplay 진행 막대 (showAutoplayProgress)
onAutoplayTimeLeft 기반으로 하단 얇은 progress 표시
Marquee (연속 롤링)
marquee prop — slidesPerView auto, loop, autoplay delay:0 + linear timing 으로 멈춤 없이 흐름
AppSwiperSlideScene
제목·설명·본문이 있는 카드형 장면 레이아웃
제작 코드
이 컴포넌트가 실제로 어떻게 구현되어 있는지 — 본체 .tsx 파일을 그대로 보여줍니다. variant 매핑·접근성 처리·forwardRef 패턴 등 디테일을 그대로 확인할 수 있어요.
"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>
);
}