react-modal-sheet

repository·main·Indexed 22 days ago

https://github.com/temzasse/react-modal-sheet

A flexible bottom sheet component for React applications (v5.6.0) that utilizes the Motion library for high-quality gestures and animations. It employs a Compound Component pattern using Sheet.Container, Sheet.Header, Sheet.Content, and Sheet.Backdrop to provide full control over rendering. Key features include configurable snapPoints for height positions, detent options ('default', 'content', 'full'), and imperative control via SheetRef or Sheet.useContext.

Tokens
12.3K
Snippets
29
Records
40
Agent score
78%

What's inside react-modal-sheet

  1. Implement accessibility with external libraries

    main

    react-modal-sheet does not include built-in accessibility features (like focus trapping or screen reader support) to avoid bloat and allow you to use your preferred tools (e.g., React Aria).

    To make a sheet accessible, you should wrap the sheet components in an accessibility provider (like OverlayProvider and FocusScope from React Aria) and apply the appropriate props (like overlayProps and dialogProps) to the Sheet.Container.

    import { Sheet } from 'react-modal-sheet';
    import { useRef } from 'react';
    import { useOverlayTriggerState } from 'react-stately';
    import {
      useOverlay,
      useModal,
      OverlayProvider,
      FocusScope,
      useButton,
      useDialog,
    } from 'react-aria';
    
    function A11yExample() {
      const sheetState = useOverlayTriggerState({});
      const openButtonRef = useRef(null);
      const openButton = useButton({ onPress: sheetState.open }, openButtonRef);
    
      return (
        <div>
          <button {...openButton.buttonProps} ref={openButtonRef}>
            Open sheet
          </button>
    
          <Sheet isOpen={sheetState.isOpen} onClose={sheetState.close}>
            <OverlayProvider>
              <FocusScope contain autoFocus restoreFocus>
                <SheetComp sheetState={sheetState} />
              </FocusScope>
            </OverlayProvider>
          </Sheet>
        </div>
      );
    }
    
    function SheetComp({ sheetState }) {
      const containerRef = useRef(null);
      const dialog = useDialog({}, containerRef);
      const overlay = useOverlay(
        { onClose: sheetState.close, isOpen: true, isDismissable: true },
        containerRef
      );
    
      const closeButtonRef = useRef(null);
      const closeButton = useButton(
        { onPress: sheetState.close, 'aria-label': 'Close sheet' },
        closeButtonRef
      );
    
      useModal();
    
      const customHeader = (
        <div>
          <span {...dialog.titleProps}>Some title for sheet</span>
          <button {...closeButton.buttonProps}>🅧</button>
        </div>
      );
    
      return (
        <>
          <Sheet.Container
            {...overlay.overlayProps}
            {...dialog.dialogProps}
            ref={containerRef}
          >
            <Sheet.Header>{customHeader}</Sheet.Header>
            <Sheet.Content>{/*...*/}</Sheet.Content>
          </Sheet.Container>
          <Sheet.Backdrop />
        </>
      );
    }
  2. How internal MotionValues work

    main

    The sheet exposes y, yInverted, and yProgress as MotionValues. These can be used with useTransform or interpolate to drive animations based on the sheet's position:

    • y: The distance to the top-most position. 0 means the sheet is fully open.
    • yInverted: The distance from the bottom of the sheet.
    • yProgress: A normalized value from 0 (closed) to 1 (fully open).
    // Example: Using yProgress to fade an element
    const opacity = useTransform(() => {
      const progress = sheetRef?.yProgress.get() ?? 0;
      const mix = interpolate([0, 0.5, 0.6], [1, 1, 0]);
      return mix(progress);
    });
  3. How the Compound Component pattern works in react-modal-sheet

    main

    The Sheet component is built using the Compound Component pattern. Instead of a single monolithic component with many configuration props, the sheet is composed of smaller building blocks:

    • Sheet.Container: The wrapper for the sheet content.
    • Sheet.Header: The top section of the sheet.
    • Sheet.Content: The main body of the sheet.
    • Sheet.Backdrop: The overlay behind the sheet.

    This approach gives you total control over the rendering output. For example, if you do not want a backdrop, you simply omit the <Sheet.Backdrop /> component from your JSX. This also allows for easier application of accessibility properties to specific parts of the sheet without the main Sheet component needing to manage them.

  4. How the Sheet compound components work together

    main

    The react-modal-sheet library uses a compound component pattern. To build a sheet, you must nest components within the Sheet root:

    1. Sheet: The root wrapper. It renders a fixed-positioned motion.div that covers the screen. All other components must be children of this.
    2. Sheet.Container: Positioned above the backdrop. It provides default styling like shadows and rounded corners. Sheet.Content and Sheet.Header should be placed inside this.
    3. Sheet.Header: Acts as a drag target and includes a dragging direction indicator. If you provide children, the default header is replaced.
    4. Sheet.Content: The main area for content. It acts as a drag target and manages internal scrolling and keyboard avoidance.
    5. Sheet.Backdrop: A translucent overlay. It is rendered as a motion.div (or motion.button if you add interaction).
  5. Ensure scrollable content is reachable via padding

    main

    When using snap points, content at the bottom of a scrollable area might be cut off if the sheet is only partially open. You can use the y value to dynamically apply paddingBottom to the sheet's content container.

    Recommendation: When applying this, use disableDrag on the Sheet.Content component to prevent the drag gesture from interfering with the manual scrolling of the content.

    import { Sheet, type SheetRef } from 'react-modal-sheet';
    import { useTransform } from 'motion/react';
    import { useRef } from 'react';
    
    function PaddingExample() {
      const sheetRef = useRef<SheetRef>(null);
    
      // Calculate padding based on distance from top (y)
      const paddingBottom = useTransform(() => {
        return sheetRef.current?.y.get() ?? 0;
      });
    
      return (
        <Sheet ref={sheetRef} isOpen={true} snapPoints={[0, 0.5, 1]}>
          <Sheet.Container>
            <Sheet.Content
              scrollStyle={{ paddingBottom }}
              disableDrag
            >
              This content is always reachable.
            </Sheet.Content>
          </Sheet.Container>
        </Sheet>
      );
    }
  6. Create a custom scroller with useScrollPosition and useVirtualKeyboard

    main

    If you need to implement a custom scrollable area that isn't the entire Sheet.Content, you can disable the default behavior using disableScroll and implement your own using the useScrollPosition and useVirtualKeyboard hooks.

    1. Use useScrollPosition({ isEnabled: isOpen }) to get a scrollRef and scrollPosition.
    2. Use useVirtualKeyboard({ isEnabled: isOpen }) to get keyboardHeight or to manage the --keyboard-inset-height CSS variable.
    3. Pass the scrollRef to your custom scrollable element.
    4. Use disableDrag on Sheet.Content to ensure dragging only works when your custom scroller is at the top.
    import {
      Sheet,
      useScrollPosition,
      useVirtualKeyboard,
    } from 'react-modal-sheet';
    
    function CustomScrollerExample() {
      const [isOpen, setOpen] = useState(false);
    
      const { scrollRef, scrollPosition } = useScrollPosition({
        isEnabled: isOpen,
      });
    
      const { keyboardHeight } = useVirtualKeyboard({
        isEnabled: isOpen,
      });
    
      return (
        <Sheet avoidKeyboard={false} isOpen={isOpen} onClose={() => setOpen(false)}>
          <Sheet.Container>
            <Sheet.Header />
            <Sheet.Content
              disableScroll
              disableDrag={scrollPosition !== 'top'}
            >
              <div
                style={{
                  paddingBottom: keyboardHeight,
                  height: '100%',
                  display: 'grid',
                  gridTemplateRows: 'auto 1fr auto',
                }}
              >
                <div>Some content here...</div>
    
                <div
                  ref={scrollRef}
                  style={{ overflowY: 'auto' }}
                >
                  <div style={{ height: '400vh' }}>Long content here...</div>
                </div>
    
                <div>More content here...</div>
              </div>
            </Sheet.Content>
          </Sheet.Container>
          <Sheet.Backdrop />
        </Sheet>
      );
    }
  7. Manually manage virtual keyboard state

    main

    If the built-in avoidance behavior interferes with your layout (e.g., on Android Chrome), you can disable it using avoidKeyboard={false} and manage the keyboard height manually using the useVirtualKeyboard hook. This hook manages the --keyboard-inset-height CSS variable, which you can apply to your content's padding.

    import { Sheet, useVirtualKeyboard } from 'react-modal-sheet';
    
    function ManualKeyboardExample() {
      const [isOpen, setOpen] = useState(false);
    
      // This hook manages the `--keyboard-inset-height` CSS variable for you
      useVirtualKeyboard({ isEnabled: isOpen });
    
      return (
        <Sheet avoidKeyboard={false} isOpen={isOpen} onClose={() => setOpen(false)}>
          <Sheet.Container>
            <Sheet.Header />
            <Sheet.Content
              // Apply keyboard height as padding bottom to keep content above the keyboard
              style={{ paddingBottom: 'var(--keyboard-inset-height)' }}
            >
              {/* Your content here */}
            </Sheet.Content>
          </Sheet.Container>
          <Sheet.Backdrop />
        </Sheet>
      );
    }
  8. Animate properties based on snap point thresholds

    main

    You can animate properties (like border radius or backdrop opacity) based on which snap point is active by interpolating between values corresponding to your snapPoints array.

    A common pattern is to create a custom hook useSnapPointTransform that maps the sheet's y position to an array of output values that match the length of your snapPoints array.

    import { interpolate, useTransform } from 'motion/react';
    import { Sheet } from 'react-modal-sheet';
    
    // Custom hook for snap-point based interpolation
    export function useSnapPointTransform(
      output: number[],
      defaultValue?: number
    ) {
      const { snapPoints, y } = Sheet.useContext();
    
      return useTransform(y, (val) => {
        if (snapPoints.length === 0) return defaultValue ?? output[0];
        const mix = interpolate(
          snapPoints.map((point) => point.snapValueY),
          output
        );
        return mix(val);
      });
    }
    
    // Usage in a component
    function SheetContainer({ children }: { children: ReactNode }) {
      const borderRadius = useSnapPointTransform([32, 32, 8, 0]);
    
      return (
        <Sheet.Container
          style={{
            borderTopRightRadius: borderRadius,
            borderTopLeftRadius: borderRadius,
          }}
        >
          {children}
        </Sheet.Container>
      );
    }
  9. Access sheet methods and properties via Ref or Context

    main

    You can imperatively control the sheet or access its internal state using two primary methods:

    1. Via a Ref: Use useRef<SheetRef>(null) and pass it to the Sheet component. This is useful for controlling the sheet from outside its component tree. Note: Do not read the ref during render due to React rules; use it in event handlers or effects.
    2. Via Sheet.useContext: Use this hook within any child component of the Sheet. This is the preferred, more declarative way to access the sheet's state.

    If you want to avoid creating a separate component just to use the hook, you can use a render-prop pattern with a helper component.

    // 1. Using a Ref
    import { Sheet, type SheetRef } from 'react-modal-sheet';
    import { useRef } from 'react';
    
    function RefExample() {
      const sheetRef = useRef<SheetRef>(null);
      return (
        <Sheet ref={sheetRef}>
          <Sheet.Container>
            <Sheet.Content>Content</Sheet.Content>
          </Sheet.Container>
        </Sheet>
      );
    }
    
    // 2. Using Context (inside a child component)
    import { Sheet } from 'react-modal-sheet';
    
    function SheetContainer() {
      const context = Sheet.useContext();
      // Use context.snapTo(), context.y, etc.
      return <Sheet.Container>...</Sheet.Container>;
    }
  10. Override styles using CSS Modules or Vanilla CSS

    main

    You can override default styles by providing a className to the sheet components or by targeting the library's internal class names in your CSS.

    CSS Modules

    Pass your imported styles directly to the component props:

    <Sheet.Container className={styles.sheetContainer}>
      <Sheet.Header className={styles.sheetHeader} />
      <Sheet.Content className={styles.sheetContent}>{/*...*/}</Sheet.Content>
    </Sheet.Container>

    Vanilla CSS

    Target these specific class names in your global or component CSS. Note that you may need to use !important because the library applies some styles inline:

    • .react-modal-sheet-backdrop
    • .react-modal-sheet-container
    • .react-modal-sheet-header
    • .react-modal-sheet-header-container
    • .react-modal-sheet-drag-indicator-container
    • .react-modal-sheet-drag-indicator
    • .react-modal-sheet-content
    import styles from './styles.css';
    
    function Example() {
      return (
        <Sheet>
          <Sheet.Container className={styles.sheetContainer}>
            <Sheet.Header className={styles.sheetHeader} />
            <Sheet.Content className={styles.sheetContent}>{/*...*/}</Sheet.Content>
          </Sheet.Container>
          <Sheet.Backdrop className={styles.sheetBackdrop} />
        </Sheet>
      );
    }
  11. Add interaction to `Sheet.Backdrop`

    main
    The Sheet.Backdrop is a translucent overlay. By default, it is non-interactive. To make it interactive (e.g., to close the sheet when clicked), you must use the onTap prop from framer-motion instead of onClick, as the backdrop is a motion component. Adding a tap handler will change the underlying element from a div to a button.