· Webentwicklung  · 11 minuten Lesezeit

Produktionsreife Carousel-Komponente mit Astro und TypeScript

Lerne, wie du eine voll ausgestattete, responsive Carousel-Komponente von Grund auf mit Astro und TypeScript entwickelst, ohne externe Abhängigkeiten

Lerne, wie du eine voll ausgestattete, responsive Carousel-Komponente von Grund auf mit Astro und TypeScript entwickelst, ohne externe Abhängigkeiten

Inhalt

In der modernen Webentwicklung stehen Entwickler oft vor der Wahl: eine externe Bibliothek verwenden oder eine eigene Lösung entwickeln. Bei Carousel-Komponenten gibt es zahlreiche Optionen wie Swiper.js, Slick Carousel oder Embla Carousel. Doch diese Bibliotheken bringen Nachteile mit sich:

  • Bundle-Größe: Externe Bibliotheken erhöhen die JavaScript-Bundle-Größe erheblich (oft 20-200 KB zusätzlich)
  • Abhängigkeiten: Wartung und Updates der Abhängigkeiten erfordern kontinuierliche Aufmerksamkeit
  • Overengineering: Die meisten Projekte benötigen nur einen Bruchteil der angebotenen Features
  • Anpassbarkeit: Styling und Verhalten zu überschreiben ist oft kompliziert
  • Performance: Ungenutzte Features belasten die Performance

Mit Astro und modernem TypeScript können wir eine schlanke, performante Carousel-Komponente entwickeln, die exakt auf unsere Anforderungen zugeschnitten ist. In diesem Artikel zeige ich Schritt für Schritt, wie man eine produktionsreife Carousel-Komponente ohne externe Abhängigkeiten erstellt.

Live-Demo

Bevor wir in die technischen Details eintauchen, hier eine Live-Demo der fertigen Carousel-Komponente:

Voll ausgestattetes Carousel

Interaktive Demonstration aller Features

Tipp: Nutze die Pfeiltasten ←/→, die Tab-Taste, die Vor/Zurück-Buttons oder klicke auf die Indikator-Punkte zur Navigation.

Was wir bauen werden

Unsere Carousel-Komponente wird folgende Features unterstützen:

  • Bilder und Videos als Carousel-Items
  • Autoplay mit konfigurierbarem Intervall
  • Navigation über Buttons (Vor/Zurück)
  • Indikator-Punkte für aktuelle Position
  • Tastaturnavigation (Pfeiltasten)
  • Responsive Design mit konfigurierbaren Seitenverhältnissen
  • Smooth Transitions und Animationen
  • Accessibility (ARIA-Labels, Keyboard Support)
  • Video-Autoplay mit automatischem Pausieren
  • Caption-Unterstützung für jedes Item
  • TypeScript für Type-Safety

Voraussetzungen

Bevor wir beginnen, solltest du folgende Technologien kennen:

  • Astro: Grundlegendes Verständnis von Astro-Komponenten und Props
  • TypeScript: Interfaces, Type Annotations und Classes
  • Tailwind CSS: Utility-First CSS Framework (optional, aber empfohlen)
  • JavaScript: ES6+ Features, DOM-Manipulation, Event Handling

Projektstruktur

Unser Projekt verwendet folgende Struktur:

src/
├── components/
│   ├── ui/
│   │   ├── WidgetWrapper.astro
│   │   ├── Headline.astro
│   │   └── Button.astro
│   ├── common/
│   │   └── Image.astro
│   └── widgets/
│       └── Carousel.astro           # Unsere Carousel-Komponente
├── types.d.ts                       # TypeScript Type Definitions
└── assets/
    └── styles/
        └── tailwind.css

Architektur-Übersicht

Unsere Carousel-Komponente besteht aus drei Hauptkomponenten:

  1. Astro Component (Carousel.astro): Rendering der HTML-Struktur und Einbindung von Child-Components
  2. TypeScript Class (Carousel): Logik für Navigation, Autoplay und Event Handling
  3. CSS Transitions: Smooth Animationen zwischen Slides

Datenfluss

Props (items, autoplay, etc.)

