Beta Preview

Gesture Driven Sheet

An iOS-style gesture driven sheet built with React Aria Components, Framer Motion, and Tailwind CSS.

import {AnimatePresence, motion, animate, useMotionTemplate, useMotionValue, useTransform, useMotionValueEvent, cubicBezier} from 'motion/react';
import {Dialog, ModalOverlay, Modal, Button, Heading} from 'react-aria-components';
import {useState} from 'react';

// Wrap React Aria modal components so they support motion values.
const MotionModal = motion.create(Modal);
const MotionModalOverlay = motion.create(ModalOverlay);

const inertiaTransition = {
  type: "inertia" as const,
  bounceStiffness: 300,
  bounceDamping: 40,
  timeConstant: 300
};

const staticTransition = {
  duration: 0.5,
  ease: cubicBezier(0.32, 0.72, 0, 1)
};

const SHEET_MARGIN = 34;
const SHEET_RADIUS = 12;

const root = typeof document !== 'undefined' ? document.body.firstChild as HTMLElement : null;

function Sheet() {
  let [isOpen, setOpen] = useState(false);
  let h = typeof window !== 'undefined' ? window.innerHeight - SHEET_MARGIN : 0;
  let y = useMotionValue(h);
  let bgOpacity = useTransform(y, [0, h], [0.4, 0]);
  let bg = useMotionTemplate`rgba(0, 0, 0, ${bgOpacity})`;

  // Scale the body down and adjust the border radius when the sheet is open.
  let bodyScale = useTransform(
    y,
    [0, h],
    [(typeof window !== 'undefined' ? (window.innerWidth - SHEET_MARGIN) / window.innerWidth : 0), 1]
  );
  let bodyTranslate = useTransform(y, [0, h], [SHEET_MARGIN - SHEET_RADIUS, 0]);
  let bodyBorderRadius = useTransform(y, [0, h], [SHEET_RADIUS, 0]);

  useMotionValueEvent(bodyScale, 'change', v => root.style.scale = `${v}`);
  useMotionValueEvent(bodyTranslate, 'change', v => root.style.translate = `0 ${v}px`);
  useMotionValueEvent(bodyBorderRadius, 'change', v => root.style.borderRadius = `${v}px`);

  return (
    <>
      <Button
        className="text-blue-600 text-lg font-semibold outline-hidden rounded-sm bg-transparent border-none pressed:text-blue-700 focus-visible:ring-3"
        onPress={() => setOpen(true)}>
        Open sheet
      </Button>
      <AnimatePresence>
        {isOpen && (
          <MotionModalOverlay
            // Force the modal to be open when AnimatePresence renders it.
            isOpen
            onOpenChange={setOpen}
            className="fixed inset-0 z-10"
            style={{ backgroundColor: bg as any }}>
            <MotionModal
              className="bg-white dark:bg-zinc-900 absolute bottom-0 w-full rounded-t-xl shadow-lg will-change-transform font-sans"
              initial={{ y: h }}
              animate={{ y: 0 }}
              exit={{ y: h }}
              transition={staticTransition}
              style={{
                y,
                top: SHEET_MARGIN,
                // Extra padding at the bottom to account for rubber band scrolling.
                paddingBottom: typeof window !== 'undefined' ? window.screen.height : 0
              }}
              drag="y"
              dragConstraints={{ top: 0 }}
              onDragEnd={(e, { offset, velocity }) => {
                if (offset.y > window.innerHeight * 0.75 || velocity.y > 10) {
                  setOpen(false);
                } else {
                  animate(y, 0, { ...inertiaTransition, min: 0, max: 0 });
                }
              }}>
              {/* drag affordance */}
              <div className="mx-auto w-12 mt-2 h-1.5 rounded-full bg-gray-400" />
              <Dialog className="px-4 pb-4 outline-hidden">
                <div className="flex justify-end">
                  <Button
                    className="text-blue-600 text-lg font-semibold mb-8 outline-hidden rounded-sm bg-transparent border-none pressed:text-blue-700 focus-visible:ring-3"
                    onPress={() => setOpen(false)}>
                    Done
                  </Button>
                </div>
                <Heading slot="title" className="text-3xl font-semibold mb-4">
                  Modal sheet
                </Heading>
                <p className="text-lg mb-4">
                  This is a dialog with a custom modal overlay built with React Aria Components and Framer Motion.
                </p>
                <p className="text-lg">
                  Lorem ipsum dolor sit amet, consectetur adipiscing elit. Aenean sit amet nisl blandit, pellentesque eros eu, scelerisque eros. Sed cursus urna at nunc lacinia dapibus.
                </p>
              </Dialog>
            </MotionModal>
          </MotionModalOverlay>
        )}
      </AnimatePresence>
      {/* Adjust page styles so the background can scale down when the sheet is open. */}
      <style>{`
        body {
          background: black;
        }

        body > :first-child {
          background: var(--s2-container-bg);
          translate: 0;
          transform-origin: center 0;
          overflow: auto;
          height: 100vh;
          max-width: unset;
          padding-inline: max((100% - 1280px) / 2, 0px);
        }
      `}</style>
    </>
  );
}

Components