Skip to content

A production-ready carousel component with Astro and TypeScript

Learn how to build a fully featured, responsive carousel component from scratch with Astro and TypeScript, without external dependencies.

Learn how to build a fully featured, responsive carousel component from scratch with Astro and TypeScript, without external dependencies.

Contents

In modern web development, developers often face a choice between an external library and a custom solution. Carousel options include Swiper.js, Slick Carousel and Embla Carousel. But these libraries have drawbacks:

  • Bundle size: External libraries significantly increase the JavaScript bundle size, often adding 20-200 KB
  • Dependencies: Maintaining and updating dependencies requires ongoing attention
  • Overengineering: Most projects need only a fraction of the available features
  • Customization: Overriding styles and behavior is often complicated
  • Performance: Unused features add runtime overhead

With Astro and modern TypeScript, we can build a lean, fast carousel component tailored precisely to our requirements. In this article, I explain step by step how to create a production-ready carousel without external dependencies.

Live demo

Before we get into the technical details, here is a live demo of the finished carousel component:

Tip: Navigate with the left and right arrow keys, Tab, the previous and next buttons, or the indicator dots.

What we will build

Our carousel component will support the following features:

  • Images and videos as carousel items
  • Autoplay with a configurable interval
  • Previous and next button navigation
  • Indicator dots showing the current position
  • Keyboard navigation using arrow keys
  • Responsive design with configurable aspect ratios
  • Smooth transitions and animations
  • Accessibility with ARIA labels and keyboard support
  • Video autoplay with automatic pausing
  • Captions for each item
  • TypeScript for type safety

Prerequisites

Before starting, you should be familiar with these technologies:

  • Astro: Basic knowledge of Astro components and props
  • TypeScript: Interfaces, type annotations and classes
  • Tailwind CSS: A utility-first CSS framework, optional but recommended
  • JavaScript: ES6+ features, DOM manipulation and event handling

Project structure

Our project uses the following structure:

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

Architecture overview

Our carousel consists of three main parts:

  1. Astro component (Carousel.astro): Renders the HTML structure and embeds child components
  2. TypeScript class (Carousel): Navigation, autoplay and event handling logic
  3. CSS transitions: Smooth animations between slides

Data flow

1. Props (items, autoplay, etc.)
2. Astro component (rendering)
3. HTML structure with data-* attributes
4. TypeScript class (client-side logic)
5. DOM manipulation and event handling

To develop a complete carousel component, we work backward from the end result. What does the carousel need in order to function?

We split development into successive steps, starting with the type system, then structure and interactivity. This keeps the work manageable and lets us focus fully on one task before moving to the next layer.

Step 1 defining TypeScript interfaces

First, we define the TypeScript interfaces for our carousel component. This provides type safety and better IDE support.

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;
}

Props explained:

PropTypeDefaultDescription
itemsCarouselItem[][]Array of images/videos with optional captions
autoplaybooleanfalseEnables automatic slide cycling
autoplayIntervalnumber5000Interval between slides in milliseconds
showIndicatorsbooleantrueShows indicator dots at the bottom
showControlsbooleantrueShows previous/next buttons
aspectRatiostring‘1/1’CSS aspect-ratio value, such as ‘16/9’ or ‘4/3’

Step 2 creating the HTML structure

Now we create the basic HTML structure for our carousel component.

---
// 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 are inserted here -->
    </div>

    <!-- Controls and indicators follow -->
  </div>
</WidgetWrapper>

Important design decisions

  1. data-carousel attribute: A unique ID for JavaScript initialization
  2. aspect-ratio: Dynamic aspect ratio through CSS custom properties
  3. group: Tailwind CSS class for showing controls on hover
  4. WidgetWrapper: Reusable container for a consistent layout

Now we add the individual slides inside the carousel container:

<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}
      >
        {/* Image 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 classes explained

ClassPurpose
absolute inset-0Stacks all slides on top of one another
transition-all duration-500Smooth transitions for opacity and scale
opacity-100 scale-100 z-10Active slide, visible, normal scale and on top
opacity-0 scale-105 z-0Inactive slides, hidden, slightly enlarged and below
object-containPreserves aspect ratio and prevents distortion

Step 4 implementing navigation controls

Next, we add previous and next buttons:

{
  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"
      />
    </>
  )
}

Important features

  • Conditional rendering: Buttons are shown only when there is more than one item
  • Hover-based visibility: group-hover:opacity-100 shows buttons on hover
  • Accessibility: aria-label for screen readers
  • Responsive positioning: left-3 md:left-6 for different screen sizes

Step 5 adding indicator dots

Indicator dots show the current position in the carousel:

{
  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

  • Glassmorphism: bg-black/20 backdrop-blur-md for a modern appearance
  • Active indicator: A larger, brighter dot with a shadow
  • Smooth transitions: transition-all duration-500 for soft transitions
  • Interactive states: Hover and active states improve UX

Now we implement all client-side logic in a TypeScript class:

<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 for buttons
      if (this.prevButton) {
        this.prevButton.addEventListener('click', () => this.prev());
      }

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

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

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

      // Pause autoplay on hover
      this.carousel.addEventListener('mouseenter', () => this.pauseAutoplay());
      this.carousel.addEventListener('mouseleave', () => this.resumeAutoplay());

      // Start autoplay
      if (this.autoplay) {
        this.startAutoplay();
      }

      // Play video if present
      this.playCurrentVideo();
    }

    goToSlide(index) {
      // Pause all videos
      this.pauseAllVideos();

      // Remove active state from the current item
      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');
      }

      // Update index
      this.currentIndex = index;

      // Add active state to the new item
      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');
      }

      // Play video if present
      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;
      });
    }
  }

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

Method overview

MethodDescription
init()Initializes event listeners and autoplay
goToSlide(index)Navigates to a specific slide
next()Navigates to the next slide cyclically
prev()Navigates to the previous slide cyclically
startAutoplay()Starts automatic cycling
pauseAutoplay()Pauses autoplay on hover
resumeAutoplay()Resumes autoplay after hover
playCurrentVideo()Plays the video in the current slide
pauseAllVideos()Pauses and resets all videos

Step 7 adding custom CSS styles

We add custom CSS for extra animations and touch-device optimizations:

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

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

  /* Keep buttons above other content */
  .carousel-prev,
  .carousel-next {
    -webkit-tap-highlight-color: transparent;
  }

  /* Active effect for touch devices */
  @media (hover: none) and (pointer: coarse) {
    .carousel-prev:active,
    .carousel-next:active {
      transform: translateY(-50%) scale(0.95);
    }
  }

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

We can now use our carousel component on any Astro page:

Basic example

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

const carouselItems = [
  {
    image: {
      src: '~/assets/images/slide1.jpg',
      alt: 'Slide 1',
    },
    caption: 'First image in the carousel',
  },
  {
    image: {
      src: '~/assets/images/slide2.jpg',
      alt: 'Slide 2',
    },
    caption: 'Second image in the carousel',
  },
  {
    video: {
      src: '/videos/demo.mp4',
    },
    caption: 'A video in the carousel',
  },
];
---

<Carousel
  title="Project gallery"
  subtitle="Our best work"
  items={carouselItems}
  autoplay={true}
  autoplayInterval={3000}
  aspectRatio="16/9"
/>

Advanced configuration

<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 optimizations

1. Lazy loading for images

Our Image component already uses loading="lazy", so images load only when they enter the viewport.

2. Responsive Images

The widths and sizes attributes generate multiple image versions:

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

3. Video optimization

Videos use the muted, loop and playsinline attributes for better performance and mobile support.

4. CSS transitions vs. JavaScript animations

We use CSS transitions rather than JavaScript animations. GPU acceleration makes them run more smoothly.

Accessibility Features

Our carousel component meets important accessibility standards:

  1. ARIA labels: Every interactive element has a meaningful label
  2. Keyboard navigation: Arrow keys for navigation
  3. aria-current: Identifies the current slide for screen readers
  4. Focus states: Visible focus rings for keyboard users
  5. Semantic HTML: Button elements instead of div elements with onClick

Summary

We have developed a complete, production-ready carousel component with:

Zero dependencies. No external libraries
TypeScript. Full type safety
Responsive design. Mobile-first approach
Performance. Lazy loading and CSS transitions
Accessibility. ARIA labels and keyboard support
Video support. Automatic playback and pausing
Flexible props. Easy adaptation to different scenarios
Modern UI. Glassmorphism and smooth animations

Advantages over external libraries

AspectCustom carouselExternal library, such as Swiper
Bundle size~2-3 KB~70-120 KB
Dependencies00
Customization100%Limited
Learning CurveLowMedium-high
MaintenanceFull controlDepends on maintainers

More demo examples

Here are more examples of using the carousel component in different scenarios:

Note: All demos are fully interactive. Try keyboard navigation, hover effects and clicking the indicators.

Conclusion

Building a custom carousel component without external dependencies is educational and offers significant benefits for performance, bundle size and maintainability. With Astro and TypeScript, we have created a modern, type-safe solution that fits seamlessly into our project.


Further reading:

Related posts

What should your portal do?

Describe your workflow. Together, we will identify the right next step.

Discuss your workflow