Astro Component (Rendering)

HTML Structure mit data-* Attributen

TypeScript Class (Client-Side Logic)

DOM Manipulation und Event Handling

Um eine vollständige Carousel-Komponente zu entwickeln, denken wir vom Endziel rückwärts. Was brauchen wir alles, damit das Carousel funktioniert?

Wir zerlegen die Entwicklung in einzelne, aufeinander aufbauende Schritte und beginnen vom Typsystem über die Struktur bis zur Interaktivität. So behalten wir den Überblick und können uns vollständig auf eine Aufgabe konzentrieren, bevor wir zur nächsten Schicht übergehen.

Schritt 1: TypeScript Interfaces definieren

Zuerst definieren wir die TypeScript-Interfaces für unsere Carousel-Komponente. Das sorgt für Type-Safety und bessere IDE-Unterstützung.

CarouselItem Interface

// src/types.d.ts

export interface Video {
  src: string;
  type?: string;
}

export interface Image {
  src: string | ImageMetadata;
  alt?: string;
}

export interface CarouselItem {
  image?: Image | ImageMetadata | string;
  video?: Video;
  caption?: string;
}
export interface Carousel extends Omit<Headline, 'classes'>, Widget {
  items?: Array<CarouselItem>;
  autoplay?: boolean;
  autoplayInterval?: number;
  showIndicators?: boolean;
  showControls?: boolean;
  aspectRatio?: string;
}

Erklärung der Props:

PropTypDefaultBeschreibung
itemsCarouselItem[][]Array von Bildern/Videos mit optionalen Captions
autoplaybooleanfalseAktiviert automatisches Durchlaufen der Slides
autoplayIntervalnumber5000Intervall in Millisekunden zwischen Slides
showIndicatorsbooleantrueZeigt Indikator-Punkte am unteren Rand
showControlsbooleantrueZeigt Vor/Zurück-Buttons
aspectRatiostring’1/1’CSS aspect-ratio Wert (z.B. ‘16/9’, ‘4/3’)

Schritt 2: HTML-Struktur erstellen

Jetzt erstellen wir die grundlegende HTML-Struktur unserer Carousel-Komponente.

---
// src/components/widgets/Carousel.astro
import WidgetWrapper from '~/components/ui/WidgetWrapper.astro';
import Headline from '~/components/ui/Headline.astro';
import Image from '~/components/common/Image.astro';
import Button from '~/components/ui/Button.astro';
import { twMerge } from 'tailwind-merge';

const {
  title = await Astro.slots.render('title'),
  subtitle = await Astro.slots.render('subtitle'),
  tagline = await Astro.slots.render('tagline'),
  items = [],
  autoplay = false,
  autoplayInterval = 5000,
  showIndicators = true,
  showControls = true,
  aspectRatio = '1/1',

  id,
  isDark = false,
  classes = {},
  bg = await Astro.slots.render('bg'),
} = Astro.props;

const carouselId = id || `carousel-${Math.random().toString(36).substr(2, 9)}`;
---

<WidgetWrapper id={id} isDark={isDark} containerClass={`max-w-7xl mx-auto ${classes?.container ?? ''}`} bg={bg}>
  {
    (title || subtitle || tagline) && (
      <Headline
        title={title}
        subtitle={subtitle}
        tagline={tagline}
        classes={{
          container: 'text-center mb-8 md:mb-12',
          title: 'text-4xl md:text-5xl font-bold tracking-tighter mb-4 font-heading',
          subtitle: 'text-xl text-muted dark:text-slate-400',
        }}
      />
    )
  }

  <div class="relative w-full mx-auto group" data-carousel={carouselId}>
    <!-- Carousel container -->
    <div
      class="relative w-full overflow-hidden rounded-lg shadow-lg bg-gray-500 dark:bg-slate-700"
      style={`aspect-ratio: ${aspectRatio};`}
    >
      <!-- Slides werden hier eingefügt -->
    </div>

    <!-- Controls und Indicators folgen -->
  </div>
</WidgetWrapper>

