Pen Settings

HTML

CSS

CSS Base

Vendor Prefixing

Add External Stylesheets/Pens

Any URLs added here will be added as <link>s in order, and before the CSS in the editor. You can use the CSS from another Pen by using its URL and the proper URL extension.

+ add another resource

JavaScript

Babel includes JSX processing.

Add External Scripts/Pens

Any URL's added here will be added as <script>s in order, and run before the JavaScript in the editor. You can use the URL of any other Pen and it will include the JavaScript from that Pen.

+ add another resource

Packages

Add Packages

Search for and use JavaScript packages from npm here. By selecting a package, an import statement will be added to the top of the JavaScript editor for this package.

Behavior

Auto Save

If active, Pens will autosave every 30 seconds after being saved once.

Auto-Updating Preview

If enabled, the preview panel updates automatically as you code. If disabled, use the "Run" button to update.

Format on Save

If enabled, your code will be formatted when you actively save your Pen. Note: your code becomes un-folded during formatting.

Editor Settings

Code Indentation

Want to change your Syntax Highlighting theme, Fonts and more?

Visit your global Editor Settings.

HTML

              
                <header>
  <h1 class="title">An Accessible Image Carousel</h1>
  <p class="subtitle">Learn how to build it in my <a href="https://www.aleksandrhovhannisyan.com/blog/image-carousel-tutorial/" target="_blank">in-depth tutorial</a></p>
</header>
<div class="carousel">
  <label>Enable right-to-left (RTL) directionality <input type="checkbox" id="rtl-toggle"></label>
  <div class="carousel-scroll-container" role="region" aria-label="Image carousel" tabindex="0">
    <ol class="carousel-media" role="list">
      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1652284225205-a27fce2b2ba7?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="3883" height="4896" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@martinkatler?utm_source=Carousel+demo&amp;utm_medium=referral ">Martin Katler</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1652723909307-b5619378937c?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="4000" height="2250" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@simonhumlr?utm_source=Carousel+demo&amp;utm_medium=referral ">Simon HUMLER</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1653149355492-3ceb41ad3fb7?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="Pink lilac" width="2333" height="3500" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@markusspiske?utm_source=Carousel+demo&amp;utm_medium=referral ">Markus Spiske</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1653422718631-f414801ccf94?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="3808" height="4928" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@qjsmith?utm_source=Carousel+demo&amp;utm_medium=referral ">Quinn Smith</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654002620977-a6cdacbbadba?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="4000" height="6000" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@christina_kozak?utm_source=Carousel+demo&amp;utm_medium=referral ">Christine Kozak</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654154117054-d774c3869a25?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="3648" height="5472" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@covene?utm_source=Carousel+demo&amp;utm_medium=referral ">Covene</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654461218976-367cf4ab09ed?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="3129" height="4693" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@ryanklausphotography?utm_source=Carousel+demo&amp;utm_medium=referral ">Ryan KLAUS</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654534095007-5db1b4faa1a6?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="" width="2976" height="4464" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@brunocervera?utm_source=Carousel+demo&amp;utm_medium=referral ">BRUNO EMMANUELLE</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654548712579-f80fc65de6bf?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="Colors of the desert. " width="4480" height="6720" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@drewtilk?utm_source=Carousel+demo&amp;utm_medium=referral ">Drew Tilk</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>

      <li class="carousel-item">
        <figure>
          <img src="https://images.unsplash.com/photo-1654763001398-2c553ee693fa?crop=entropy&amp;cs=tinysrgb&amp;fit=max&amp;fm=jpg&amp;ixid=MnwzMzU2NDN8MHwxfHJhbmRvbXx8fHx8fHx8fDE2NTQ5NzY4ODc&amp;ixlib=rb-1.2.1&amp;q=80&amp;w=1080" alt="architecture" width="4000" height="6000" loading="lazy" decoding="async">
          <figcaption>Photo by <a href="https://unsplash.com/@fl__q?utm_source=Carousel+demo&amp;utm_medium=referral ">Taiki Ishikawa</a> on <a href="https://unsplash.com/?utm_source=Carousel+demo&amp;utm_medium=referral">Unsplash</a></figcaption>
        </figure>
      </li>
    </ol>
  </div>
</div>

<!-- Navigation buttons, to be cloned and inserted into the carousel with JavaScript -->
<template id="carousel-controls">
  <ol role="list" class="carousel-controls" aria-label="Navigation controls">
    <li>
      <button class="carousel-control" aria-label="Previous" data-direction="start">
        <!-- https://feathericons.com/ -->
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <polyline points="15 18 9 12 15 6"></polyline>
        </svg>
      </button>
    </li>
    <li>
      <button class="carousel-control" aria-label="Next" data-direction="end">
        <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
          <polyline points="9 18 15 12 9 6"></polyline>
        </svg>
      </button>
    </li>
  </ol>
