ImageCarousel image
Afbeelding slider met auto-play, pijlen en dots. Pauzeert op hover. Caption gradient overlay.
src/components/image/ImageCarousel.astro
---
interface Props {
slides: { src: string; alt?: string; caption?: string; }[];
autoPlay?: boolean;
interval?: number;
showDots?: boolean;
showArrows?: boolean;
eyebrow?: string;
headline?: string;
aspectRatio?: string;
}
const {
slides,
autoPlay = true,
interval = 4000,
showDots = true,
showArrows = true,
eyebrow,
headline,
aspectRatio = '16/9',
} = Astro.props;
const id = `icr-${Math.random().toString(36).slice(2, 8)}`;
---
<section class="icr__section">
{(eyebrow || headline) && (
<div class="icr__header">
{eyebrow && <p class="icr__eyebrow">{eyebrow}</p>}
{headline && <h2 class="icr__headline">{headline}</h2>}
</div>
)}
<div
class="icr__wrap"
id={id}
data-autoplay={autoPlay ? 'true' : 'false'}
data-interval={interval}
style={`--icr-ratio: ${aspectRatio};`}
>
<div class="icr__track">
{slides.map((slide, index) => (
<div class={`icr__slide${index === 0 ? ' icr__slide--active' : ''}`} aria-hidden={index !== 0 ? 'true' : 'false'}>
<img
src={slide.src}
alt={slide.alt ?? ''}
class="icr__img"
loading={index === 0 ? 'eager' : 'lazy'}
decoding="async"
/>
{slide.caption && (
<div class="icr__caption">
<span class="icr__caption-text">{slide.caption}</span>
</div>
)}
</div>
))}
</div>
{showArrows && slides.length > 1 && (
<>
<button class="icr__arrow icr__arrow--prev" aria-label="Vorige slide">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button class="icr__arrow icr__arrow--next" aria-label="Volgende slide">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
</>
)}
{showDots && slides.length > 1 && (
<div class="icr__dots" role="tablist" aria-label="Slide navigatie">
{slides.map((_, index) => (
<button
class={`icr__dot${index === 0 ? ' icr__dot--active' : ''}`}
role="tab"
aria-label={`Slide ${index + 1}`}
aria-selected={index === 0 ? 'true' : 'false'}
data-index={index}
/>
))}
</div>
)}
</div>
</section>
<script>
document.querySelectorAll<HTMLElement>('.icr__wrap').forEach((wrap) => {
const slides = wrap.querySelectorAll<HTMLElement>('.icr__slide');
const dots = wrap.querySelectorAll<HTMLButtonElement>('.icr__dot');
const prevBtn = wrap.querySelector<HTMLButtonElement>('.icr__arrow--prev');
const nextBtn = wrap.querySelector<HTMLButtonElement>('.icr__arrow--next');
const autoPlay = wrap.dataset.autoplay === 'true';
const intervalMs = parseInt(wrap.dataset.interval ?? '4000', 10);
let current = 0;
let timer: ReturnType<typeof setInterval> | null = null;
function goTo(index: number) {
slides[current].classList.remove('icr__slide--active');
slides[current].setAttribute('aria-hidden', 'true');
dots[current]?.classList.remove('icr__dot--active');
dots[current]?.setAttribute('aria-selected', 'false');
current = (index + slides.length) % slides.length;
slides[current].classList.add('icr__slide--active');
slides[current].setAttribute('aria-hidden', 'false');
dots[current]?.classList.add('icr__dot--active');
dots[current]?.setAttribute('aria-selected', 'true');
}
function startTimer() {
if (!autoPlay) return;
timer = setInterval(() => goTo(current + 1), intervalMs);
}
function stopTimer() {
if (timer) clearInterval(timer);
}
prevBtn?.addEventListener('click', () => { stopTimer(); goTo(current - 1); startTimer(); });
nextBtn?.addEventListener('click', () => { stopTimer(); goTo(current + 1); startTimer(); });
dots.forEach((dot) => {
dot.addEventListener('click', () => {
stopTimer();
goTo(parseInt(dot.dataset.index ?? '0', 10));
startTimer();
});
});
wrap.addEventListener('mouseenter', stopTimer);
wrap.addEventListener('mouseleave', startTimer);
// Touch/swipe support
let touchStartX = 0;
wrap.addEventListener('touchstart', (e) => { touchStartX = e.touches[0].clientX; }, { passive: true });
wrap.addEventListener('touchend', (e) => {
const diff = touchStartX - e.changedTouches[0].clientX;
if (Math.abs(diff) > 40) {
stopTimer();
goTo(diff > 0 ? current + 1 : current - 1);
startTimer();
}
});
startTimer();
});
</script>
<style>
:root {
--color-primary: #0a0a0a;
--color-accent: #6366f1;
--color-bg: #fff;
--color-muted: #6b7280;
--radius: 0.5rem;
}
.icr__section {
padding: 4rem 1.5rem;
background: var(--color-bg);
}
.icr__header {
text-align: center;
margin-bottom: 2rem;
}
.icr__eyebrow {
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--color-accent);
margin: 0 0 0.5rem;
}
.icr__headline {
font-size: clamp(1.75rem, 4vw, 2.75rem);
font-weight: 700;
color: var(--color-primary);
margin: 0;
line-height: 1.15;
}
.icr__wrap {
position: relative;
max-width: 1200px;
margin: 0 auto;
border-radius: var(--radius);
overflow: hidden;
user-select: none;
}
.icr__track {
position: relative;
aspect-ratio: var(--icr-ratio, 16 / 9);
width: 100%;
overflow: hidden;
}
.icr__slide {
position: absolute;
inset: 0;
opacity: 0;
transition: opacity 0.5s ease;
pointer-events: none;
}
.icr__slide--active {
opacity: 1;
pointer-events: auto;
}
.icr__img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.icr__caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: 2rem 1.5rem 1rem;
background: linear-gradient(to top, rgba(0, 0, 0, 0.6) 0%, transparent 100%);
}
.icr__caption-text {
color: #fff;
font-size: 0.9375rem;
line-height: 1.5;
}
.icr__arrow {
position: absolute;
top: 50%;
transform: translateY(-50%);
width: 44px;
height: 44px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--color-primary);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
transition: background 0.2s ease, transform 0.2s ease;
z-index: 10;
}
.icr__arrow:hover {
background: #fff;
transform: translateY(-50%) scale(1.05);
}
.icr__arrow--prev {
left: 1rem;
}
.icr__arrow--next {
right: 1rem;
}
.icr__dots {
position: absolute;
bottom: 1rem;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: 0.5rem;
z-index: 10;
}
.icr__dot {
width: 8px;
height: 8px;
border-radius: 50%;
border: none;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0;
transition: background 0.2s ease, transform 0.2s ease;
}
.icr__dot--active {
background: #fff;
transform: scale(1.25);
}
.icr__dot:hover:not(.icr__dot--active) {
background: rgba(255, 255, 255, 0.8);
}
@media (prefers-reduced-motion: reduce) {
* {
animation: none !important;
transition: none !important;
}
}
</style>
Props
| Prop | Type | Default | Beschrijving |
|---|---|---|---|
slides * | { src: string; alt?: string; caption?: string }[] | — | Slides array |
autoPlay | boolean | true | Automatisch wisselen |
interval | number | 4000 | Wisselinterval in ms |
showDots | boolean | true | Dot indicators |
showArrows | boolean | true | Vorige/volgende pijlen |
aspectRatio | string | '16/9' | Beeldverhouding |
* = verplicht