Wichtige Designentscheidungen

  1. data-carousel Attribut: Eindeutige ID für die JavaScript-Initialisierung
  2. aspect-ratio: Dynamisches Seitenverhältnis über CSS Custom Properties
  3. group: Tailwind CSS Klasse für hover-basierte Control-Anzeige
  4. WidgetWrapper: Wiederverwendbarer Container für konsistentes Layout

Jetzt fügen wir die einzelnen Slides innerhalb des Carousel-Containers ein:

<div
  class="relative w-full overflow-hidden rounded-lg shadow-lg bg-gray-500 dark:bg-slate-700"
  style={`aspect-ratio: ${aspectRatio};`}
>
  {
    items.map((item, index) => (
      <div
        class={twMerge(
          'carousel-item absolute inset-0 transition-all duration-500 ease-in-out',
          index === 0 ? 'opacity-100 scale-100 z-10' : 'opacity-0 scale-105 z-0'
        )}
        data-carousel-item={index}
      >
        {/* Bild-Rendering */}
        {item.image &&
          (typeof item.image === 'string' ? (
            <Fragment set:html={item.image} />
          ) : (
            <Image
              class="w-full h-full object-contain"
              widths={[400, 768, 1024, 2040]}
              sizes="(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 33vw"
              loading="lazy"
              alt={`Slide ${index + 1}`}
              {...item.image}
            />
          ))}

        {/* Video-Rendering */}
        {item.video && (
          <video class="w-full h-full object-contain" muted loop playsinline>
            <source src={item.video.src} type={item.video.type || 'video/mp4'} />
          </video>
        )}

        {/* Caption */}
        {item.caption && (
          <div class="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 via-black/40 to-transparent px-6 py-12 md:px-10 md:py-14">
            <p class="text-white text-center text-base md:text-lg lg:text-xl font-medium leading-relaxed max-w-4xl">
              {item.caption}
            </p>
          </div>
        )}
      </div>
    ))
  }
</div>

CSS-Klassen Erklärung

KlasseZweck
absolute inset-0Positioniert alle Slides übereinander (Stack)
transition-all duration-500Smooth Transitions für opacity und scale
opacity-100 scale-100 z-10Aktives Slide (sichtbar, normal skaliert, oben)
opacity-0 scale-105 z-0Inaktive Slides (unsichtbar, leicht vergrößert, unten)
object-containErhält Seitenverhältnis, verhindert Verzerrung

Schritt 4: Navigation Controls implementieren

Als Nächstes fügen wir die Vor/Zurück-Buttons hinzu:

{
  showControls && items.length > 1 && (
    <>
      {/* Previous button */}
      <Button
        type="button"
        variant="icon-circle"
        icon="tabler:chevron-left"
        iconClass="w-5 h-5 md:w-7 md:h-7 m-0"
        class="carousel-prev absolute top-1/2 left-3 md:left-6 z-20 -translate-y-1/2 opacity-0 invisible group-hover:opacity-100 group-hover:visible focus:opacity-100 focus:visible"
        aria-label="Previous slide"
      />

      {/* Next button */}
      <Button
        type="button"
        variant="icon-circle"
        icon="tabler:chevron-right"
        iconClass="w-5 h-5 md:w-7 md:h-7 m-0"
        class="carousel-next absolute top-1/2 right-3 md:right-6 z-20 -translate-y-1/2 opacity-0 invisible group-hover:opacity-100 group-hover:visible focus:opacity-100 focus:visible"
        aria-label="Next slide"
      />
    </>
  )
}

Wichtige Features

  • Conditional Rendering: Buttons werden nur bei mehr als einem Item angezeigt
  • Hover-basierte Sichtbarkeit: group-hover:opacity-100 zeigt Buttons nur bei Hover
  • Accessibility: aria-label für Screen Reader
  • Responsive Positioning: left-3 md:left-6 für unterschiedliche Bildschirmgrößen

Schritt 5: Indicator Dots hinzufügen

Die Indikator-Punkte zeigen die aktuelle Position im Carousel an:

{
  showIndicators && items.length > 1 && (
    <div class="absolute bottom-2 md:bottom-4 left-1/2 -translate-x-1/2 z-20 flex gap-2.5 md:gap-3 bg-black/20 backdrop-blur-md rounded-full px-4 py-2.5">
      {items.map((_, index) => (
        <button
          type="button"
          class={twMerge(
            'carousel-indicator h-2.5 md:h-3 rounded-full transition-all duration-500 ease-out',
            'hover:scale-110 active:scale-95',
            'focus:outline-none focus:ring-2 focus:ring-white/50 focus:ring-offset-2 focus:ring-offset-black/20',
            index === 0 ? 'bg-white shadow-lg w-8 md:w-10 scale-105' : 'bg-white/60 hover:bg-white/80 w-2.5 md:w-3'
          )}
          aria-label={`Go to slide ${index + 1}`}
          aria-current={index === 0 ? 'true' : 'false'}
          data-carousel-indicator={index}
        />
      ))}
    </div>
  )
}

Design-Details

  • Glass-Morphism: bg-black/20 backdrop-blur-md für modernen Look
  • Aktiver Indikator: Größerer, hellerer Punkt mit Shadow
  • Smooth Transitions: transition-all duration-500 für weiche Übergänge
  • Interaktive States: Hover und Active States für besseres UX

Jetzt implementieren wir die gesamte Client-Side Logik in einer TypeScript-Klasse:

<script define:vars={{ carouselId, autoplay, autoplayInterval, itemsLength: items.length }}>
  class Carousel {
    constructor(carouselId, autoplay, autoplayInterval, itemsLength) {
      this.carousel = document.querySelector(`[data-carousel="${carouselId}"]`);
      if (!this.carousel || itemsLength <= 1) return;

      this.items = this.carousel.querySelectorAll('[data-carousel-item]');
      this.indicators = this.carousel.querySelectorAll('[data-carousel-indicator]');
      this.prevButton = this.carousel.querySelector('.carousel-prev');
      this.nextButton = this.carousel.querySelector('.carousel-next');
      this.videos = this.carousel.querySelectorAll('video');

      this.currentIndex = 0;
      this.autoplay = autoplay;
      this.autoplayInterval = autoplayInterval;
      this.intervalId = null;

      this.init();
    }

    init() {
      // Event listeners für Buttons
      if (this.prevButton) {
        this.prevButton.addEventListener('click', () => this.prev());
      }

      if (this.nextButton) {
        this.nextButton.addEventListener('click', () => this.next());
      }

      // Event listeners für Indicators
      this.indicators.forEach((indicator, index) => {
        indicator.addEventListener('click', () => this.goToSlide(index));
      });

      // Tastaturnavigation
      this.carousel.addEventListener('keydown', (e) => {
        if (e.key === 'ArrowLeft') this.prev();
        if (e.key === 'ArrowRight') this.next();
      });

      // Pause Autoplay bei Hover
      this.carousel.addEventListener('mouseenter', () => this.pauseAutoplay());
      this.carousel.addEventListener('mouseleave', () => this.resumeAutoplay());

      // Autoplay starten
      if (this.autoplay) {
        this.startAutoplay();
      }

      // Video abspielen wenn vorhanden
      this.playCurrentVideo();
    }

    goToSlide(index) {
      // Alle Videos pausieren
      this.pauseAllVideos();

      // Aktiven State vom aktuellen Item entfernen
      this.items[this.currentIndex].classList.remove('opacity-100', 'scale-100', 'z-10');
      this.items[this.currentIndex].classList.add('opacity-0', 'scale-105', 'z-0');

      if (this.indicators[this.currentIndex]) {
        this.indicators[this.currentIndex].classList.remove('bg-white', 'shadow-lg', 'w-8', 'md:w-10', 'scale-105');
        this.indicators[this.currentIndex].classList.add('bg-white/60', 'w-2.5', 'md:w-3');
        this.indicators[this.currentIndex].setAttribute('aria-current', 'false');
      }

      // Index aktualisieren
      this.currentIndex = index;

      // Aktiven State zum neuen Item hinzufügen
      this.items[this.currentIndex].classList.remove('opacity-0', 'scale-105', 'z-0');
      this.items[this.currentIndex].classList.add('opacity-100', 'scale-100', 'z-10');

      if (this.indicators[this.currentIndex]) {
        this.indicators[this.currentIndex].classList.remove('bg-white/60', 'w-2.5', 'md:w-3');
        this.indicators[this.currentIndex].classList.add('bg-white', 'shadow-lg', 'w-8', 'md:w-10', 'scale-105');
        this.indicators[this.currentIndex].setAttribute('aria-current', 'true');
      }

      // Video abspielen wenn vorhanden
      this.playCurrentVideo();
    }

    next() {
      const nextIndex = (this.currentIndex + 1) % this.items.length;
      this.goToSlide(nextIndex);
    }

    prev() {
      const prevIndex = (this.currentIndex - 1 + this.items.length) % this.items.length;
      this.goToSlide(prevIndex);
    }

    startAutoplay() {
      this.intervalId = setInterval(() => this.next(), this.autoplayInterval);
    }

    pauseAutoplay() {
      if (this.intervalId) {
        clearInterval(this.intervalId);
        this.intervalId = null;
      }
    }

    resumeAutoplay() {
      if (this.autoplay && !this.intervalId) {
        this.startAutoplay();
      }
    }

    playCurrentVideo() {
      const currentItem = this.items[this.currentIndex];
      const video = currentItem.querySelector('video');
      if (video) {
        video.play().catch(() => {
          // Autoplay failed, which is fine
        });
      }
    }

    pauseAllVideos() {
      this.videos.forEach((video) => {
        video.pause();
        video.currentTime = 0;
      });
    }
  }

  // Carousel initialisieren
  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', () => {
      new Carousel(carouselId, autoplay, autoplayInterval, itemsLength);
    });
  } else {
    new Carousel(carouselId, autoplay, autoplayInterval, itemsLength);
  }
