@mshafiqyajid/react-sheet
Headless hook and styled sheet component for React. Bottom, top, left, and right positions, snap points, swipe-to-dismiss, focus trap, body scroll lock, Escape to close, and smooth spring slide animations. Zero dependencies.
Playground #
Props
TSX
import { SheetStyled } from "@mshafiqyajid/react-sheet/styled";
import "@mshafiqyajid/react-sheet/styles.css";
<SheetStyled
open={open}
onOpenChange={setOpen}
showTitle
showFooter
/>Install #
npm install @mshafiqyajid/react-sheet Quick start #
import { useState } from "react";
import { SheetStyled } from "@mshafiqyajid/react-sheet/styled";
import "@mshafiqyajid/react-sheet/styles.css";
const [open, setOpen] = useState(false);
<button onClick={() => setOpen(true)}>Open sheet</button>
<SheetStyled
open={open}
onOpenChange={setOpen}
title="Options"
footer={<button onClick={() => setOpen(false)}>Done</button>}
>
<p>Sheet content goes here.</p>
</SheetStyled> Side variants #
Set side to control which edge the sheet slides in from. Default is "bottom".
<SheetStyled open={open} onOpenChange={setOpen} side="right" title="Settings">
<p>Slides in from the right.</p>
</SheetStyled>
<SheetStyled open={open} onOpenChange={setOpen} side="top" title="Notifications">
<p>Slides down from the top.</p>
</SheetStyled>
<SheetStyled open={open} onOpenChange={setOpen} side="left" title="Navigation">
<p>Slides in from the left.</p>
</SheetStyled> Snap points #
Pass snapPoints to allow the sheet to snap to intermediate heights. Drag past the last snap point to dismiss.
<SheetStyled
open={open}
onOpenChange={setOpen}
snapPoints={["40vh", "80vh"]}
defaultSnapPoint="40vh"
onSnapPointChange={(snap) => console.log("snapped to", snap)}
>
<p>Drag up to expand to 80 vh, drag past threshold to dismiss.</p>
</SheetStyled> Swipe-to-dismiss #
swipeToDismiss is enabled by default. Set a custom swipeThreshold (pixels) if needed.
<SheetStyled
open={open}
onOpenChange={setOpen}
swipeToDismiss={true}
swipeThreshold={80}
>
<p>Drag more than 80 px to dismiss.</p>
</SheetStyled>
{/* Disable swipe */}
<SheetStyled open={open} onOpenChange={setOpen} swipeToDismiss={false}>
<p>Swipe disabled — use the overlay or Escape to close.</p>
</SheetStyled> Focus management #
Focus is trapped inside the sheet while open and returned to the previously-focused element on close. Override with initialFocusRef and finalFocusRef.
const inputRef = useRef<HTMLInputElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
<button ref={triggerRef} onClick={() => setOpen(true)}>Open</button>
<SheetStyled
open={open}
onOpenChange={setOpen}
initialFocusRef={inputRef}
finalFocusRef={triggerRef}
>
<input ref={inputRef} placeholder="Focused on open" />
</SheetStyled> Lifecycle callbacks #
<SheetStyled
open={open}
onOpenChange={setOpen}
onAfterOpen={() => console.log("sheet fully open")}
onAfterClose={() => console.log("sheet fully closed")}
>
<p>Content</p>
</SheetStyled> Headless #
import { useSheet } from "@mshafiqyajid/react-sheet";
const { isOpen, open, close, sheetProps, overlayProps, handleProps, dragY } = useSheet({
swipeToDismiss: true,
swipeThreshold: 60,
});
<button onClick={open}>Open</button>
{isOpen && (
<div {...overlayProps} style={{ position: "fixed", inset: 0, background: "rgba(0,0,0,0.4)" }}>
<div
{...sheetProps}
style={{
position: "fixed",
bottom: 0, left: 0, right: 0,
background: "#fff",
borderRadius: "16px 16px 0 0",
padding: "1rem",
transform: dragY > 0 ? `translateY(${dragY}px)` : undefined,
}}
>
<div {...handleProps} style={{ width: 40, height: 5, background: "#ccc", borderRadius: 9999, margin: "0 auto 1rem" }} />
<p>Headless sheet with drag tracking.</p>
<button onClick={close}>Close</button>
</div>
</div>
)} API #
| Prop | Type | Default | Description |
|---|---|---|---|
| open | boolean | — | Controlled open state |
| defaultOpen | boolean | false | Uncontrolled default open state |
| onOpenChange | (open: boolean) => void | — | Called when open state changes |
| onAfterOpen | () => void | — | Fires after the open transition completes |
| onAfterClose | () => void | — | Fires after the close transition completes |
| side | "bottom" | "top" | "left" | "right" | "bottom" | Which edge the sheet slides from |
| snapPoints | (string | number)[] | — | Snap heights/widths e.g. ["40vh", "80vh"] |
| defaultSnapPoint | string | number | — | Initial snap point value |
| onSnapPointChange | (snap: string | number) => void | — | Called when the active snap point changes |
| swipeToDismiss | boolean | true | Allow drag-to-dismiss |
| swipeThreshold | number | 50 | Pixel drag distance required to dismiss |
| closeOnOverlayClick | boolean | true | Close when clicking the overlay backdrop |
| closeOnEsc | boolean | true | Close on Escape key |
| showHandle | boolean | true | Show the drag handle pill (hidden on left/right) |
| title | ReactNode | — | Sheet heading — omit to hide the header |
| description | ReactNode | — | Accessible description linked via aria-describedby |
| footer | ReactNode | — | Footer content — omit to hide footer |
| children | ReactNode | — | Scrollable body content |
| initialFocusRef | RefObject<HTMLElement> | — | Element to focus when sheet opens |
| finalFocusRef | RefObject<HTMLElement> | — | Element to focus when sheet closes |
| lockBodyScroll | boolean | true | Lock body scroll while sheet is open |
| container | HTMLElement | null | document.body | Override the portal container |
| className | string | — | Extra class on the panel element |
| style | CSSProperties | — | Inline style on the panel element |
| ref | forwarded | — | Forwarded to the panel div |
Data attributes
| Attribute | Values | Description |
|---|---|---|
| data-side | "bottom" | "top" | "left" | "right" | Current side — on both overlay and panel |
| data-state | "open" | "closed" | Visibility state — on both overlay and panel |
| data-swiping | "true" | undefined | Present on panel while pointer drag is active |
useSheet options & return
| Key | Type | Description |
|---|---|---|
| open | boolean | Controlled open state |
| defaultOpen | boolean | Uncontrolled default (false) |
| onOpenChange | (open: boolean) => void | Open change callback |
| closeOnEsc | boolean | Close on Escape (true) |
| swipeToDismiss | boolean | Track pointer drag (true) |
| swipeThreshold | number | Dismiss threshold in px (50) |
| lockBodyScroll | boolean | Lock body scroll (true) |
| Returns: | ||
| isOpen | boolean | Current open state |
| open | () => void | Open the sheet |
| close | () => void | Close the sheet |
| toggle | () => void | Toggle open/closed |
| sheetProps | SheetProps | Spread onto panel element (role, aria-*, tabIndex, id) |
| overlayProps | OverlayProps | Spread onto overlay element |
| handleProps | HandleProps | Spread onto drag handle element |
| dragY | number | Current drag offset in pixels (0 when idle) |