# @surdeddd/bottom-sheet · React React 17+ adapter. Ships a `` component (forwardRef) and a `useBottomSheet()` hook for headless control. ## Install ```bash npm i @surdeddd/bottom-sheet react react-dom ``` ## Component API ```tsx import { BottomSheet, type BottomSheetHandle } from "@surdeddd/bottom-sheet/react"; import "@surdeddd/bottom-sheet/styles"; import { useRef } from "react"; export const App = () => { const ref = useRef(null); return ( Search} leftButton={} rightButton={} screen={} // fades in behind the sheet by progress onChange={(state) => console.log(state.activeId, state.progress)} > ); }; ``` ### Props Every `EngineOption` is forwarded plus: | Prop | Default | Description | | --- | --- | --- | | `backdrop` | `true` | Render the dimmed overlay element | | `closeOnBackdrop` | `true` | Tap-out closes | | `header` | — | Drag handle content (ReactNode) | | `leftButton` / `rightButton` | — | Slots above the sheet | | `screen` | — | Background fading by progress | | `noSSR` | `false` | Skip render on server (Next.js) | | `ariaLabel` / `ariaLabelledBy` | — | Accessible name | | `onChange(state)` | — | Fires on settled snap transitions | ### Imperative handle (`ref`) ```ts type BottomSheetHandle = { snapTo(id: string): Promise; open(id?: string): Promise; close(): Promise; setAllowed(ids: string[], snap?: string): void; setSnapPoints(points: EngineOptions["snapPoints"], allowed?: string[]): void; setScrim(opts: ScrimUpdate): void; setScrimOverlay(opts: ScrimOverlayOptions): () => void; addAnchor(opts: AnchorOptions): () => void; setScrimStages(opts: ScrimStagesOptions | null): () => void; getEngine(): BottomSheetEngine | null; state: EngineState; // settled-only snapshot }; ``` ### Anchors & scrim stages (declarative) ```tsx }, { position: "dock-bottom", node: }, ]} scrimStages={{ stages: [ { for: "peek", node: }, { forRange: [0.5, 1], node: }, ], }} ... /> ``` See [anchors & stages docs](anchors.md) for positions, `showOn`, `fadeRange` and the animation spec. ```ts ``` ## Headless hook For full JSX control, use `useBottomSheet()`: ```tsx import { useBottomSheet } from "@surdeddd/bottom-sheet/react"; const { sheetRef, handleRef, contentRef, backdropRef, screenRef, state, snapTo } = useBottomSheet({ snapPoints, animation: "spring" }); return (
); ``` ## SSR / Next.js The component is SSR-safe by default (no `window` touched at import). For hydration-strict pages, pass `noSSR`: ```tsx ``` Or wrap with `dynamic`: ```tsx const BottomSheet = dynamic( () => import("@surdeddd/bottom-sheet/react").then(m => m.BottomSheet), { ssr: false }, ); ``` ## One-shot construction `useBottomSheet(opts)` reads `opts` once on mount and never re-watches it. Mutating `opts.snapPoints` / `opts.allowed` / `opts.mode` between renders has **no effect** on the engine — this matches React's "uncontrolled state owned by an effect" pattern and avoids whole-engine remount thrash on every render. For runtime updates, use the returned setter methods: ```tsx const sheet = useBottomSheet({ snapPoints, allowed, initial: "min" }); // Replace allow-list at runtime (e.g. lock to "full" while a form is dirty): useEffect(() => { sheet.setAllowed(formDirty ? ["full"] : ["min", "full"]); }, [formDirty, sheet]); // Replace snap-point geometry (respond to viewport changes): sheet.getEngine()?.setSnapPoints(newSnapPoints); ``` Engine recreation (changing `mode`, `animation`, `focusTrap`) requires a keyed remount — wrap the component in a parent with a `key` that flips when those config props change. > Use `getEngine()` rather than the deprecated `engine` field on the return > value: under React Strict Mode the layout effect double-invokes and the > bare `engine` field is briefly `null` for one render between teardown and > remount. ## Performance The hook subscribes to `useSyncExternalStore` only on `snap` / `dragstart` / `dragend` — drag pixels and animation frames don't trigger React renders. For continuous progress in your UI, subscribe imperatively: ```tsx const ref = useRef(null); useEffect(() => { const id = setInterval(() => { if (ref.current) updateMyDOM(ref.current.state.progress); }, 33); return () => clearInterval(id); }, []); ```