</script>

Methoden-Übersicht

MethodeBeschreibung
init()Initialisiert Event Listeners und Autoplay
goToSlide(index)Navigiert zu einem bestimmten Slide
next()Navigiert zum nächsten Slide (zyklisch)
prev()Navigiert zum vorherigen Slide (zyklisch)
startAutoplay()Startet automatisches Durchlaufen
pauseAutoplay()Pausiert Autoplay (bei Hover)
resumeAutoplay()Setzt Autoplay fort (nach Hover)
playCurrentVideo()Spielt Video im aktuellen Slide ab
pauseAllVideos()Pausiert alle Videos und setzt sie zurück

Schritt 7: Custom CSS Styles

Für zusätzliche Animationen und Touch-Device Optimierungen fügen wir Custom CSS hinzu:

<style>
  /* Smooth fade-in animation für Controls */
  .carousel-prev.visible,
  .carousel-next.visible {
    animation: fadeIn 0.3s ease-out;
  }

  @keyframes fadeIn {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }

  /* Buttons bleiben über anderen Inhalten */
  .carousel-prev,
  .carousel-next {
    -webkit-tap-highlight-color: transparent;
  }

  /* Active Effect für Touch Devices */
  @media (hover: none) and (pointer: coarse) {
    .carousel-prev:active,
    .carousel-next:active {
      transform: translateY(-50%) scale(0.95);
    }
  }

  /* Interaktive Indicators */
  .carousel-indicator {
    -webkit-tap-highlight-color: transparent;
  }
</style>

Jetzt können wir unsere Carousel-Komponente in beliebigen Astro-Seiten verwenden:

Einfaches Beispiel

---
import Carousel from '~/components/widgets/Carousel.astro';

const carouselItems = [
  {
    image: {
      src: '~/assets/images/slide1.jpg',
      alt: 'Slide 1',
    },
    caption: 'Erstes Bild im Carousel',
  },
  {
    image: {
      src: '~/assets/images/slide2.jpg',
      alt: 'Slide 2',
    },
    caption: 'Zweites Bild im Carousel',
  },
  {
    video: {
      src: '/videos/demo.mp4',
    },
    caption: 'Ein Video im Carousel',
  },
];
---

<Carousel
  title="Projekt Galerie"
  subtitle="Unsere besten Arbeiten"
  items={carouselItems}
  autoplay={true}
  autoplayInterval={3000}
  aspectRatio="16/9"