</template>
              
            
!

CSS

              
                * {
  margin: 0;
  padding: 0;
  box-sizing: border-box;
}
html,
body {
  height: 100%;
}
img {
  /* Line height reset */
  display: block;
}
body {
  font-family: Arial, Helvetica, sans-serif;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 2rem;
  max-width: 860px;
  margin: 0 auto;
}
header {
  text-align: center;
}
.title {
  font-size: 2.5rem;
  margin-bottom: 0.25em;
}
.subtitle {
  font-size: 1.25rem;
}
.carousel {
  position: relative;
}

.carousel [role="list"] {
  padding: 0;
  list-style: none;
}

.carousel-scroll-container {
  /* Enable horizontal scrolling */
  overflow-x: auto;

  /* Enable horizontal scroll snap */
  scroll-snap-type: x proximity;

  /* Smoothly snap from one focal point to another */
  scroll-behavior: smooth;
}

.carousel-media {
  /* Arrange media horizontally */
  display: flex;
  gap: 1rem;
}

.carousel-item {
  /* Limit the height of each media item */
  height: 300px;

  /* Prevent media from shrinking */
  flex-shrink: 0;

  /* The focal point for each item is the center */
  scroll-snap-align: center;
}

.carousel-item:first-of-type {
  /* Allow users to fully scroll to the start */
  scroll-snap-align: start;
}

.carousel-item:last-of-type {
  /* Allow users to fully scroll to the end */
  scroll-snap-align: end;
}

.carousel-item > *,
.carousel-item :is(figure, picture, img) {
  height: 100%;
}

.carousel-item img {
  /* Responsive width based on aspect ratio */
  width: auto;
}

.slideshow .carousel-item {
  /* Full-width slides, taller height */
  height: 90vmin;
  width: 100%;
}

.carousel figure {
  position: relative;
}

.carousel figcaption {
  position: absolute;
  bottom: 0;
  left: 0;
  width: 100%;
  padding: 0.25rem;
  text-align: center;
  background-color: hsl(0deg 0% 0% / 75%);
  font-size: small;
}

.carousel figcaption,
.carousel figcaption * {
  color: white;
}

.carousel-control {
  --offset-x: 0.25rem;
  cursor: pointer;

  /* Anchor the controls relative to the outer wrapper */
  position: absolute;

  /* Center the controls vertically */
  top: 50%;
  padding: 1rem;
  transform: translateY(-50%);
  border-radius: 50%;
  border: solid 1px hsl(0deg 0% 50%);
  background-color: white;
  color: black;
  box-shadow: 0 0 16px 0 hsl(0deg 0% 0% / 20%);
  line-height: 0;
}

.carousel-control:focus-visible {
  outline: none;
  box-shadow: 0 0 0 2px black, 0 0 0 4px white;
}

/* Don't allow icons to be event targets */
.carousel-control * {
  pointer-events: none;
}

.carousel-control[data-direction="start"] {
  /* Same as left in LTR and right in RTL */
  inset-inline-start: var(--offset-x);
}

.carousel-control[data-direction="end"] {
  /* Same as right in LTR and left in RTL */
  inset-inline-end: var(--offset-x);
}

[dir="rtl"] .carousel-control {
  transform: translateY(-50%) scale(-1);
}

.carousel-control[aria-disabled="true"] {
  filter: opacity(0.5);
  cursor: not-allowed;
}

label {
  display: block;
  text-align: center;
  margin-bottom: 1rem;
  direction: ltr;
}

input[type="checkbox"] {
  vertical-align: middle;
}

              
            
!

JS

              
                import throttle from 'https://cdn.skypack.dev/lodash@4.17.21/throttle';

/**
 * @typedef CarouselProps
 * @property {HTMLElement} root
 * @property {HTMLOListElement} [navigationControls]
 */

export default class Carousel {
  /** @type {HTMLElement} */
  #root;
  /** @type {HTMLElement} */
  #scrollContainer;
  /** @type {HTMLElement[]} */
  #scrollSnapTargets;
  /** @type {HTMLElement} */
  #navControlPrevious;
  /** @type {HTMLElement} */
  #navControlNext;
  /** @type {boolean} */
  #isRTL;

  /**
   * @param {CarouselProps} props
   */
  constructor(props) {
    this.#root = props.root;
    this.#scrollContainer = this.#root.querySelector('[role="region"][tabindex="0"]');
    this.#scrollSnapTargets = this.#scrollContainer.querySelectorAll('[role="list"] > *');
    this.#isRTL = window.getComputedStyle(this.#root).direction === 'rtl';

    this.#insertNavigationControls(props.navigationControls);
    this.#scrollContainer.addEventListener('scroll', throttle(this.#handleCarouselScroll, 200));
    this.#handleCarouselScroll();
  }

