Components
Carousel

Carousel

A simple, responsive, infinite carousel component with zero dependencies. It supports touch gestures, mouse dragging and mouse wheels. It can also be turned into an autoscrolling ticker:

Installation

npm install @madeinhaus/carousel

Import

import Carousel from '@madeinhaus/carousel';
import '@madeinhaus/carousel/dist/index.css';

Props

All props are optional.

  • activeItemIndex number (default: 0)
    The index of the active item.
  • align "start" | "center" (default: "start")
    The alignment of the active item.
  • as React.ElementType (default: 'ul')
    The element type to use for the container element.
  • childAs React.ElementType (default: 'li')
    The element type to use for the item wrapper elements.
  • className string (default: undefined)
    The CSS class name to apply to the container element.
  • damping number (default: 200)
    The damping factor applied to the velocity of the carousel after throwing. Lower values make the carousel stop more abruptly.
  • disableSnap boolean (default: false)
    Whether to disable snapping. If snapping is disabled, the carousel will not snap to the --carousel-snap-position.
  • enableNavigationGestures boolean (default: false)
    Mobile browsers often implement gestures to navigate back or forward in the browser history (usually by swiping from the edges of the screen). These gestures can interfere with the carousel's touch gestures and are therefore disabled by default. If this prop is set to true, the carousel will re-enable these gestures.
  • enableVerticalScroll boolean (default: false)
    Whether to enable vertical scrolling. If enabled, the carousel will scroll horizontally when swiping or scrolling up or down.
  • itemClassName string (default: undefined)
    The CSS class name to apply to the item wrapper elements.
  • onDrag () => void (default: undefined)
    A callback that is called when the user starts dragging the carousel.
  • onPress (event: PointerEvent) => void (default: undefined)
    A callback that is called when the user presses on the carousel.
  • onSnap (index: number) => void (default: undefined)
    A callback that is called when the carousel snaps to an item.
  • style React.CSSProperties (default: undefined)
    The CSS style to apply to the container element.

CSS Variables

Responsive props are passed to the carousel via CSS variables. Define these on the carousel's container element or on one of its ancestors.

  • --carousel-gap length value (default: 0)
    The gap between carousel items.
  • --carousel-item-width length value (default: 0)
    The width of the carousel items. Set this to a length value !== 0 if all items are the same width. This helps with performance because the carousel doesn't have to measure the width of each item.
  • --carousel-snap-position length value (default: 0)
    If snapping is enabled, this defines the position (measured from the left edge of the carousel's container) that an item snaps to. Use this in combination with the align prop to snap the left edge (align="start") or the center (align="center") of an item to this position.
  • --carousel-autoscroll number (default: 0)
    Set this to a value !== 0 to turn the carousel into an autoscrolling ticker. The value is the speed in px/ms. Values between +/- 0.1 and 0.2 work best. Negative values make the carousel scroll to the left, positive values to the right.
  • --carousel-disabled 0 | 1 (default: 0)
    Set this to 1 to disable the carousel (disables all carousel JS and all internal carousel CSS). Useful e.g. if on large screens you want to layout items on a (static) grid and on small screens in a carousel.

Length values can be any valid CSS length value, e.g. 1rem, 100px, 50%, calc(var(--grid-margin) * 2). Relative length values are resolved relative to the size of the carousel's container.

Imperative Methods

You may use the following methods via ref to programmatically control the carousel:

  • moveIntoView(index, options)
    Moves the item at the given index into view.
    The options object takes the following keys:
    • easeFn EasingFunction (optional, default: easings.easeInOutCubic)

    • duration number (optional, default: 700)
  • refresh()
    Re-renders the carousel. Useful if any of the CSS variables have changed programmatically (not triggered by resize).

Tips

  • To center the active item in the container, set the align prop to center and --carousel-snap-position to 50%. The default is to align the left edge of the active item with the left edge of the container.
  • The children you pass to the carousel are internally wrapped, by default in a <li> element. If you want to use a different element, pass it as the childAs prop. To style the wrapper element, use the itemClassName prop. Example: <Carousel childAs="span" itemClassName={styles.itemWrapper}>.
  • See Carousel.module.scss (opens in a new tab) for the default CSS applied to the container and the item wrappers.

Known Issues

  • The carousel is not accessible. It doesn't support keyboard navigation and it doesn't announce its state to screen readers.
  • The carousel can currently not be used as a controlled component. Even though it has a activeItemIndex prop, it is not used to control the active item (it can only be used to initialize the active item on mount). Instead, use moveIntoView(index, options) to programmatically move a specific item into view.
  • The carousel may run into an infinite loop in certain conditions. This has only been observed on iPhones with 120Hz displays (13+) while quickly swiping left and right. The issue is duct-tape-fixed so that the carousel doesn't get stuck in an infinite loop anymore, but the actual bug has not been identified yet.

Examples

Basic

CarouselDemo.tsx
import Carousel from '@madeinhaus/carousel';
import '@madeinhaus/carousel/dist/index.css';
 
import styles from './CarouselDemo.module.css';
 
const dog = '/assets/images/dogs/n02097047_1028.jpg';
const dogs = new Array(20).fill(dog);
 
const CarouselDemo: React.FC = () => {
  return (
    <div className={styles.root}>
      <Carousel className={styles.carousel} itemClassName={styles.carouselItem}>
        {dogs.map((dog, i) => (
          <img key={i} src={dog} alt="" className={styles.image} />
        ))}
      </Carousel>
    </div>
  );
};
 
export default CarouselDemo;
CarouselDemo.module.css
.carousel {
  --image-size: 300px;
  --carousel-gap: 20px;
  --carousel-item-width: var(--image-size);
}
 
.carouselItem {
  position: relative;
  width: var(--image-size);
  height: var(--image-size);
}
 
.image {
  position: absolute;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

Lazy Loading

CarouselDemoLazyLoad.tsx
import Carousel from '@madeinhaus/carousel';
import '@madeinhaus/carousel/dist/index.css';
import cx from 'clsx';
import { useIntersectionObserver, useImagePreload } from '@madeinhaus/hooks';
 
import styles from './Carousel.module.css';
 
const dog = '/assets/images/dogs/n02097047_1028.jpg';
const dogs = new Array(20).fill(dog);
 
const CarouselDemo: React.FC = () => {
  return (
    <div className={styles.root}>
      <Carousel className={styles.carousel} itemClassName={styles.carouselItem}>
        {dogs.map((dog, i) => (
          <LazyImage key={i} url={dog} />
        ))}
      </Carousel>
    </div>
  );
};
 
interface LazyImageProps {
  url: string;
}
 
const LazyImage: React.FC<LazyImageProps> = ({ url }) => {
  const [inView, intersectionRef] = useIntersectionObserver();
  const [loaded, loadRef] = useImagePreload();
  return (
    <div ref={intersectionRef} className={styles.imageWrapper}>
      <img
        ref={loadRef}
        src={inView ? url : undefined}
        className={cx(styles.image, { [styles.loaded]: loaded })}
        alt=""
      />
    </div>
  );
};
 
export default CarouselDemo;
CarouselDemoLazyLoad.module.css
.carousel {
  --image-size: 300px;
  --carousel-gap: 20px;
  --carousel-item-width: var(--image-size);
}
 
.carouselItem {
  position: relative;
  width: var(--image-size);
  height: var(--image-size);
}
 
.imageWrapper {
  position: absolute;
  inset: 0;
}
 
.image {
  position: absolute;
  width: 100%;
  height: 100%;
  object-fit: cover;
  opacity: 0.001;
  transition: opacity 0.5s linear;
}
 
.image.loaded {
  opacity: 1;
}