/>

Erweiterte Konfiguration

<Carousel
  title="Portfolio Showcase"
  tagline="Featured Projects"
  subtitle="Explore our latest work"
  items={carouselItems}
  autoplay={true}
  autoplayInterval={5000}
  showIndicators={true}
  showControls={true}
  aspectRatio="4/3"
  isDark={true}
  classes={{
    container: 'py-16 md:py-20',
  }}
/>

Performance-Optimierungen

1. Lazy Loading für Bilder

Unsere Image-Komponente verwendet bereits loading="lazy", was bedeutet, dass Bilder erst geladen werden, wenn sie in den Viewport kommen.

2. Responsive Images

Mit dem widths und sizes Attribut generieren wir mehrere Bildversionen:

<Image widths={[400, 768, 1024, 2040]} sizes="(max-width: 767px) 100vw, (max-width: 1023px) 50vw, 33vw" />

3. Video Optimierung

Videos werden mit muted, loop und playsinline Attributen optimiert für bessere Performance und Mobile Support.

4. CSS Transitions vs. JavaScript Animations

Wir nutzen CSS Transitions statt JavaScript für Animationen, was von der GPU beschleunigt wird und smoother läuft.

Accessibility Features

Unsere Carousel-Komponente erfüllt wichtige Accessibility-Standards:

  1. ARIA Labels: Alle interaktiven Elemente haben aussagekräftige Labels
  2. Keyboard Navigation: Pfeiltasten für Navigation
  3. aria-current: Zeigt aktuellen Slide für Screen Reader an
  4. Focus States: Sichtbare Focus Rings für Tastaturnutzer
  5. Semantic HTML: Button-Elemente statt div mit onClick

Zusammenfassung

Wir haben eine vollständige, produktionsreife Carousel-Komponente entwickelt mit:

Zero Dependencies – Keine externen Bibliotheken
TypeScript – Vollständige Type-Safety
Responsive Design – Mobile-First Ansatz
Performance – Lazy Loading, CSS Transitions
Accessibility – ARIA Labels, Keyboard Support
Video Support – Automatisches Abspielen und Pausieren
Flexible Props – Einfache Anpassung an verschiedene Szenarien
Modern UI – Glass-Morphism, Smooth Animations

Vorteile gegenüber externen Bibliotheken

AspektCustom CarouselExterne Bibliothek (z.B. Swiper)
Bundle-Größe~2-3 KB~70-120 KB
Dependencies00
Anpassbarkeit100%Eingeschränkt
Learning CurveNiedrigMittel-Hoch
MaintenanceVolle KontrolleAbhängig von Maintainern

Weitere Demo-Beispiele

Hier sind einige weitere Beispiele, wie die Carousel-Komponente in verschiedenen Szenarien verwendet werden kann:

Quadratisches Carousel

Ideal für Profile, Produkte oder Social Media Inhalte

Carousel ohne Autoplay

Manuelle Navigation für wichtige Inhalte die Aufmerksamkeit erfordern

Vollbild Carousel

Ideal für beeindruckende visuelle Präsentationen

Hinweis: Alle Demos sind vollständig interaktiv! Probiere Tastaturnavigation, Hover-Effects und Indicator-Clicks aus.

Fazit

Das Entwickeln einer eigenen Carousel-Komponente ohne externe Dependencies ist nicht nur lehrreich, sondern bietet auch erhebliche Vorteile in Bezug auf Performance, Bundle-Größe und Wartbarkeit. Mit Astro und TypeScript haben wir eine moderne, typsichere Lösung geschaffen, die sich nahtlos in unser Projekt integriert.


Weiterführende Links:

Zurück zum Blog

Ähnliche Beiträge

Alle Beiträge ansehen
Angular Debugging in VSCode unter Ubuntu

Angular Debugging in VSCode unter Ubuntu

Wie du Angular-Projekte unter Ubuntu in VSCode wieder debuggen kannst, wenn der Standard-Debugger nicht mehr funktioniert. Schritt-für-Schritt-Anleitung mit Tipps aus der Praxis