  set isRTL(isRightToLeft) {
    this.#isRTL = isRightToLeft;
  }

  /**
   * @param {HTMLElement} controls
   */
  #insertNavigationControls(controls) {
    if (!controls) return;

    const [navControlPrevious, navControlNext] = controls.querySelectorAll('button[data-direction]');
    this.#navControlPrevious = navControlPrevious;
    this.#navControlNext = navControlNext;

    const handleNavigation = (e) => {
      const direction = e.target.dataset.direction;
      const isDisabled = e.target.getAttribute('aria-disabled') === 'true';
      if (isDisabled) return;
      this.navigateToNextItem(direction);
    };

    this.#navControlPrevious.addEventListener('click', handleNavigation);
    this.#navControlNext.addEventListener('click', handleNavigation);
    this.#root.appendChild(controls);
  }

  #handleCarouselScroll = () => {
    // scrollLeft is negative in a right-to-left writing mode
    const scrollLeft = Math.abs(this.#scrollContainer.scrollLeft);
    // off-by-one correction for Chrome, where clientWidth is sometimes rounded down
    const width = this.#scrollContainer.clientWidth + 1;
    const isAtStart = Math.floor(scrollLeft) === 0;
    const isAtEnd = Math.ceil(width + scrollLeft) >= this.#scrollContainer.scrollWidth;
    this.#navControlPrevious?.setAttribute('aria-disabled', isAtStart);
    this.#navControlNext?.setAttribute('aria-disabled', isAtEnd);
  };

  /**
   * Returns the focal point for the given element, as determined by its scroll-snap-align (falling back to the fallback if not specified).
   * @param {HTMLElement} element The element in question.
   * @param {'start'|'center'|'end'} [fallback] A fallback value for the focal point.
   * @returns {'start'|'center'|'end'}
   */
  #getFocalPoint(element, fallback = 'center') {
    let focalPoint = window.getComputedStyle(element).scrollSnapAlign;
    if (focalPoint === 'none') {
      focalPoint = fallback;
    }
    return focalPoint;
  }

  /**
   * Returns the distance from the starting edge of the viewport to the given focal point on the element.
   * @param {HTMLElement} element
   * @param {'start'|'center'|'end'} [focalPoint]
   */
  #getDistanceToFocalPoint(element, focalPoint = 'center') {
    const documentWidth = document.documentElement.clientWidth;
    const rect = element.getBoundingClientRect();
    switch (focalPoint) {
      case 'start':
        return this.#isRTL ? documentWidth - rect.right : rect.left;
      case 'end':
        return this.#isRTL ? documentWidth - rect.left : rect.right;
      case 'center':
      default: {
        const centerFromLeft = rect.left + rect.width / 2;
        return this.#isRTL ? documentWidth - centerFromLeft : centerFromLeft;
      }
    }
  }

  /**
   * @param {'start'|'end'} direction
   */
  navigateToNextItem(direction) {
    let mediaItems = [...this.#scrollSnapTargets];
    mediaItems = direction === 'start' ? mediaItems.reverse() : mediaItems;

    const scrollContainerCenter = this.#getDistanceToFocalPoint(this.#scrollContainer, 'center');
    let targetFocalPoint;
    for (const mediaItem of mediaItems) {
      const focalPoint = this.#getFocalPoint(mediaItem);
      const distanceToItem = this.#getDistanceToFocalPoint(mediaItem, focalPoint);
      const isTarget =
        (direction === 'start' && distanceToItem + 1 < scrollContainerCenter) ||
        (direction === 'end' && distanceToItem - scrollContainerCenter > 1);
      if (isTarget) {
        targetFocalPoint = distanceToItem;
        break;
      }
    }

    // This should never happen, but it doesn't hurt to check
    if (typeof targetFocalPoint === 'undefined') return;
    // RTL flips the direction
    const sign = this.#isRTL ? -1 : 1;
    const scrollAmount = sign * (targetFocalPoint - scrollContainerCenter);
    this.#scrollContainer.scrollBy({ left: scrollAmount });
  }
}

const navigationControlsTemplate = document.querySelector('#carousel-controls');
const carousel = new Carousel({
  root: document.querySelector('.carousel'),
  navigationControls: navigationControlsTemplate.content.cloneNode(true),
});

// RTL switcher, for demo purposes only
const rtlToggle = document.querySelector('#rtl-toggle');
rtlToggle.addEventListener('input', (e) => {
  const dir = e.target.checked ? 'rtl' : 'ltr';
  document.documentElement.dir = dir;
  carousel.isRTL = dir === 'rtl';
});

              
            
!
999px

Console