We're excited to introduce the React Aria Sheet component! A Sheet is an overlay that slides in from an edge of the viewport, and can be swiped by the user to dismiss. It supports gesture-synchronized animations, snap points, stacking, overscroll padding, software keyboard awareness, and multiple placements and swipe directions.
Our implementation is entirely built using native CSS scroll snapping and view timelines, on top of our existing Modal component. This means swipe gestures run off the main thread at the native display refresh rate without any JavaScript touch handling, and we get fluid momentum scrolling and elastic physics for free. In this post, I'll explain how it works.
Setting up the viewport
To create the illusion of swiping to open and close a sheet, we use regular CSS overflow scrolling. The sheet overlay is a scrollable element positioned above the rest of the page, hiding its scrollbar with scrollbar-width: none. It is double the height of the window viewport, and positioned so that only the top half is visible, with the sheet in the bottom half. The sheet is inside a "stage", which is a viewport-sized flex container that positions the sheet itself on the edge of the viewport (e.g. the bottom). We add CSS scroll snap points at the top and bottom of the sheet so that it always stops either fully open or fully closed.
When the sheet is closed, the scroll position is zero, so the sheet is off screen (clipped by the window viewport). When it opens, it scrolls down to 100% with scroll-behavior: smooth, which animates the sheet up and into view. The user can swipe it away by scrolling to the top (moving the sheet down). Once we detect that the sheet is completely out of view (via an IntersectionObserver), it is unmounted.
To account for native rubber band scrolling physics, the overscrollPadding prop adds additional padding to the edge of the sheet so that it appears to stretch beyond the viewport when dragged.
All of this geometry flips depending on the swipeDirection and position props, so it works for top, left, right, and center sheets as well.
Gesture-synced animations
The backdrop and the sheet itself support a swipeAnimation prop, which is a CSS keyframe animation synchronized with the swipe gesture. For example, the backdrop might fade in and out, and the sheet could apply a transform or adjust its corner radius based on the swipe progress. This uses CSS view-timeline, which tracks how much of an element is visible and uses this to drive a keyframe animation. We use view-timeline-inset: 0 100dvh so that swipe animations begin when the sheet is visible, not when clipped by the window viewport. Swipe the demo below to see how it works.
.sheet {
view-timeline: --sheet;
view-timeline-inset: 0 100dvh;
}
.backdrop {
animation: backdrop linear both;
animation-timeline: --sheet;
animation-range: entry;
}Snap points
Sheet supports custom snapPoints, which allow it to stop partially open at specific visible heights. This is implemented by rendering an invisible 1px absolutely positioned marker div inside the sheet for each snap point. scroll-margin-top: 100dvh offsets the scroll position so the marker snaps to the bottom of the viewport, leaving the specified height of the sheet on screen.
When a sheet is partially visible, we disable scrolling the sheet's inner content by setting overflow: hidden. This allows the user to swipe anywhere to expand. Once the sheet is fully open, the content becomes scrollable. overscroll-behavior: auto enables scroll chaining, which means that when the user is at the top of the inner content, they can swipe down to dismiss the sheet. When they have scrolled the inner content, swiping down scrolls the content.
Non-dismissible sheets
When the preventDismissal prop is true, the user cannot swipe the sheet away. However, they can still swipe between snap points. In this case, after the entering animation, the stage is adjusted so that the bottom of the viewport aligns with the first snap point at scrollTop = 0. This prevents it from being swiped off the screen, and preserves the native feeling scroll bounce.
Stacking
Sheets can be stacked with a custom stackAnimation, driven by the same view timelines that track the swipe gesture. These are defined on the root html element via timeline-scope, which allows them to be used by any element on the page. One or more instances of the stack animation are applied to each sheet, tied to the timelines of the sheets in front of it. animation-composition: accumulate combines their effects together, so for each sheet the stack depth appears to increase.
Software keyboards
The software keyboard, especially on iOS, is notoriously difficult to wrangle. It appears on top of content rather than pushing it, and often causes the entire page to scroll when it opens. The dynamic viewport units also do not adjust when the keyboard opens. We account for this by applying padding-bottom: calc(100dvh - var(--visual-viewport-height)) to the sheet's scrollable content. This allows the user to reach the content at the bottom of the sheet, while it still visually scrolls behind the keyboard.
Thanks to Nikolas Schröter, we also improved our usePreventScroll hook to reduce layout shifts that occur when the keyboard opens and closes. Tapping an input inside a Sheet opens the keyboard and smoothly scrolls the input into view, without scrolling the entire page.
Wrap up
Modern CSS is surprisingly powerful. By combining scroll snapping, view timeline animations, overscroll-behavior, and dynamic viewport units, we've been able to build a sheet component with fully native swipe gestures and fluid animations. This would not have been possible just a few years ago.
Check out the React Aria Sheet component to learn how you can use it in your application or component library!