Zoeken...  ⌘K GitHub

ImageCarousel image

Afbeelding slider met auto-play, pijlen en dots. Pauzeert op hover. Caption gradient overlay.

/image-carousel
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