Gesture Driven Modal Sheet
An iOS-style gesture driven modal sheet built with React Aria Components, Framer Motion, and Tailwind CSS.
Example#
import {animate, AnimatePresence, cubicBezier, motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useTransform} from 'motion/react'; import {Button, Dialog, Heading, Modal, ModalOverlay} from 'react-aria-components'; import {useState} from 'react'; // Wrap React Aria modal components so they support motion values. const MotionModal = motion(Modal); const MotionModalOverlay = motion(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 = document.body.firstChild as HTMLElement; function Sheet() { let [isOpen, setOpen] = useState(false); let h = window.innerHeight - SHEET_MARGIN; let y = useMotionValue(h); let bgOpacity = useTransform(y, [0, h], [0.4, 0]); let bg = useMotionTemplate`rgba(0, 0, 0, )`; // Scale the body down and adjust the border radius when the sheet is open. let bodyScale = useTransform( y, [0, h], [(window.innerWidth - SHEET_MARGIN) / window.innerWidth, 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 = ` `); useMotionValueEvent( bodyTranslate, 'change', (v) => root.style.translate = `0 px`); useMotionValueEvent( bodyBorderRadius, 'change', (v) => root.style.borderRadius = ` 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-(--page-background) absolute bottom-0 w-full rounded-t-xl shadow-lg will-change-transform" 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: window.screen.height }} 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> </> ); }
import { animate, AnimatePresence, cubicBezier, motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'; import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; import {useState} from 'react'; // Wrap React Aria modal components so they support motion values. const MotionModal = motion(Modal); const MotionModalOverlay = motion(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 = document.body.firstChild as HTMLElement; function Sheet() { let [isOpen, setOpen] = useState(false); let h = window.innerHeight - SHEET_MARGIN; let y = useMotionValue(h); let bgOpacity = useTransform(y, [0, h], [0.4, 0]); let bg = useMotionTemplate`rgba(0, 0, 0, )`; // Scale the body down and adjust the border radius when the sheet is open. let bodyScale = useTransform( y, [0, h], [ (window.innerWidth - SHEET_MARGIN) / window.innerWidth, 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 = ` `); useMotionValueEvent( bodyTranslate, 'change', (v) => root.style.translate = `0 px`); useMotionValueEvent( bodyBorderRadius, 'change', (v) => root.style.borderRadius = ` 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-(--page-background) absolute bottom-0 w-full rounded-t-xl shadow-lg will-change-transform" 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: window.screen.height }} 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> </> ); }
import { animate, AnimatePresence, cubicBezier, motion, useMotionTemplate, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'; import { Button, Dialog, Heading, Modal, ModalOverlay } from 'react-aria-components'; import {useState} from 'react'; // Wrap React Aria modal components so they support motion values. const MotionModal = motion(Modal); const MotionModalOverlay = motion(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 = document .body .firstChild as HTMLElement; function Sheet() { let [isOpen, setOpen] = useState(false); let h = window.innerHeight - SHEET_MARGIN; let y = useMotionValue( h ); let bgOpacity = useTransform(y, [ 0, h ], [0.4, 0]); let bg = useMotionTemplate`rgba(0, 0, 0, )`; // Scale the body down and adjust the border radius when the sheet is open. let bodyScale = useTransform( y, [0, h], [ (window .innerWidth - SHEET_MARGIN) / window .innerWidth, 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 = ` `); useMotionValueEvent( bodyTranslate, 'change', (v) => root.style .translate = `0 px`); useMotionValueEvent( bodyBorderRadius, 'change', (v) => root.style .borderRadius = ` 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-(--page-background) absolute bottom-0 w-full rounded-t-xl shadow-lg will-change-transform" 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: window .screen .height }} 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> </> ); }
Tailwind config#
This example uses the tailwindcss-react-aria-components plugin. When using Tailwind v4, add it to your CSS:
@import "tailwindcss"; @plugin "tailwindcss-react-aria-components";
@import "tailwindcss"; @plugin "tailwindcss-react-aria-components";
@import "tailwindcss"; @plugin "tailwindcss-react-aria-components";
Tailwind v3
When using Tailwind v3, add the plugin to your tailwind.config.js
instead:
module.exports = { // ... plugins: [ require('tailwindcss-react-aria-components') ] };
module.exports = { // ... plugins: [ require('tailwindcss-react-aria-components') ] };
module.exports = { // ... plugins: [ require( 'tailwindcss-react-aria-components' ) ] };
Note: When using Tailwind v3, install tailwindcss-react-aria-components
version 1.x instead of 2.x.
Components#
Dialog
A dialog is an overlay shown above other content in an application.
Button
A button allows a user to perform an action.