add swipeable banner slider with autoplay
This commit is contained in:
BIN
public/assets/ark-library/figma/banner-mobile-1.png
Normal file
BIN
public/assets/ark-library/figma/banner-mobile-1.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 512 KiB |
@@ -1,3 +1,11 @@
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from "react";
|
||||
|
||||
const FIGMA_ASSET_BASE = "/assets/ark-library/figma";
|
||||
|
||||
export const officialRecommendationCoverFallbacks = [
|
||||
@@ -8,30 +16,217 @@ export const officialRecommendationCoverFallbacks = [
|
||||
`${FIGMA_ASSET_BASE}/official-recommendation-5.png`,
|
||||
] as const;
|
||||
|
||||
type BannerSlide = {
|
||||
id: string;
|
||||
mobile: string;
|
||||
desktop: string;
|
||||
alt: string;
|
||||
};
|
||||
|
||||
const BANNER_SLIDES: BannerSlide[] = [
|
||||
{
|
||||
id: "ark-banner-1",
|
||||
mobile: `${FIGMA_ASSET_BASE}/banner-mobile-1.png`,
|
||||
desktop: `${FIGMA_ASSET_BASE}/banner-desktop.png`,
|
||||
alt: "",
|
||||
},
|
||||
{
|
||||
id: "ark-banner-2",
|
||||
mobile: `${FIGMA_ASSET_BASE}/banner-375.png`,
|
||||
desktop: `${FIGMA_ASSET_BASE}/banner-wide.png`,
|
||||
alt: "",
|
||||
},
|
||||
];
|
||||
|
||||
const AUTOPLAY_MS = 5000;
|
||||
const RESUME_AFTER_INTERACTION_MS = 8000;
|
||||
|
||||
export function FigmaBanner() {
|
||||
const slides = BANNER_SLIDES;
|
||||
const scrollerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeIndex, setActiveIndex] = useState(0);
|
||||
const [autoplayPaused, setAutoplayPaused] = useState(false);
|
||||
const resumeTimerRef = useRef<number | null>(null);
|
||||
const dragStateRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startScrollLeft: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const hasMultiple = slides.length > 1;
|
||||
|
||||
const goTo = useCallback((index: number, behavior: ScrollBehavior) => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
const target = scroller.children[index] as HTMLElement | undefined;
|
||||
if (!target) return;
|
||||
scroller.scrollTo({ left: target.offsetLeft, behavior });
|
||||
}, []);
|
||||
|
||||
const pauseAutoplay = useCallback(() => {
|
||||
setAutoplayPaused(true);
|
||||
if (resumeTimerRef.current !== null) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
resumeTimerRef.current = window.setTimeout(() => {
|
||||
setAutoplayPaused(false);
|
||||
resumeTimerRef.current = null;
|
||||
}, RESUME_AFTER_INTERACTION_MS);
|
||||
}, []);
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (resumeTimerRef.current !== null) {
|
||||
window.clearTimeout(resumeTimerRef.current);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
|
||||
const handleScroll = () => {
|
||||
const width = scroller.clientWidth;
|
||||
if (width === 0) return;
|
||||
const next = Math.round(scroller.scrollLeft / width);
|
||||
setActiveIndex((prev) => (prev === next ? prev : next));
|
||||
};
|
||||
|
||||
handleScroll();
|
||||
scroller.addEventListener("scroll", handleScroll, { passive: true });
|
||||
return () => scroller.removeEventListener("scroll", handleScroll);
|
||||
}, [slides.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!hasMultiple || autoplayPaused) return;
|
||||
const timer = window.setInterval(() => {
|
||||
setActiveIndex((prev) => {
|
||||
const next = (prev + 1) % slides.length;
|
||||
goTo(next, "smooth");
|
||||
return next;
|
||||
});
|
||||
}, AUTOPLAY_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [hasMultiple, autoplayPaused, slides.length, goTo]);
|
||||
|
||||
const handlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.pointerType !== "mouse") return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
dragStateRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startScrollLeft: scroller.scrollLeft,
|
||||
moved: false,
|
||||
};
|
||||
scroller.setPointerCapture(event.pointerId);
|
||||
pauseAutoplay();
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const scroller = scrollerRef.current;
|
||||
if (!scroller) return;
|
||||
const dx = event.clientX - drag.startX;
|
||||
if (!drag.moved && Math.abs(dx) > 4) {
|
||||
drag.moved = true;
|
||||
scroller.style.scrollSnapType = "none";
|
||||
}
|
||||
if (drag.moved) {
|
||||
scroller.scrollLeft = drag.startScrollLeft - dx;
|
||||
}
|
||||
};
|
||||
|
||||
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragStateRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
const scroller = scrollerRef.current;
|
||||
dragStateRef.current = null;
|
||||
if (!scroller) return;
|
||||
if (scroller.hasPointerCapture(event.pointerId)) {
|
||||
scroller.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
if (drag.moved) {
|
||||
const width = scroller.clientWidth || 1;
|
||||
const nearest = Math.round(scroller.scrollLeft / width);
|
||||
const clamped = Math.max(0, Math.min(slides.length - 1, nearest));
|
||||
scroller.style.scrollSnapType = "";
|
||||
goTo(clamped, "smooth");
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<picture className="-mx-4 block overflow-hidden border border-[#2a2a32] bg-black shadow-[0_24px_70px_rgba(0,0,0,0.18)] min-[440px]:-mx-5 sm:-mx-6 md:mx-0 md:rounded-xl">
|
||||
<source
|
||||
media="(max-width: 439px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-375.png`}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 575px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-440.png`}
|
||||
/>
|
||||
<source
|
||||
media="(max-width: 767px)"
|
||||
srcSet={`${FIGMA_ASSET_BASE}/banner-576.png`}
|
||||
/>
|
||||
<img
|
||||
src={`${FIGMA_ASSET_BASE}/banner-desktop.png`}
|
||||
alt=""
|
||||
className="h-auto w-full object-cover"
|
||||
width={1280}
|
||||
height={290}
|
||||
loading="eager"
|
||||
decoding="async"
|
||||
/>
|
||||
</picture>
|
||||
<div>
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onTouchStart={pauseAutoplay}
|
||||
className="flex cursor-grab snap-x snap-mandatory overflow-x-auto overflow-y-hidden scroll-smooth select-none touch-pan-x [-ms-overflow-style:none] [scrollbar-width:none] active:cursor-grabbing [&::-webkit-scrollbar]:hidden"
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="ARK Library banner"
|
||||
>
|
||||
{slides.map((slide, index) => (
|
||||
<div
|
||||
key={slide.id}
|
||||
className="relative w-full shrink-0 snap-start"
|
||||
role="group"
|
||||
aria-roledescription="slide"
|
||||
aria-label={`${index + 1} / ${slides.length}`}
|
||||
>
|
||||
<picture className="block w-full overflow-hidden rounded-xl bg-black">
|
||||
<source media="(max-width: 767px)" srcSet={slide.mobile} />
|
||||
<img
|
||||
src={slide.desktop}
|
||||
alt={slide.alt}
|
||||
className="pointer-events-none h-[200px] w-full object-cover md:h-auto"
|
||||
width={1280}
|
||||
height={290}
|
||||
loading={index === 0 ? "eager" : "lazy"}
|
||||
decoding="async"
|
||||
draggable={false}
|
||||
/>
|
||||
</picture>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{hasMultiple ? (
|
||||
<div
|
||||
className="mt-3 flex items-center justify-center gap-2"
|
||||
role="tablist"
|
||||
aria-label="Banner pagination"
|
||||
>
|
||||
{slides.map((slide, index) => {
|
||||
const active = index === activeIndex;
|
||||
return (
|
||||
<button
|
||||
key={slide.id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={active}
|
||||
aria-label={`Go to slide ${index + 1}`}
|
||||
onClick={() => {
|
||||
pauseAutoplay();
|
||||
setActiveIndex(index);
|
||||
goTo(index, "smooth");
|
||||
}}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
active
|
||||
? "w-6 bg-ark-gold"
|
||||
: "w-1.5 bg-white/30 hover:bg-white/50"
|
||||
}`}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user