mui / mui/material-ui

[joy-ui] Add Carousel component

Open
#33,392 9 comments 35 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

on hold package: joy-ui scope: new proposal type: new feature
Dominant language
JavaScript
Stars
99.1k
Forks
32.5k
Avg merge
2d 17h
Merged PRs (30d)
106

Description

Summary

@siriwatknp @danilo-leal

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!

For the underlying component logic (here it's focus on design), see https://github.com/mui/mui-x/issues/3601.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start by reviewing the proposed Carousel root, context, styles, and types entry points shown in the issue, along with the @tanstack/react-virtual integration and Joy UI examples. Before coding, establish the component API, virtualization and SSR behavior, controls, and acceptance criteria; done should mean an agreed, tested Carousel implementation rather than the current exploratory snippet.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript, react, typescript
Domain
frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
25/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.