Advanced
Animation Internals
react-edge-sheet uses CSS transform transitions for animations — no JavaScript animation libraries, no requestAnimationFrame loops. For animation presets and asymmetric enter/exit transitions, see the Animations guide.
How the enter animation works
- Sheet mounts with the panel off-screen (e.g.
translateY(150%)for bottom) - A
requestAnimationFramecall triggerssetIsEntered(true)— giving the browser one paint to register the off-screen position - CSS transition runs:
transformchanges totranslateY(0)over300ms
The 150% offset (not 100%) prevents a visible pixel flash on some devices when the panel first appears.
Exit animation
close()setsisExiting = true- Transform transitions back to the off-screen value
- The CSS
transitionendevent fires (guarded to only run ontransformon the panel element) - Internal state resets —
isExitingandisOpenboth become false — unmounting the panel
Size Animation
When animateSize={true} (default), the panel smoothly resizes when its content changes height or width. This uses a ResizeObserver on the inner content div.
// Disable size animation for static content:
<Sheet ref={ref} edge="bottom" animateSize={false}>
...
</Sheet>The animated size transition uses:
height(vertical sides: top/bottom)width(horizontal sides: left/right)
with a 0.25s ease transition.
Custom Styling
The library exposes four style layers (outside → inside):
- Overlay —
containerClassName/containerStyle: full-screen portal root (backdrop + panel). Merged with defaults (position: fixed,inset: 0, flex alignment,zIndex). Overridingposition/insetmay break the overlay. - Panel —
className/style: the dialog panel (role="dialog"). - Animate-size wrapper —
innerWrapperClassName/innerWrapperStyle: only whenanimateSizeis true andsnapPointsis not used. Overridingheight,width, oroverflowmay break size animation. - Content —
contentClassName/contentStyle: innermost wrapper around children (ResizeObserver target).
Style the sheet panel
Use the style prop for inline styles on the panel element:
<Sheet
ref={ref}
edge="bottom"
style={{
backgroundColor: 'white',
borderRadius: '1.5rem 1.5rem 0 0',
padding: '2rem',
boxShadow: '0 -4px 40px rgba(0,0,0,0.12)',
}}
>
...
</Sheet>Style with className
Use the className prop with your CSS framework:
// Tailwind CSS
<Sheet ref={ref} edge="right" className="bg-white dark:bg-gray-900 p-6 rounded-l-2xl shadow-2xl">
...
</Sheet>Content wrapper (contentClassName, contentStyle)
The sheet wraps your children in an inner div used for measuring (ResizeObserver). Use contentClassName and contentStyle to style this wrapper — e.g. for overflow, scroll, or padding:
<Sheet ref={ref} contentClassName="overflow-y-auto p-4" contentStyle={{ maxHeight: '70vh' }}>
...
</Sheet>Position alignment
The align prop positions the sheet along the edge:
- Top/bottom:
start(left),center(default),end(right) - Left/right:
start(top),center(default),end(bottom)
// Notifications dropdown in top-right corner
<Sheet ref={ref} edge="top" align="end" maxWidth="400px">
...
</Sheet>
// Sidebar anchored to bottom
<Sheet ref={ref} edge="right" align="end" maxWidth="320px">
...
</Sheet>Limit panel size
maxSize, maxHeight, and maxWidth are optional. When omitted, no max constraint is applied — the panel size is determined by content and parent layout.
Use maxSize for shorthand (max-height for top/bottom, max-width for left/right):
<Sheet ref={ref} edge="bottom" maxSize="60vh">
...
</Sheet>
<Sheet ref={ref} edge="right" maxSize="400px">
...
</Sheet>Backdrop customization
Backdrop blur: Use backdropStyle to override the default blur(20px):
<Sheet ref={ref} backdropStyle={{ backdropFilter: 'blur(8px)' }}>...</Sheet> // lighter
<Sheet ref={ref} backdropStyle={{ backdropFilter: 'none' }}>...</Sheet> // no blur
<Sheet ref={ref} backdropStyle={{ backdropFilter: 'blur(40px)' }}>...</Sheet> // heavyNo backdrop (sheet-only modal):
<Sheet ref={ref} backdrop={false}>
...
</Sheet>Custom backdrop (like gorhom/bottom-sheet): Use backdropComponent to render your own overlay:
<Sheet
ref={ref}
backdropComponent={(props) => (
<div
style={{
position: 'absolute',
inset: 0,
opacity: props.isExiting ? 0 : props.isEntered ? 1 : 0,
background: 'rgba(0,0,0,0.5)',
transition: 'opacity 0.3s',
}}
onClick={props.closeOnBackdropClick ? props.close : undefined}
/>
)}
>
...
</Sheet>Min / max size
Use minSize, minHeight, minWidth for minimum dimensions (mirrors the max API):
<Sheet ref={ref} edge="bottom" minHeight="200px" maxHeight="80vh">...</Sheet>
<Sheet ref={ref} edge="right" minWidth="280px" maxWidth="400px">...</Sheet>Scroll lock
By default, opening the sheet locks body scroll (overflow: hidden). Disable this entirely with scrollLock={false} so the page stays scrollable behind the sheet:
<Sheet ref={ref} scrollLock={false}>
...
</Sheet>When scroll lock is enabled, padding-right is applied by default to match the scrollbar width and avoid layout shift. Control padding only (not the lock itself) with scrollLockPadding:
// Lock scroll but skip padding (may cause layout shift when scrollbar hides)
<Sheet ref={ref} scrollLockPadding={false}>...</Sheet>
// Custom padding while locked
<Sheet ref={ref} scrollLockPadding="1rem">...</Sheet>scrollLockPadding has no effect when scrollLock is false.
Transition overrides
Override the built-in transitions via props:
<Sheet
ref={ref}
transition="transform 0.25s ease-out"
sizeTransition="0.2s ease"
backdropTransition="opacity 0.25s ease-out"
>
...
</Sheet>Accessibility
The sheet panel is rendered with role="dialog" and aria-modal="true". Escape key always closes the sheet, and a focus trap keeps keyboard navigation inside the panel while it is open.
For full details — ARIA props, focus restoration, screen reader guidance — see the Keyboard & Focus guide.
Quick example:
<Sheet ref={ref} edge="bottom" aria-labelledby="sheet-title" aria-describedby="sheet-desc">
<div>
<h2 id="sheet-title">Sheet Title</h2>
<p id="sheet-desc">Description of the sheet content.</p>
</div>
</Sheet>SSR / Next.js
Sheet uses createPortal and accesses document, which makes it incompatible with SSR. Two patterns work:
Pattern 1: Wrap the component using Sheet
// MyDrawer.tsx
import { useRef } from 'react';
import { Sheet, SheetRef } from 'react-edge-sheet';
export default function MyDrawer() {
const ref = useRef<SheetRef>(null);
// ...
}
// page.tsx
import dynamic from 'next/dynamic';
const MyDrawer = dynamic(() => import('./MyDrawer'), { ssr: false });Pattern 2: Dynamic import of Sheet itself
import dynamic from 'next/dynamic';
const Sheet = dynamic(() => import('react-edge-sheet').then((m) => ({ default: m.Sheet })), {
ssr: false,
});Remix
In Remix, use ClientOnly from remix-utils:
import { ClientOnly } from 'remix-utils/client-only';
<ClientOnly>{() => <MySheetComponent />}</ClientOnly>;onOpen / onClose Timing
onOpen fires after the enter animation completes. onClose fires after the exit animation completes — this is when the component unmounts.
If you need to react to the open/close intent (before animation), use onOpenChange:
<Sheet
open={open}
onOpenChange={(nextOpen) => {
// fires immediately when user closes (backdrop click / Escape)
if (!nextOpen) analytics.track('sheet_dismissed');
setOpen(nextOpen);
}}
onOpen={() => {
// fires after enter animation
fetchData();
}}
onClose={() => {
// fires after exit animation — component is about to unmount
cleanup();
}}
>
...
</Sheet>