[Joy] Add Carousel component
See original GitHub issueDuplicates
- I have searched the existing issues
Latest version
- I have tested the latest version
Summary 💡
So standard Material UI doesn’t provide any carousel component out of the box, however after playing around with the new Joy UI components I started writing a CSS snap carousel component that could possibly be useful for Joy. The idea partially stems from the Google news feed and a few other apps I commonly use.
I’ll provide the src files below as this isn’t a ready component in any way, it still doesn’t have the forward back scroll functions in place, a lot of the type names need to be fixed, and I’m unsure if the virtual library I use is appropriate for this. It definitely needs a bit of work but I’d be really happy to hear people’s thoughts on how a carousel component should be designed.
BUGS So far the only bug I’ve run into is when you scroll to the last element, the first element in the list beings to render, then disappear in a loop. I honestly don’t know if virtual makes sense for this, however major carousel libraries have this kind of support and there are cases where it could be needed. Perhaps you are rendering a gallery then it could make sense to have, images that wouldn’t all be loaded at once, etc.
NOTES There is also a prop called isSSR to switch between standard rendering and using the virtual hook (@tanstack/react-virtual), I still need to devise a solution to allow users to programmatically scroll regardless of the method used.
(Also the file structure is slightly dif from MUI. Would be fixed later.) Index
Carousel Root w/ Virtual Hook
import { mergeRefs } from '@fox-dls/utils';
import { unstable_composeClasses as composeClasses } from '@mui/base';
import { Box, Button, useThemeProps } from '@mui/material';
import { OverridableComponent } from '@mui/types';
import clsx from 'clsx';
import PropTypes from 'prop-types';
import * as React from 'react';
import { CarouselContext } from './context';
import { CarouselRoot } from './styles';
import {
getCarouselUtilityClass,
CarouselProps,
CarouselTypeMap
} from './types';
import { useVirtualizer } from '@tanstack/react-virtual';
const useUtilityClasses = (ownerState: CarouselProps) => {
const slots = {
root: []
};
return composeClasses(slots, getCarouselUtilityClass, {});
};
export const Carousel = React.forwardRef(function Carousel(inProps, ref) {
const props = useThemeProps({
props: inProps,
name: 'MuiCarousel'
});
const {
className,
//@ts-ignore
component = 'div',
children,
visibleItems,
gap,
isControls,
vertical,
viewConfig,
placeholder,
isSSR,
...other
} = props;
const ownerState = {
...props,
component
};
const classes = useUtilityClasses(ownerState);
const scrollerRef = React.useRef();
const scrollerVirtualizer = useVirtualizer({
horizontal: vertical ? false : true,
count: React.Children.count(children),
getScrollElement: () => scrollerRef.current,
estimateSize: () => 0,
...viewConfig
});
return (
<CarouselContext.Provider value={null}>
<CarouselRoot
as={component}
ownerState={ownerState}
className={clsx(classes.root, className)}
ref={mergeRefs([ref, scrollerRef])}
{...other}
>
{isSSR
? scrollerVirtualizer.getVirtualItems().map(virtual => (
<Box key={virtual.index} ref={virtual.measureElement}>
{placeholder
? React.Children.toArray(children)[virtual.index] ??
placeholder
: React.Children.toArray(children)[virtual.index]}
</Box>
))
: React.Children.map(children, (child, index) => {
if (!React.isValidElement(child)) {
return child;
}
if (index === 0) {
return React.cloneElement(child, {
'carousel-first-child': ''
});
}
if (index === React.Children.count(children) - 1) {
return React.cloneElement(child, { 'carousel-last-child': '' });
}
return child;
})}
</CarouselRoot>
<button onClick={() => scrollerVirtualizer.scrollToIndex(4)}>
Scroll To Index 2
</button>
</CarouselContext.Provider>
);
}) as OverridableComponent<CarouselTypeMap>;
Carousel.propTypes /* remove-proptypes */ = {
/**
* Used to render icon or text elements inside the Carousel if `src` is not set.
* This can be an element, or just a string.
*/
children: PropTypes.node,
/**
* @ignore
*/
className: PropTypes.string,
/**
* The component used for the root node.
* Either a string to use a HTML element or a component.
*/
component: PropTypes.elementType,
/**
* The number of items visible at a given time. Defaults to 1
*/
visibleItems: PropTypes.number,
/**
* Item peek in px
*/
peek: PropTypes.string,
/**
* The gap between items in px. Defaults to 0
*/
gap: PropTypes.string,
/**
* Is Verical or Horizontal
*/
vertical: PropTypes.bool,
/**
* Config for view observer
*/
viewConfig: PropTypes.object,
/**
* Children props
*/
itemProps: PropTypes.shape({}),
/**
* Placeholder node to load
*/
placeholder: PropTypes.node,
/**
* Is SSR
*/
isSSR: PropTypes.bool,
sx: PropTypes.oneOfType([
PropTypes.arrayOf(
PropTypes.oneOfType([PropTypes.func, PropTypes.object, PropTypes.bool])
),
PropTypes.func,
PropTypes.object
])
} as any;
Types & Classes
Types & Classes
import * as React from 'react';
import { OverridableStringUnion, OverrideProps } from '@mui/types';
import { SxProps } from '@mui/system';
import { generateUtilityClass, generateUtilityClasses } from '@mui/base';
export type CarouselSlot = 'root';
export interface CarouselTypeMap<P = {}, D extends React.ElementType = 'div'> {
props: P & {
/**
* Used to render icon or text elements inside the Carousel if `src` is not set.
* This can be an element, or just a string.
*/
children?: React.ReactNode;
/**
* The system prop that allows defining system overrides as well as additional CSS styles.
*/
sx?: SxProps;
/**
* The number of items visible at a given time. Defaults to 1
*/
visibleItems?: number;
/**
* Item peek in px
*/
peek?: string;
/**
* The gap between items in px. Defaults to 0
*/
gap?: string;
/**
* Are the overlay controls available
*/
isControls?: boolean;
/**
* Is Horizontal or Vertical
*/
vertical?: boolean;
/**
* Config for view observer
*/
viewConfig?: object;
/**
* Placeholder node to load
*/
placeholder?: React.ReactNode;
/**
* Is SSR
*/
isSSR?: boolean;
};
defaultComponent: D;
}
export type CarouselProps<
D extends React.ElementType = CarouselTypeMap['defaultComponent'],
P = { component?: React.ElementType }
> = OverrideProps<CarouselTypeMap<P, D>, D>;
export interface CarouselClasses {
/** Styles applied to the root element. */
root: string;
}
export type CarouselClassKey = keyof CarouselClasses;
export function getCarouselUtilityClass(slot: string): string {
return generateUtilityClass('MuiCarousel', slot);
}
const CarouselClasses: CarouselClasses = generateUtilityClasses('MuiCarousel', [
'root'
]);
export default CarouselClasses;
Styles
Carousel Style
import { styled } from '@mui/system';
import { CarouselProps } from '../types';
import { resolveSxValue } from '../../theme/mui/utils';
export const CarouselRoot = styled('div', {
name: 'MuiCarousel',
slot: 'Root',
overridesResolver: (props, styles) => styles.root
})<{ theme?: any; ownerState: CarouselProps }>(({ theme, ownerState }) => [
{
'--Carousel-contain': 'layout paint size style',
'--Carousel-overflow': 'visible hidden',
'--Carousel-content-visibility': 'unset',
'--Carousel-controls-z-index': 1,
'--Carousel-gap': ownerState.gap ?? '0px',
'--Carousel-height': '100%',
'--Carousel-peek': ownerState.peek ?? '0px',
'--Carousel-scrollsnapstop': 'always',
'--Carousel-visible-items': ownerState.visibleItems ?? 1,
'&::-webkit-scrollbar': {
display: 'none'
},
'& > *': {
scrollSnapAlign: 'start'
},
['@supports (-webkit-scroll-behavior:smooth) or (-moz-scroll-behavior:smooth) or (-ms-scroll-behavior:smooth) or (scroll-behavior:smooth)']:
{
scrollSnapType: 'var(--Carousel-scroll-snap-type,inline mandatory);'
},
margin: '0px !important',
width: '100% !important',
height: 'var(--Carousel-height)',
contain: 'var(--Carousel-contain)',
containIntrinsicSize: 'var(--Carousel-contain-intrinsic)',
//contentVisibility: 'var(--Carousel-content-visibility)',
display: 'grid',
gridAutoFlow: 'column',
gridAutoColumns:
'var( --Carousel-auto-columns, calc( ( 100% - var(--Carousel-gap,16px) * (var(--Carousel-visible-items,unset) - 1) ) / var(--Carousel-visible-items,unset) ) )',
gap: 'var(--Carousel-gap,16px)',
gridTemplateRows: 'var(--Carousel-rows,none)',
justifyContent: 'flex-start',
minHeight: 'var(--Carousel-min-height)',
overflow: 'var(--Carousel-overflow,auto hidden)',
overscrollBehaviorInline: 'contain',
paddingBlockStart: 'var(--Carousel-padding-block-start,unset)',
paddingBlockEnd: 'var(--Carousel-padding-block-end,unset)',
paddingInlineStart:
'var( --Carousel-padding-inline-start, var(--Carousel-peek,32px) )',
paddingInlineEnd:
'var( --Carousel-padding-inline-end, var(--Carousel-peek,32px) )',
scrollPaddingInline: 'var(--Carousel-peek,32px)',
touchAction: 'var(--Carousel-touch-action,pan-x pan-y pinch-zoom)',
scrollbarWidth: 'none'
}
]);
Examples 🌈
I still need to move all this to its own sandbox.
<AspectRatio ratio="20/19" sx={{ width: '100%', height: '100%' }}>
<CardContainer>
<CardContent>
<Carousel
gap="0px"
visibleItems={2}
gap="10px"
placeholder={<Loading />}
isSSR={true}
>
<CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
<CardCover>
<img
src="https://images.unsplash.com/photo-1656536665880-2cb1ab7d54e3?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
alt=""
/>
</CardCover>
</CardContainer>
<CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
<CardCover>
<img
src="https://images.unsplash.com/photo-1656231934649-c476090deb59?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1524&q=80"
alt=""
/>
</CardCover>
</CardContainer>
<CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
<CardCover>
<img
src="https://images.unsplash.com/photo-1656501854040-2850f4a37562?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
alt=""
/>
</CardCover>
</CardContainer>
<CardContainer sx={{ height: '100%', borderRadius: '0px' }}>
<CardCover>
<img
src="https://images.unsplash.com/photo-1656501854040-2850f4a37562?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=774&q=80"
alt=""
/>
</CardCover>
</CardContainer>
</Carousel>
</CardContent>
</CardContainer>
</AspectRatio>
Motivation 🔦
It looks to me as if Joy might be the next big thing for MUI and I would like to attempt to provide useful components so that other developers will be able to make cool things!
Issue Analytics
- State:
- Created a year ago
- Reactions:14
- Comments:6 (1 by maintainers)
It should allow for some freedom, with next and previous coming from the hook it’s much easier to implement your own carousel buttons as opposed to having them overlayed on the left and right. If anyone has any feedback I would greatly appreciate it, optimizations, best practices, etc, I would really like to try to get a carousel into Mui so other developers have more in their arsenal!
I think this solution is much better, the Carousel object itself is quite simple and honestly lets users decide how they want to handle the navigation aspect. Included is the hook that takes the ref and lets you move forward, back, etc. I’m already making a slightly different hook with better functions but I think the carousel and hook idea might be good. It’s no swiper JS but it’s simple and I think the CSS snap feature is great for most use cases!