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:
🎶Youspinmerightroundbabyrightroundlikearecordbabyrightroundroundround🎶Youspinmerightroundbabyrightroundlikearecordbabyrightroundroundround
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 on mount.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.direction
“horizontal” | “vertical” (default: “horizontal”)
The scroll direction.
If set to"vertical"
,enableVerticalScroll
must be set totrue
.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 totrue
, 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 (or scroll vertically whendirection
is set to"vertical"
).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-size
length value (default: 0)
The width (or height for vertical carousels) of the carousel items. Set this to a length value !== 0 if all items are the same width (or height). This helps with performance because the carousel doesn’t have to measure the size 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 thealign
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.
Theoptions
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 tocenter
and--carousel-snap-position
to50%
. 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 thechildAs
prop. To style the wrapper element, use theitemClassName
prop. Example:<Carousel childAs="span" itemClassName={styles.itemWrapper}>
. - See Carousel.module.scss 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, usemoveIntoView(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-size: 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-size: 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;
}