@surdeddd/bottom-sheet 0.23.4 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +513 -527
  2. package/README.ru.md +533 -545
  3. package/dist/{BottomSheetCore-C5tJYVMY.d.cts → BottomSheetCore-B_r4KCQL.d.cts} +48 -13
  4. package/dist/{BottomSheetCore-DxSrC4QC.d.ts → BottomSheetCore-B_r4KCQL.d.ts} +48 -13
  5. package/dist/{BottomSheetEngine-B3F9qukX.d.cts → BottomSheetEngine-0F7mjyvF.d.cts} +1 -1
  6. package/dist/{BottomSheetEngine-BchzgqGG.d.ts → BottomSheetEngine-CJV_DDrM.d.ts} +1 -1
  7. package/dist/core.cjs +810 -436
  8. package/dist/core.d.cts +2 -3
  9. package/dist/core.d.ts +2 -3
  10. package/dist/core.js +810 -436
  11. package/dist/debug.d.cts +0 -1
  12. package/dist/debug.d.ts +0 -1
  13. package/dist/{default-features-DwVTzDwP.d.ts → default-features-4Xwv6aVt.d.ts} +4 -2
  14. package/dist/{default-features-BGiCSmV1.d.cts → default-features-DX2NOxP6.d.cts} +4 -2
  15. package/dist/element.cjs +1421 -697
  16. package/dist/element.d.cts +11 -3
  17. package/dist/element.d.ts +11 -3
  18. package/dist/element.iife.global.js +2 -2
  19. package/dist/element.js +1421 -697
  20. package/dist/features.cjs +334 -190
  21. package/dist/features.d.cts +3 -4
  22. package/dist/features.d.ts +3 -4
  23. package/dist/features.js +334 -191
  24. package/dist/{index-C7TSQkVK.d.cts → index-2shlxhcG.d.cts} +1 -1
  25. package/dist/{index-CdM1tke5.d.ts → index-CNCRQnpc.d.ts} +1 -1
  26. package/dist/index.cjs +1129 -631
  27. package/dist/index.d.cts +11 -6
  28. package/dist/index.d.ts +11 -6
  29. package/dist/index.js +1129 -632
  30. package/dist/integrations/formik.cjs +21 -10
  31. package/dist/integrations/formik.d.cts +0 -1
  32. package/dist/integrations/formik.d.ts +0 -1
  33. package/dist/integrations/formik.js +22 -11
  34. package/dist/integrations/react-hook-form.cjs +21 -12
  35. package/dist/integrations/react-hook-form.d.cts +0 -1
  36. package/dist/integrations/react-hook-form.d.ts +0 -1
  37. package/dist/integrations/react-hook-form.js +21 -12
  38. package/dist/option-keys-DItuAjUw.d.cts +6 -0
  39. package/dist/option-keys-a5DYNwwO.d.ts +6 -0
  40. package/dist/overlay.cjs +386 -189
  41. package/dist/overlay.d.cts +1 -3
  42. package/dist/overlay.d.ts +1 -3
  43. package/dist/overlay.js +386 -189
  44. package/dist/preact.d.cts +3 -4
  45. package/dist/preact.d.ts +3 -4
  46. package/dist/qwik-lib/index.qwik.cjs +1306 -671
  47. package/dist/qwik-lib/index.qwik.mjs +1306 -671
  48. package/dist/qwik.cjs +1326 -701
  49. package/dist/qwik.d.cts +12 -30
  50. package/dist/qwik.d.ts +12 -30
  51. package/dist/qwik.js +1326 -701
  52. package/dist/react.cjs +1373 -750
  53. package/dist/react.d.cts +26 -12
  54. package/dist/react.d.ts +26 -12
  55. package/dist/react.js +1375 -752
  56. package/dist/solid.cjs +1332 -727
  57. package/dist/solid.d.cts +13 -438
  58. package/dist/solid.d.ts +13 -438
  59. package/dist/solid.js +1334 -729
  60. package/dist/solid.source.jsx +1374 -751
  61. package/dist/styles.css +20 -6
  62. package/dist/svelte-core.cjs +1209 -676
  63. package/dist/svelte-core.js +1207 -677
  64. package/dist/svelte-src/BottomSheet.svelte +101 -58
  65. package/dist/svelte.cjs +1323 -705
  66. package/dist/svelte.d.cts +45 -25
  67. package/dist/svelte.d.ts +45 -25
  68. package/dist/svelte.js +1323 -705
  69. package/dist/vue.cjs +1354 -773
  70. package/dist/vue.d.cts +20 -3
  71. package/dist/vue.d.ts +20 -3
  72. package/dist/vue.js +1354 -773
  73. package/dist/webgl.cjs +159 -53
  74. package/dist/webgl.d.cts +2 -2
  75. package/dist/webgl.d.ts +2 -2
  76. package/dist/webgl.js +159 -53
  77. package/llms.txt +330 -0
  78. package/package.json +42 -20
  79. package/dist/transform-jVfAikE9.d.cts +0 -3
  80. package/dist/transform-jVfAikE9.d.ts +0 -3
package/README.md CHANGED
@@ -1,59 +1,51 @@
1
1
  # @surdeddd/bottom-sheet
2
2
 
3
- Русская версия: [README.ru.md](https://github.com/Surdeddd/BottomSheet/blob/main/README.ru.md)
4
-
5
- > Universal, headless bottom-sheet engine. **One core, eight adapters, GPU-accelerated, fully tested.**
6
-
7
- ![bundle](https://img.shields.io/badge/core-%E2%89%A424%20KB%20gzip%20budget-1a1614?style=flat-square)
8
- ![slim](https://img.shields.io/badge/slim%20core-%E2%89%A419.5%20KB%20gzip-1a1614?style=flat-square)
9
- ![tests](https://img.shields.io/badge/tests-500%2B%20unit-2ea44f?style=flat-square)
10
- ![ts](https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white)
3
+ Bottom sheet, drawer and modal sheet for the web. One gesture engine with snap
4
+ points, spring physics and keyboard accessibility, and the same engine behind
5
+ adapters for **React, Vue 3, Svelte 5, Solid, Qwik, Preact** and a
6
+ `<bottom-sheet>` **custom element** that works in Angular, Lit, Astro or plain
7
+ HTML.
8
+
9
+ [![npm](https://img.shields.io/npm/v/@surdeddd/bottom-sheet?style=flat-square&color=1a1614)](https://www.npmjs.com/package/@surdeddd/bottom-sheet)
10
+ [![downloads](https://img.shields.io/npm/dm/@surdeddd/bottom-sheet?style=flat-square&color=1a1614)](https://www.npmjs.com/package/@surdeddd/bottom-sheet)
11
+ ![core size](https://img.shields.io/badge/core-25.6%20KB%20gzip-1a1614?style=flat-square)
12
+ ![tests](https://img.shields.io/badge/tests-1000%2B%20unit%20%C2%B7%20370%20e2e%20per%20engine-2ea44f?style=flat-square)
13
+ ![types](https://img.shields.io/badge/TypeScript-strict-3178c6?style=flat-square&logo=typescript&logoColor=white)
11
14
  ![license](https://img.shields.io/badge/license-MIT-dc3522?style=flat-square)
12
15
 
13
- A framework-agnostic bottom-sheet primitive. Spring physics, Pointer Events,
14
- GPU-only motion, full keyboard a11y, hardware-back interception, multi-sheet
15
- stacking — and the exact same engine behind every adapter.
16
-
17
- ## 🚀 [Live demo → bottom-sheet-demo-ashy.vercel.app](https://bottom-sheet-demo-ashy.vercel.app)
18
-
19
- 📚 [API reference (typedoc) → surdeddd.github.io/BottomSheet](https://surdeddd.github.io/BottomSheet/)
16
+ **[Live demo](https://bottom-sheet-demo-ashy.vercel.app)** ·
17
+ **[API reference](https://surdeddd.github.io/BottomSheet/)** ·
18
+ [Changelog](CHANGELOG.md) ·
19
+ [Русская версия](https://github.com/Surdeddd/BottomSheet/blob/main/README.ru.md)
20
20
 
21
21
  <p align="center">
22
22
  <picture>
23
23
  <source media="(prefers-color-scheme: dark)" srcset="docs/screenshots/sheet-dark.png" />
24
- <img src="docs/screenshots/sheet-light.png" alt="bottom sheet at a mid snap point over a dimmed page" width="420" />
24
+ <img src="docs/screenshots/sheet-light.png" alt="A bottom sheet at its middle snap point over a dimmed page" width="380" />
25
25
  </picture>
26
+ <img src="https://raw.githubusercontent.com/Surdeddd/BottomSheet/main/docs/gifs/01-drag-to-close.gif" alt="Dragging the sheet between snap points and flicking it closed" width="300" />
26
27
  </p>
27
28
 
28
- <p align="center">
29
- <img src="https://raw.githubusercontent.com/Surdeddd/BottomSheet/main/docs/gifs/01-drag-to-close.gif" alt="drag to close — spring physics" width="380" />
30
- </p>
31
-
32
- <p align="center">
33
- <img src="docs/assets/architecture.svg" alt="One engine core feeding eight adapters — React, Preact, Vue 3, Svelte, Solid, Qwik, Web Component and Vanilla — plus a standalone /overlay subpath" width="720" />
34
- </p>
35
-
36
- ## What it does
37
-
38
- Every item below is verifiable in this repo's `src/`:
39
-
40
- - **8 build targets** — React, Preact, Vue 3, Svelte 5, Solid, Qwik, Web
41
- Component (Lit-compatible `<bottom-sheet>`) and the vanilla core, plus an
42
- `/overlay` subpath (standalone slide-up panel).
43
- - **4 axes** — `bottom`, `top`, `left`, `right` (via `mode`).
44
- - **Spring physics with velocity carry**, plus pointer-type-aware tuning
45
- (separate touch / mouse / pen drag thresholds).
46
- - **Hardware-back interception** on Android — opt-in via `closeOnBack`.
47
- - **`env(safe-area-inset-*)`-aware padding** so action rows clear the home
48
- indicator.
49
- - **WCAG 2.1 AA keyboard slider** — Arrow/Home/End/Esc stepping, focus trap
50
- with restore.
51
- - **Multi-sheet stacking** plus a typed sheet-manager registry.
52
- - **Cancelable `before-snap` / `before-close`** — veto a transition
53
- synchronously.
54
- - Backed by 500+ unit tests across the suite and ~200 Playwright e2e runs
55
- across three browser engines (`mobile-chrome` / `mobile-safari` /
56
- `firefox`).
29
+ ## Why this one
30
+
31
+ - **One engine, every framework.** The component you use in React behaves
32
+ exactly like the one in Vue, Svelte, Solid, Qwik or the custom element,
33
+ because they are thin adapters over the same core.
34
+ - **Snap points in any unit.** Pixels, percentages, `fit` (the content's own
35
+ height) or any CSS length such as `50dvh` or `clamp(200px, 60%, 800px)`.
36
+ - **Four edges.** `bottom`, `top`, `left`, `right`, plus `start` / `end` that
37
+ follow the writing direction.
38
+ - **Feels native.** Spring physics with velocity carry, rubber-band over-drag,
39
+ separate thresholds for touch and mouse, motion on the compositor only.
40
+ - **Accessible.** Keyboard slider on the handle, focus trap with restore,
41
+ `inert` for everything behind a modal sheet, reduced motion, forced colors.
42
+ - **Knows about the phone.** Rests on top of the on-screen keyboard, respects
43
+ safe-area insets, closes on the Back button, locks page scroll the iOS-safe
44
+ way.
45
+ - **Stacks.** Several sheets open at once, each with its own backdrop and focus
46
+ scope.
47
+ - **Small and tree-shakable.** 25.6 KB gzip for the full engine, 21.9 KB for the
48
+ slim core, no runtime dependencies.
57
49
 
58
50
  ## Install
59
51
 
@@ -61,624 +53,618 @@ Every item below is verifiable in this repo's `src/`:
61
53
  npm i @surdeddd/bottom-sheet
62
54
  ```
63
55
 
64
- React / Vue / Svelte are listed as **optional peer deps** — install only
65
- the framework you use, the bundle pays only for that adapter.
56
+ Frameworks are optional peer dependencies: you install only the one you use.
66
57
 
67
58
  ## Quick start
68
59
 
69
- ### React
60
+ ### A modal sheet opened by a button
70
61
 
71
62
  ```tsx
63
+ import { useState } from 'react';
72
64
  import { BottomSheet } from '@surdeddd/bottom-sheet/react';
73
65
  import '@surdeddd/bottom-sheet/styles';
74
66
 
67
+ export function Filters() {
68
+ const [open, setOpen] = useState(false);
69
+
70
+ return (
71
+ <>
72
+ <button onClick={() => setOpen(true)}>Filters</button>
73
+
74
+ <BottomSheet
75
+ open={open}
76
+ onOpenChange={setOpen}
77
+ snapPoints={[
78
+ { id: 'closed', size: 0 },
79
+ { id: 'open', size: 'fit' },
80
+ ]}
81
+ focusTrap
82
+ header={<h2>Filters</h2>}
83
+ >
84
+ <FilterForm />
85
+ </BottomSheet>
86
+ </>
87
+ );
88
+ }
89
+ ```
90
+
91
+ `open` needs one snap point of size `0`: that is the closed state. A tap on the
92
+ backdrop, Escape and a drag down all close the sheet and report it through
93
+ `onOpenChange`.
94
+
95
+ ### A sheet that stays on screen
96
+
97
+ The maps pattern: a strip at the bottom that the user pulls up.
98
+
99
+ ```tsx
75
100
  <BottomSheet
76
101
  snapPoints={[
77
- { id: 'minimized', size: 96 },
102
+ { id: 'peek', size: 96 },
78
103
  { id: 'half', size: '45dvh' },
79
- { id: 'full', size: '85%' },
104
+ { id: 'full', size: '90%' },
80
105
  ]}
81
- initial='minimized'
82
- animation='spring'
83
- spring={{ stiffness: 260, damping: 28 }}
84
- focusTrap
85
- closeOnEscape
86
- header={<h2>Search vehicles</h2>}
106
+ initial="peek"
107
+ header={<h2>Nearby</h2>}
87
108
  >
88
- <YourList />
89
- </BottomSheet>;
109
+ <PlaceList />
110
+ </BottomSheet>
90
111
  ```
91
112
 
92
- [Full React docs →](docs/react.md)
113
+ ### Other frameworks
93
114
 
94
- ### Vue 3
115
+ <details>
116
+ <summary><b>Vue 3</b></summary>
95
117
 
96
118
  ```vue
97
- <script setup>
98
- import { BottomSheet } from '@surdeddd/bottom-sheet/vue';
99
- import '@surdeddd/bottom-sheet/styles';
119
+ <script setup lang="ts">
120
+ import { ref } from 'vue';
121
+ import { BottomSheet } from '@surdeddd/bottom-sheet/vue';
122
+ import '@surdeddd/bottom-sheet/styles';
123
+
124
+ const open = ref(false);
125
+ const points = [
126
+ { id: 'closed', size: 0 },
127
+ { id: 'open', size: 'fit' },
128
+ ];
100
129
  </script>
101
130
 
102
131
  <template>
103
- <BottomSheet
104
- :snap-points="snaps"
105
- initial="minimized"
106
- focus-trap
107
- @snap="onSnap"
108
- >
109
- <template #header><h2>Search</h2></template>
110
- <YourList />
132
+ <button @click="open = true">Filters</button>
133
+ <BottomSheet v-model:open="open" :snap-points="points" focus-trap>
134
+ <template #header><h2>Filters</h2></template>
135
+ <FilterForm />
111
136
  </BottomSheet>
112
137
  </template>
113
138
  ```
114
139
 
115
- [Full Vue docs →](docs/vue.md)
140
+ [Vue guide →](docs/vue.md)
141
+
142
+ </details>
116
143
 
117
- ### Svelte 5
144
+ <details>
145
+ <summary><b>Svelte 5</b></summary>
118
146
 
119
147
  ```svelte
120
148
  <script lang="ts">
121
- import { createBottomSheet } from "@surdeddd/bottom-sheet/svelte";
122
- import "@surdeddd/bottom-sheet/styles";
123
-
124
- let sheetEl: HTMLElement | undefined = $state();
125
- let handleEl: HTMLElement | undefined = $state();
126
- let contentEl: HTMLElement | undefined = $state();
127
-
128
- const ctrl = createBottomSheet({
129
- snapPoints: [{ id: "min", size: 96 }, { id: "full", size: "85%" }],
130
- initial: "min",
131
- });
132
-
133
- $effect(() => {
134
- if (!sheetEl) return;
135
- return ctrl.attach({ element: sheetEl, handle: handleEl, scrollContainer: contentEl });
136
- });
137
- </script>
138
-
139
- <section class="bs-sheet" bind:this={sheetEl} data-mode="bottom">
140
- <div class="bs-handle" bind:this={handleEl}><h2>Search</h2></div>
141
- <div class="bs-content" bind:this={contentEl}><!-- list --></div>
142
- </section>
143
- ```
149
+ import { BottomSheet } from '@surdeddd/bottom-sheet/svelte';
150
+ import '@surdeddd/bottom-sheet/styles';
144
151
 
145
- [Full Svelte docs →](docs/svelte.md)
152
+ let open = $state(false);
153
+ </script>
146
154
 
147
- ### Web Component (any framework)
155
+ <button onclick={() => (open = true)}>Filters</button>
148
156
 
149
- ```html
150
- <link rel="stylesheet" href="/styles.css" />
151
- <script type="module" src="/element.js"></script>
152
-
153
- <bottom-sheet
154
- snap-points='[{"id":"min","size":96},{"id":"full","size":"85%"}]'
155
- initial="min"
156
- animation="spring"
157
- focus-trap="true"
157
+ <BottomSheet
158
+ bind:open
159
+ snapPoints={[{ id: 'closed', size: 0 }, { id: 'open', size: 'fit' }]}
160
+ focusTrap
158
161
  >
159
- <h2 slot="header">Search</h2>
160
- <ul>
161
- ...
162
- </ul>
163
- </bottom-sheet>
162
+ {#snippet header()}<h2>Filters</h2>{/snippet}
163
+ {#snippet children()}<FilterForm />{/snippet}
164
+ </BottomSheet>
164
165
  ```
165
166
 
166
- [Full Web-Component docs →](docs/web-component.md)
167
+ [Svelte guide →](docs/svelte.md)
167
168
 
168
- ### CDN — no bundler, no install
169
+ </details>
169
170
 
170
- Drop-in usage from a static HTML page. The IIFE bundle registers
171
- `<bottom-sheet>` as a side effect — no JS glue required.
171
+ <details>
172
+ <summary><b>Solid</b></summary>
172
173
 
173
- ```html
174
- <!-- CDN, no bundler -->
175
- <link
176
- rel="stylesheet"
177
- href="https://unpkg.com/@surdeddd/bottom-sheet/dist/styles.css"
178
- />
179
- <script src="https://unpkg.com/@surdeddd/bottom-sheet/dist/element.iife.global.js"></script>
180
- <bottom-sheet
181
- snap-points='[{"id":"min","size":96},{"id":"full","size":"85%"}]'
182
- initial="min"
183
- >
184
- <h2 slot="header">Title</h2>
185
- <p>Content</p>
186
- </bottom-sheet>
187
- ```
188
-
189
- ### Vanilla core (no framework)
190
-
191
- ```ts
192
- import { BottomSheetEngine } from '@surdeddd/bottom-sheet';
174
+ ```tsx
175
+ import { createSignal } from 'solid-js';
176
+ import { BottomSheet } from '@surdeddd/bottom-sheet/solid';
193
177
  import '@surdeddd/bottom-sheet/styles';
194
178
 
195
- const engine = new BottomSheetEngine({
196
- element: document.querySelector('.bs-sheet'),
197
- handle: document.querySelector('.bs-handle'),
198
- scrollContainer: document.querySelector('.bs-content'),
199
- backdrop: document.querySelector('.bs-backdrop'),
200
- snapPoints: [
201
- { id: 'min', size: 96 },
202
- { id: 'full', size: '85%' },
203
- ],
204
- animation: 'spring',
205
- });
206
- engine.snapTo('full');
179
+ export function Filters() {
180
+ const [open, setOpen] = createSignal(false);
181
+ return (
182
+ <>
183
+ <button onClick={() => setOpen(true)}>Filters</button>
184
+ <BottomSheet
185
+ open={open()}
186
+ onOpenChange={setOpen}
187
+ snapPoints={[{ id: 'closed', size: 0 }, { id: 'open', size: 'fit' }]}
188
+ focusTrap
189
+ header={<h2>Filters</h2>}
190
+ >
191
+ <FilterForm />
192
+ </BottomSheet>
193
+ </>
194
+ );
195
+ }
207
196
  ```
208
197
 
209
- [Full vanilla docs →](docs/vanilla.md)
210
-
211
- ## API at a glance
198
+ [Solid guide →](docs/solid.md)
212
199
 
213
- ### Snap points
214
-
215
- ```ts
216
- type SnapPoint =
217
- | number // pixels
218
- | `${number}%` // percent of viewport along the axis
219
- | 'fit' // natural height of the whole sheet (handle + header + content + footer)
220
- | 'content' // alias of 'fit'
221
- | 'full' // 100 % of axis
222
- | string; // any CSS length — "50dvh", "clamp(200px, 60%, 800px)"
223
- ```
200
+ </details>
224
201
 
225
- ### Engine options
226
-
227
- | Option | Default | What it does |
228
- | -------------------- | ------------- | -------------------------------------------------------------------------------------------------------------------------- |
229
- | `snapPoints` | required | Ordered list of `{ id, size }` |
230
- | `allowed` | all ids | Subset the sheet may settle on right now |
231
- | `initial` | first allowed | Snap id to start at |
232
- | `mode` | `"bottom"` | `bottom`, `top`, `left`, `right`, or logical `start` / `end`; reactive in React and Vue adapters |
233
- | `animation` | `"spring"` | `spring`, `tween`, `ios-spring`, `material-bounce`, `linear`, `snappy` |
234
- | `settleAnimation` | off | `"waapi"` — settle runs as a compositor-driven Web Animation (pre-sampled spring keyframes); falls back to rAF automatically |
235
- | `features` | all defaults | `EngineFeature[]` — override/extend the composable feature set (see Slim core below) |
236
- | `spring` | snappy | `{ stiffness, damping, mass }` |
237
- | `flickVelocity` | `0.65 px/ms` | Mobile flick threshold |
238
- | `dragThreshold` | `18 px` | Below this, drag snaps back |
239
- | `rubberBand` | `true` | iOS-style soft over-drag |
240
- | `backdropRange` | `[0, 1]` | Progress range over which backdrop fades |
241
- | `screenRange` | `[0, 1]` | Progress range over which the `screen`/scrim content fades |
242
- | `focusTrap` | `false` | Trap Tab focus inside the sheet when open |
243
- | `initialFocus` | first field | Element / selector to focus on open; `false` focuses the sheet container (`tabindex=-1`) instead — avoids the iOS keyboard |
244
- | `closeOnEscape` | `true` | Listen for Escape and close |
245
- | `closeOnBack` | `false` | Intercept Android back button (history.popstate) |
246
- | `closeOnRouteChange` | `false` | Close when the URL changes (patches `pushState`/`replaceState` + popstate) |
247
- | `lockBodyScroll` | `true` | iOS-safe body lock (`position: fixed`) |
248
- | `stackEffect` | `false` | iOS card-stack: back sheets scale down per depth |
249
- | `inertSiblings` | `false` | Mark page siblings `inert` while open (full modal) |
250
- | `persistent` | `false` | Block dismissal (backdrop / Escape / back); programmatic `close()` still works |
251
- | `disableClose` | `false` | Block all closing, including programmatic |
252
- | `disableDrag` | `false` | Suppress the drag gesture (imperative snaps still work) |
253
- | `dragFrom` | `"handle"` | Which regions start a drag: `"handle"`, `"sheet"` (whole sheet), or `"zones"` (only `[data-bs-drag]` subtrees). Defaults to `"sheet"` when no handle is given. `[data-bs-no-drag]` opts a subtree out in every mode. Also `setDragFrom()` / `getDragFrom()` — switching remounts the gesture, no component remount |
254
- | `dragFromContent` | `true` | Whether a touch gesture on the scroll container drags the sheet; override per snap point with `{ id, size, dragFromContent: false }`. Also `setDragFromContent()` |
255
- | `fitContentToSnap` | `false` | Keeps the whole scroll container reachable and the footer on screen at every snap point, not only the largest. Off by default; see [Content reachable at every snap](#content-reachable-at-every-snap) |
256
- | `radius` | token default | Corner radius (`string` CSS length or `number` px); also `setRadius()` |
257
- | `maxHeight` | none | Cap the sheet height. `number` (px) or a string (`"92dvh"`, `"50%"`) re-resolved on viewport / orientation changes |
258
- | `returnFocusTo` | opener | Focus target on dismiss — an `HTMLElement`, a selector `string`, or a `() => HTMLElement` factory |
259
-
260
- ### Anchored elements, docked bars & scrim stages
261
-
262
- Pin UI around the sheet — it rides the motion on the compositor (zero JS per
263
- frame) and shows/hides per state with configurable animations:
202
+ <details>
203
+ <summary><b>Qwik</b></summary>
264
204
 
265
- ```ts
266
- engine.addAnchor({
267
- element: closeBtn,
268
- position: 'sheet-top-right', // rides the sheet edge; 9 screen positions too
269
- showOn: ['half', 'full'], // snap ids or a predicate
270
- animation: 'pop', // fade | scale | slide | pop | custom keyframes
271
- });
272
-
273
- engine.addAnchor({
274
- element: tabBar,
275
- position: 'dock-bottom', // full-width bar; the sheet slides under it
276
- });
205
+ ```tsx
206
+ import { component$, useSignal } from '@builder.io/qwik';
207
+ import { BottomSheet } from '@surdeddd/bottom-sheet/qwik';
208
+ import '@surdeddd/bottom-sheet/styles';
277
209
 
278
- engine.setScrimStages({
279
- stages: [
280
- { for: 'peek', element: teaser },
281
- { forRange: [0.5, 1], element: expanded },
282
- ],
283
- animation: 'fade',
210
+ export const Filters = component$(() => {
211
+ const open = useSignal(false);
212
+ return (
213
+ <>
214
+ <button onClick$={() => (open.value = true)}>Filters</button>
215
+ <BottomSheet
216
+ open={open.value}
217
+ onOpenChange$={next => (open.value = next)}
218
+ snapPoints={[{ id: 'closed', size: 0 }, { id: 'open', size: 'fit' }]}
219
+ focusTrap
220
+ >
221
+ <h2 q:slot="header">Filters</h2>
222
+ <FilterForm />
223
+ </BottomSheet>
224
+ </>
225
+ );
284
226
  });
285
227
  ```
286
228
 
287
- [Full anchors & stages docs →](docs/anchors.md)
229
+ [Qwik guide →](docs/qwik.md)
288
230
 
289
- ### Events
231
+ </details>
290
232
 
291
- ```ts
292
- engine.on('snap', ({ id, size, progress }) => {}); // settled state change
293
- engine.on('open', ({ id }) => {}); // 0 → >0 (enter starts)
294
- engine.on('opened', ({ id }) => {}); // enter settled
295
- engine.on('close', () => {}); // → 0 (exit starts)
296
- engine.on('closed', () => {}); // exit settled
297
- engine.on('dragstart', ({ size }) => {});
298
- engine.on('drag', ({ size, delta }) => {}); // ~60 fps — payload is pooled, don't retain
299
- engine.on('dragend', ({ size, velocity }) => {});
300
- engine.on('progress', ({ value, size }) => {}); // ~60 fps
301
-
302
- // Cancelable — call cancel() synchronously to veto the transition:
303
- engine.on('before-snap', ({ id, size, previousId, cancel }) => {});
304
- engine.on('before-close', ({ reason, cancel }) => {}); // reason: programmatic | backdrop | escape | back
305
- ```
306
-
307
- ### Imperative
308
-
309
- ```ts
310
- engine.snapTo('half');
311
- engine.open(); // → first allowed snap with resolved size > 0
312
- engine.close(reason?); // → first snap that resolves to size 0, else first allowed
313
- engine.expand(); // → largest allowed snap
314
- engine.collapse(); // → smallest allowed snap with size > 0
315
- engine.setAllowed(ids, snapId); // dynamic allowlist
316
- engine.setSnapPoints(points); // re-measure
317
- engine.recompute(); // re-measure a 'fit' / 'content' snap on demand
318
- engine.getResolvedSnaps(); // → [{ id, size }] resolved to pixels right now
319
- engine.setPersistent(bool); // toggle persistence (block dismissal) at runtime
320
- engine.setDisableClose(bool); // toggle close-blocking at runtime
321
- engine.setDisableDrag(bool); // toggle the drag gesture at runtime
322
- engine.setRadius('28px'); // corner radius
323
- engine.setMaxHeight('92dvh'); // height cap (re-resolved on viewport change)
324
- engine.canDismiss(); // false when persistent / disableClose
325
- engine.isTop(); // top of the multi-sheet stack?
326
- engine.depth(); // open sheets above this one
327
- engine.destroy(); // remove listeners + cancel animation
328
- ```
329
-
330
- `initial` starts the sheet **born open** at any snap whose size resolves `> 0`
331
- (no `open` / `opened` event fires — there is no transition to announce). Calling
332
- `open()` / `snapTo()` **synchronously in the mount tick is safe** — no
333
- `requestAnimationFrame` / `nextTick` delay is needed, including `fit` / `content`
334
- and teleported sheets. A sheet mounted hidden (`display:none` ancestor) heals
335
- and emits its open sequence once on reveal. See
336
- [Architecture → Opening at construction](docs/ARCHITECTURE.md).
233
+ <details>
234
+ <summary><b>Custom element</b> (Angular, Lit, Astro, plain HTML)</summary>
337
235
 
338
- ### Scrim runtime API
236
+ ```html
237
+ <script type="module">
238
+ import '@surdeddd/bottom-sheet/element';
239
+ </script>
339
240
 
340
- The scrim is the dim layer behind/around the sheet (`screenComponent`). All
341
- scrim properties can be mutated at runtime — no remount needed.
241
+ <button onclick="document.querySelector('bottom-sheet').open()">Filters</button>
342
242
 
343
- | Method | Purpose |
344
- | --- | --- |
345
- | `setScrimMode('full' \| 'above-sheet' \| 'off')` | Switch positioning at runtime. `'off'` fully disables the scrim layer. |
346
- | `setScrimEnabled(boolean)` | Convenience switch — stashes/restores opacity ranges. |
347
- | `setScrimTapToClose(boolean)` | Install/teardown click-to-close on the scrim. |
348
- | `setScrimColor(color \| null)` / `setScrimBlur(blur \| null)` | Live color & backdrop-filter blur. `null` clears. |
349
- | `setScrimInteractive(boolean)` | Toggle `pointer-events` on the scrim layer. |
350
- | `setBackdropRange([s, e])` / `setScreenRange([s, e])` | Opacity-range mapping (progress 0..1 → opacity 0..1). |
351
- | `setScrim({ ... })` | Batch — applies preset + individual fields in one pass with a single `applySize`. |
352
- | `setScrimOverlay({ children, position, interactive })` | Inject a positioned floating element into the scrim area. Returns a teardown. Useful for monitoring-style "dim everything except this badge" UX. |
353
- | `getScrimState()` | Read-only `{ mode, enabled }` snapshot. Stable public API for introspection without coupling to internals. |
243
+ <bottom-sheet snap-points="closed:0,open:fit" focus-trap="true">
244
+ <h2 slot="header">Filters</h2>
245
+ <form>…</form>
246
+ </bottom-sheet>
247
+ ```
354
248
 
355
- **Presets**: `'subtle' | 'standard' | 'monitoring' | 'cinematic'` — use via `scrimPreset` constructor option or `setScrim({ preset })`.
249
+ The element carries its own styles in its shadow root; no stylesheet is needed.
250
+ Without a bundler, load it from a CDN:
356
251
 
357
- ### Overlay runtime API
252
+ ```html
253
+ <script src="https://unpkg.com/@surdeddd/bottom-sheet/dist/element.iife.global.js"></script>
254
+ ```
358
255
 
359
- The overlay engine (`@surdeddd/bottom-sheet/overlay`) is a standalone slide-up
360
- panel that mirrors the scrim's runtime-mutability story for content.
256
+ [Custom element guide →](docs/web-component.md)
361
257
 
362
- | Method | Purpose |
363
- | --- | --- |
364
- | `setOverlayChildren(node \| fragment \| factory)` | Replace overlay contents at runtime. Pass a `Node`, a `DocumentFragment`, or a `() => Node` factory. |
365
- | `clearOverlayChildren()` | Remove any injected content. |
366
- | `setOverlay({ children, ... })` | Batch update — `children` is one of the accepted fields alongside positioning/preset overrides. |
258
+ </details>
367
259
 
368
- **Presets**: `OverlayPreset` is `'sheet' | 'dialog' | 'sidebar' | 'toast'` —
369
- exposed as the `OVERLAY_PRESETS` const map and accepted by `setOverlay({ preset })`.
260
+ <details>
261
+ <summary><b>No framework</b></summary>
370
262
 
371
- ### Sheet manager (route-based registry)
263
+ ```html
264
+ <div class="bs-root">
265
+ <div class="bs-backdrop"></div>
266
+ <section class="bs-sheet" data-mode="bottom" role="dialog" aria-label="Filters">
267
+ <div class="bs-handle" role="slider" tabindex="0" aria-label="Resize sheet"></div>
268
+ <div class="bs-content">…</div>
269
+ </section>
270
+ </div>
271
+ ```
372
272
 
373
273
  ```ts
374
- import { createSheetManager } from "@surdeddd/bottom-sheet";
274
+ import { BottomSheetEngine } from '@surdeddd/bottom-sheet';
275
+ import '@surdeddd/bottom-sheet/styles';
375
276
 
376
- const sheets = createSheetManager<"home" | "marker" | "panorama">({
377
- home: { snapPoints: [...], allowed: ["min", "half", "full"], onOpen: loadHome },
378
- marker: { snapPoints: [...], allowed: ["half", "full"], onOpen: focusMarker, onClose: blur },
277
+ const root = document.querySelector('.bs-root')!;
278
+ const sheet = new BottomSheetEngine({
279
+ element: root.querySelector('.bs-sheet')!,
280
+ handle: root.querySelector('.bs-handle')!,
281
+ scrollContainer: root.querySelector('.bs-content')!,
282
+ backdrop: root.querySelector('.bs-backdrop')!,
283
+ snapPoints: [
284
+ { id: 'closed', size: 0 },
285
+ { id: 'open', size: 'fit' },
286
+ ],
287
+ initial: 'closed',
379
288
  });
380
289
 
381
- // In your route watcher:
382
- sheets.transition(prev, next);
383
- const cfg = sheets.resolve(currentRoute); // typed!
290
+ document.querySelector('#filters')!.addEventListener('click', () => sheet.open());
384
291
  ```
385
292
 
386
- ## Performance
293
+ [Vanilla guide →](docs/vanilla.md)
387
294
 
388
- Three deliberate choices keep things 60 fps even on mid-range Android:
295
+ </details>
389
296
 
390
- 1. **Transform-based positioning.** The sheet's `height` is set once to the
391
- largest snap; size changes apply via `transform: translate3d(...)`. Stays
392
- on the compositor — zero layout per frame.
393
- 2. **Spring sub-stepping with single DOM write.** Stiff springs integrate at
394
- 240 Hz internally for stability, but the engine writes to the DOM exactly
395
- once per `requestAnimationFrame`.
396
- 3. **Settled-state-only React snapshot.** `useSyncExternalStore` refreshes
397
- only on `snap` / `dragstart` / `dragend`. Drag pixels and animation frames
398
- propagate via CSS variables (`--bs-progress`, `--bs-size`) and direct
399
- imperative subscriptions — no React re-renders on every frame.
297
+ Preact: `@surdeddd/bottom-sheet/preact` is the React adapter running on
298
+ `preact/compat` ([guide](docs/preact.md)). Angular, Lit and Astro use the
299
+ custom element or the engine: [Angular](docs/angular.md), [Lit](docs/lit.md),
300
+ [Astro](docs/astro.md).
400
301
 
401
- ## Slim core & composable features
302
+ ## Concepts
402
303
 
403
- The default `BottomSheetEngine` ships every optional behavior preinstalled.
404
- If you only need the essentials, import the slim core (~17.5 KB gzip vs
405
- ~23 KB) and register just the features you use:
304
+ **Snap points** are the sizes the sheet can rest at. Each has an `id` you refer
305
+ to and a `size`:
406
306
 
407
307
  ```ts
408
- import { BottomSheetCore } from '@surdeddd/bottom-sheet/core';
409
- import { routeFeature, contentSwipeFeature } from '@surdeddd/bottom-sheet/features';
410
-
411
- const engine = new BottomSheetCore({
412
- element, handle, snapPoints,
413
- closeOnBack: true,
414
- features: [routeFeature(), contentSwipeFeature()],
415
- });
308
+ type SnapPoint =
309
+ | number // pixels
310
+ | `${number}%` // share of the viewport along the sheet's axis
311
+ | 'fit' | 'content' // the sheet's own height: handle, header, content, footer
312
+ | 'full' // the whole axis
313
+ | string; // any CSS length: "50dvh", "clamp(200px, 60%, 800px)"
416
314
  ```
417
315
 
418
- Optional features: `routeFeature()` (`closeOnBack` / `routedTo` /
419
- `closeOnRouteChange`), `visualViewportFeature()` (soft-keyboard clamp),
420
- `contentSwipeFeature()`, `persistFeature()`, `autoCollapseFeature()`.
421
- Everything else (gestures, snap math, resize self-heal, a11y slider, focus
422
- trap, scroll lock, scrim, stacking) is core. Setting an option whose feature
423
- is missing dev-warns once and is ignored in production. Custom features
424
- implement `{ name, install(ctx) }` — the same seam the built-ins use.
425
-
426
- ### WebGL renderer (opt-in)
316
+ A point of size `0` is the **closed** state. A sheet without one cannot be
317
+ closed, only collapsed to its smallest point.
318
+
319
+ **Three layers** make up a sheet: the *sheet* itself, the *backdrop* that dims
320
+ the page, and an optional *scrim* (`screen`) for content that fades in behind
321
+ the sheet, such as a map preview or a blurred overlay.
322
+
323
+ **State** is one object: `{ activeId, size, progress, phase, isDragging,
324
+ isAnimating }`. `progress` runs from 0 at the smallest allowed point to 1 at the
325
+ largest. `phase` is `closed`, `opening`, `open` or `closing`.
326
+
327
+ ## API
328
+
329
+ Every option, method and event below works the same in every adapter. Component
330
+ adapters take options as props (`focus-trap` in Vue templates, `focus-trap="true"`
331
+ on the custom element).
332
+
333
+ ### Options
334
+
335
+ Sizing and position
336
+
337
+ | Option | Default | What it does |
338
+ | --- | --- | --- |
339
+ | `snapPoints` | required | List of `{ id, size }` |
340
+ | `allowed` | all ids | The points the sheet may rest at right now |
341
+ | `initial` | first allowed | The point to start at. A point larger than `0` starts the sheet open, with no `open` event |
342
+ | `mode` | `"bottom"` | Edge: `bottom`, `top`, `left`, `right`, or `start` / `end` |
343
+ | `maxHeight` | none | Cap on the sheet's size: pixels, or a CSS length resolved again when the viewport changes |
344
+ | `radius` | `20px` | Corner radius |
345
+ | `fitContentToSnap` | `false` | Keep the whole scroll area and the footer reachable at every point, not only the largest. [Details](docs/content-fit.md) |
346
+
347
+ Motion
348
+
349
+ | Option | Default | What it does |
350
+ | --- | --- | --- |
351
+ | `animation` | `"spring"` | `spring`, `ios-spring`, `material-bounce`, `snappy`, `tween`, `linear` |
352
+ | `spring` | preset | `{ stiffness, damping, mass }` |
353
+ | `duration`, `easing` | `220`, ease-out-back | Length in ms and curve `(t) => number` of the `tween` animation |
354
+ | `settleAnimation` | off | `"waapi"` runs the settle as a compositor-driven Web Animation |
355
+ | `respectReducedMotion` | `true` | Jump straight to the point under `prefers-reduced-motion: reduce` |
356
+ | `viewTransitions` | `false` | Move between points through `document.startViewTransition()` where the browser has it |
357
+
358
+ Gestures
359
+
360
+ | Option | Default | What it does |
361
+ | --- | --- | --- |
362
+ | `dragFrom` | `"handle"` | Where a drag may start: the `handle`, the whole `sheet`, or `zones` (`[data-bs-drag]` subtrees only). `[data-bs-no-drag]` opts a subtree out. A sheet without a handle defaults to `sheet` |
363
+ | `dragFromContent` | `true` | Whether a touch on the scroll area drags the sheet. Can be set per point: `{ id, size, dragFromContent: false }` |
364
+ | `disableDrag` | `false` | Turn the gesture off; methods still work |
365
+ | `rubberBand` | `true` | Soft over-drag past the last point |
366
+ | `flickVelocity` | `0.65` px/ms | Speed at which a touch or pen release counts as a flick. A mouse uses a fixed 0.4 |
367
+ | `dragThreshold` | `18` px | A touch or pen drag shorter than this snaps back. A mouse uses a fixed 12 |
368
+
369
+ Closing
370
+
371
+ | Option | Default | What it does |
372
+ | --- | --- | --- |
373
+ | `closeOnEscape` | `true` | Escape closes the top sheet |
374
+ | `closeOnBack` | `false` | The Back button closes the sheet instead of leaving the page |
375
+ | `routedTo` | none | URL shown while the sheet is open; Back closes it and restores the address |
376
+ | `closeOnRouteChange` | `false` | Close when the URL changes |
377
+ | `persistent` | `false` | The user cannot dismiss the sheet: no backdrop tap, Escape, Back, drag or key closes it. `close()` still does |
378
+ | `disableClose` | `false` | Nothing closes the sheet, `close()` included |
379
+
380
+ Focus and accessibility
381
+
382
+ | Option | Default | What it does |
383
+ | --- | --- | --- |
384
+ | `focusTrap` | `false` | Move focus into the sheet when it opens, keep Tab inside it while it is the top one, and give focus back when it closes. Mark a popup rendered elsewhere with `data-bs-focus-allow` to let focus go there |
385
+ | `initialFocus` | first focusable | With `focusTrap`: the element or selector that gets focus on open. The first focusable element is the handle; `false` focuses the sheet itself |
386
+ | `returnFocusTo` | none | Element, selector or function: where to send focus on close. A focus trap already returns it to where it was |
387
+ | `inertSiblings` | `false` | Make everything outside the sheet `inert` while it is the top one |
388
+ | `lockBodyScroll` | `true` | Lock page scroll while the sheet is open |
389
+
390
+ Backdrop and scrim
391
+
392
+ | Option | Default | What it does |
393
+ | --- | --- | --- |
394
+ | `backdropRange` | `[0, 1]` | The stretch of `progress` over which the backdrop fades in |
395
+ | `backdropOpacity` | `1` | The opacity the backdrop reaches at the end of that stretch |
396
+ | `backdropColor` | `rgb(0 0 0 / 0.5)` | Colour of the backdrop; the same as the `--bs-backdrop-color` token |
397
+ | `screenRange` | `[0, 1]` | The same stretch for the scrim |
398
+ | `scrimMode` | `"full"` | `full` screen, `above-sheet` (stops at the sheet's edge) or `off` |
399
+ | `scrimPreset` | none | `subtle`, `standard`, `monitoring`, `cinematic`: colour, blur and range in one |
400
+ | `scrimColor`, `scrimBlur` | preset | Scrim fill and the radius of its `backdrop-filter` blur |
401
+ | `scrimInteractive` | `false` | The scrim takes pointer input instead of letting it through |
402
+ | `scrimTapToClose` | `false` | A tap on the scrim closes the sheet |
403
+
404
+ More
405
+
406
+ | Option | Default | What it does |
407
+ | --- | --- | --- |
408
+ | `stackEffect` | `false` | Scale the sheets behind the top one, like the iOS card stack |
409
+ | `persistKey` | none | `localStorage` key: the sheet starts where it was left |
410
+ | `autoCollapseAfter` | off | Milliseconds of rest after which the sheet returns to its smallest open point |
411
+ | `linkedSheets` | none | Other engines that collapse when this one opens |
412
+ | `features` | all | Feature list for the [slim core](#slim-core) |
413
+
414
+ Component adapters add a few props of their own:
415
+
416
+ | Prop | Default | What it does |
417
+ | --- | --- | --- |
418
+ | `open`, `onOpenChange` | uncontrolled | Controlled open state. `v-model:open` in Vue, `bind:open` in Svelte, `onOpenChange$` in Qwik, the `open` attribute on the element |
419
+ | `snap` | uncontrolled | The active snap point, as a prop: `v-model:snap` in Vue, `bind:snap` in Svelte, `snap` with `onSnap$` in Qwik, the `snap` attribute on the element. React and Solid use the ref |
420
+ | `header`, `footer` | none | Regions above and below the scroll area (slots in Vue, snippets in Svelte) |
421
+ | `backdrop` | `true` | Render the backdrop |
422
+ | `closeOnBackdrop` | `true` | A tap on the backdrop closes the sheet |
423
+ | `teleportTo` | none | Move the sheet's DOM out of a clipping or transformed ancestor |
424
+ | `ariaLabel` | `"Bottom sheet"` | Accessible name of the sheet |
425
+ | `handleLabel`, `contentLabel` | English | Accessible names of the handle and the scroll area, for apps in another language |
426
+
427
+ ### Methods
427
428
 
428
429
  ```ts
429
- import { webglRenderer } from '@surdeddd/bottom-sheet/webgl';
430
-
431
- const engine = new BottomSheetEngine({
432
- element, handle, snapPoints,
433
- features: [webglRenderer({ jelly: 0.6 })],
434
- });
430
+ sheet.open(); // closed → the first allowed open point; an open sheet stays put
431
+ sheet.open('half'); // to a specific point
432
+ sheet.close(); // → the closed point; without one, the smallest point
433
+ sheet.snapTo('full');
434
+ sheet.expand(); // → the largest allowed point
435
+ sheet.collapse(); // → the smallest allowed open point
436
+
437
+ sheet.setAllowed(['half', 'full']); // change where it may rest
438
+ sheet.setSnapPoints(points); // replace the points
439
+ sheet.recompute(); // re-measure a 'fit' point
440
+ sheet.setMode('right');
441
+ sheet.setPersistent(true);
442
+ sheet.setDisableClose(true);
443
+ sheet.setDisableDrag(true);
444
+ sheet.setRadius('28px');
445
+ sheet.setMaxHeight('92dvh');
446
+ sheet.setBackdropOpacity(0.6);
447
+ sheet.setBackdropColor('rgb(0 0 0 / 0.4)');
448
+
449
+ sheet.state; // { activeId, size, progress, phase, isDragging, isAnimating }
450
+ sheet.getResolvedSnaps(); // [{ id, size }] in pixels right now
451
+ sheet.canDismiss(); // false while persistent or disableClose
452
+ sheet.isTop(); // is it the top sheet of the stack?
453
+ sheet.destroy();
435
454
  ```
436
455
 
437
- Paints the sheet surface — panel, radii, shadow, edge sheen — on the GPU. The
438
- moment a drag starts, the sheet's text is lifted into a texture so the whole
439
- sheet bends as one surface, and handed straight back to the DOM once it
440
- settles. Layout, scrolling, gestures and the accessibility tree never leave the
441
- DOM. 4.2 KB gzip, and only if you import it.
442
- Falls back to the DOM renderer, silently and mid-session if it has to, when
443
- there is no WebGL, under `prefers-reduced-motion`, or when the browser drops
444
- the GL context.
456
+ `open()`, `close()` and `snapTo()` return a promise that resolves when the
457
+ sheet has landed. They are safe to call in the tick the sheet is mounted.
445
458
 
446
- [Full WebGL docs →](docs/webgl.md)
459
+ When `setAllowed()` or `setSnapPoints()` takes the active point away, an open
460
+ sheet moves to the nearest point that keeps it open.
447
461
 
448
- ### Debug overlay
462
+ In components these are on the ref (`ref.current.open()`), on the element
463
+ (`el.open()`), or on the object the composable returns. Anything missing there
464
+ is one call away through `getEngine()`.
449
465
 
450
- ```ts
451
- import { attachDebugOverlay } from '@surdeddd/bottom-sheet/debug';
466
+ ### Events
452
467
 
453
- const detach = attachDebugOverlay(engine);
468
+ ```ts
469
+ sheet.on('open', ({ id }) => {}); // the sheet starts to appear
470
+ sheet.on('opened', ({ id }) => {}); // it has landed
471
+ sheet.on('close', () => {}); // it starts to leave
472
+ sheet.on('closed', () => {}); // it is gone: unmount here
473
+ sheet.on('snap', ({ id, size, progress }) => {}); // it settled on a point
474
+
475
+ sheet.on('dragstart', ({ size }) => {});
476
+ sheet.on('drag', ({ size, delta }) => {}); // every frame
477
+ sheet.on('dragend', ({ size, velocity }) => {});
478
+ sheet.on('progress', ({ value, size }) => {}); // every frame of any motion
479
+
480
+ sheet.on('before-snap', ({ id, previousId, cancel }) => {});
481
+ sheet.on('before-close', ({ reason, cancel }) => {});
454
482
  ```
455
483
 
456
- A fixed dev panel showing the active snap, live size/progress, drag/animate
457
- flags and the resolved snap list — driven purely by public engine events,
458
- shipped as its own entry so it costs nothing in app bundles.
484
+ Call `cancel()` synchronously inside `before-snap` or `before-close` to stop
485
+ the move; `reason` is `programmatic`, `backdrop`, `escape` or `back`.
486
+
487
+ The scroll lock, the focus trap and the history entry go on at `open`. The
488
+ history entry comes off at `close`; the lock and the trap at `closed`, when
489
+ the sheet is gone. A move reversed half-way fires `open` or `close` again, so
490
+ the two always alternate.
491
+
492
+ Components expose these as `onSnap`, `onOpen`… props (React, Solid),
493
+ `@snap`, `@open`… (Vue), `onsnap`… (Svelte), `onSnap$`… (Qwik) and DOM events
494
+ on the custom element.
459
495
 
460
- ### Qwik SSR
496
+ ### The keyboard
461
497
 
462
- The package declares a `qwik` field pointing at
463
- `dist/qwik-lib/index.qwik.mjs`. Qwik apps pick the adapter up automatically
464
- as a vendor root: the consumer optimizer segments it into QRL chunks and
465
- `renderToString` emits a resumable (`q:container="paused"`) sheet. No extra
466
- configuration — `import { BottomSheet } from '@surdeddd/bottom-sheet/qwik'`
467
- keeps working for CSR, and Qwik City SSR now works out of the box.
498
+ The handle is a slider. The arrow that points away from the sheet's edge grows
499
+ it (`ArrowUp` for a bottom sheet, `ArrowLeft` for a right one), the opposite one
500
+ shrinks it, `Home` and `End` jump to the smallest and largest point, `Escape`
501
+ closes.
468
502
 
469
- ## Theming
503
+ ### More of the API
504
+
505
+ | Topic | Guide |
506
+ | --- | --- |
507
+ | Buttons and bars pinned to the sheet's edge, scrim content per state | [Anchors and scrim stages](docs/anchors.md) |
508
+ | Changing the scrim at runtime | [Theming → the scrim](docs/theming.md#the-scrim) |
509
+ | A lighter slide-up panel without snap points | [Overlay](docs/overlay.md) |
510
+ | Sheets configured per route | [Vanilla → sheet manager](docs/vanilla.md#sheet-manager) |
511
+ | Navigating right after a close with `closeOnBack` | [Vanilla → waiting for the history](docs/vanilla.md#waiting-for-the-history) |
512
+ | Moving the sheet from code, frame by frame | [Recipe: programmatic drag](docs/recipes/programmatic-drag.md) |
513
+ | Your own engine extensions | [Plugins](docs/plugins.md) |
514
+ | Right-to-left interfaces | [RTL](docs/rtl.md) |
470
515
 
471
- All visuals are driven by CSS custom properties. Override anywhere:
516
+ ## Styling
517
+
518
+ Everything visual is a CSS custom property. Set them on `.bs-root`, on a parent,
519
+ or on `:root`; they reach into the custom element's shadow root too.
472
520
 
473
521
  ```css
474
522
  .bs-root {
475
523
  --bs-surface: #18181b;
476
- --bs-handle-color: #71717a;
477
524
  --bs-radius: 28px;
478
- --bs-shadow: 0 -8px 30px rgba(0, 0, 0, 0.6);
525
+ --bs-handle-color: #71717a;
526
+ --bs-backdrop-color: rgb(0 0 0 / 0.6);
479
527
  }
480
528
  ```
481
529
 
482
- The engine writes `--bs-progress` (0..1) and `--bs-size` (px) to the sheet
483
- element — drive any CSS animation from drag without touching JS:
530
+ The engine writes `--bs-progress` (0 to 1) and `--bs-size` (pixels) on the
531
+ sheet and on `.bs-root` on every frame, so anything inside the root, such as
532
+ the `screen` layer or a floating button, follows the sheet without JavaScript:
484
533
 
485
534
  ```css
486
- .fab {
487
- transform: scale(calc(1 - var(--bs-progress) * 0.3));
488
- }
489
- .map-canvas {
490
- filter: blur(calc(var(--bs-progress) * 8px));
491
- }
492
- .search-input {
493
- opacity: calc(1 - var(--bs-progress));
494
- }
535
+ .bs-root .map-preview { filter: blur(calc(var(--bs-progress) * 8px)); }
536
+ .bs-root .fab { bottom: calc(var(--bs-size) + 16px); }
495
537
  ```
496
538
 
497
- > **z-index is engine-owned.** The stack controller rewrites inline `z-index` on
498
- > `.bs-sheet`, `.bs-backdrop` and anchor wrappers on every stack change (base
499
- > `100`, step `10` per depth), so a value you set is overwritten and races the
500
- > stack. To position the sheet layer against app UI, move the container
501
- > (teleport target / `.bs-root` parent) or change the open order — don't style
502
- > `z-index` on bs-managed elements. See
503
- > [Architecture → z-index & the sheet stack](docs/ARCHITECTURE.md).
504
-
505
- **Every sheet owns its backdrop.** Open a list, then a detail sheet over it,
506
- and both backdrops stay live: the dimming deepens with each level, and the
507
- sheet underneath stays masked for the whole time the one above it closes, so
508
- taps never reach the page mid-animation. Each backdrop follows its own sheet's
509
- progress, so a buried sheet that closes clears its own scrim. The stack keeps
510
- the order sheets started opening in — a sheet opened while another is still
511
- animating stays on top. If you want a single dim level, pass `backdrop={false}`
512
- (`:backdrop="false"` in Vue) to the sheets you open on top.
513
-
514
- ### Content reachable at every snap
515
-
516
- The sheet is always as tall as its largest snap point and is moved with a
517
- transform; that is what keeps a drag on the compositor. The cost is that below
518
- the largest snap the bottom of the scroll container sits under the screen edge,
519
- by exactly `largest snap − current size`. At a half snap you can scroll, but the
520
- last rows never come into view until the sheet is pulled up.
521
-
522
- `fitContentToSnap` closes that gap:
539
+ Three ready-made looks ship as extra stylesheets:
523
540
 
524
- ```tsx
525
- <BottomSheet snapPoints={points} fitContentToSnap>
526
- <LongList />
527
- </BottomSheet>
528
- ```
529
-
530
- When the sheet settles, the engine writes the hidden amount to
531
- `--bs-content-inset` and the stylesheet shrinks `.bs-content` by exactly that
532
- much, so the scroll container ends at the edge of the screen: the last row, a
533
- focused field and the scrollbar all stay in the visible part, and the thumb is
534
- sized for what you can actually see. The moment the sheet starts to move, it
535
- switches to the full-height container with a spacer of the same size at the end
536
- of the list, so the rows below the edge are already laid out when a drag or an
537
- animation reveals them. The switch changes the box and the scroll range by the
538
- same amount, so nothing on screen moves. That is one layout when motion starts
539
- and one when it settles, none per frame, so the motion stays transform-only.
540
- While the sheet moves, a list that was scrolled to its very end is held in place
541
- on screen instead of riding up with the sheet and dropping back at the end.
542
-
543
- The footer has the same problem and gets the same cure. It is the last thing in
544
- the sheet, so below the largest snap it sits under the screen edge along with
545
- the end of the list. With `fitContentToSnap` on, the shrunken container puts the
546
- footer at the bottom of the visible part of the sheet at rest, and while the
547
- sheet moves the stylesheet lifts `.bs-footer` by `--bs-size − --bs-max-size`, so
548
- it rides that edge on every frame of a drag or an animation, still without a
549
- layout. The list ends above it, not under it. On a snap too short to hold both,
550
- the footer slides under the screen edge instead of covering the handle. The
551
- WebGL renderer gets the same layout; while the sheet moves its footer paints
552
- the surface colour, and a drag lifts the list into a texture that follows the
553
- list's scroll position, so a list held at its end stays put there as well.
554
-
555
- #### Scrolling the list at every snap on touch
556
-
557
- Making the end of the list *reachable* and letting a finger *scroll* to it are
558
- two separate things. By default a swipe that starts on the content drags the
559
- sheet until it reaches its largest snap, and only then scrolls the list — the
560
- native sheet behaviour. On a mouse or trackpad the wheel scrolls at any snap
561
- regardless; on touch it does not. To scroll at a half snap with a finger, hand
562
- content swipes to the list and keep the handle (and header) for moving the
563
- sheet:
564
-
565
- ```tsx
566
- <BottomSheet snapPoints={points} fitContentToSnap dragFromContent={false}>
567
- <LongList />
568
- </BottomSheet>
541
+ ```ts
542
+ import '@surdeddd/bottom-sheet/themes/ios'; // also /material, /vercel
569
543
  ```
570
544
 
571
- `dragFromContent` can also be set per snap point, which keeps the native
572
- pull-to-expand feel at a small peek and frees the list from half upwards:
573
-
574
- ```ts
575
- const points = [
576
- { id: "peek", size: 120 },
577
- { id: "half", size: "50%", dragFromContent: false },
578
- { id: "full", size: "90%", dragFromContent: false },
579
- ];
545
+ ```html
546
+ <div class="bs-theme-ios"><!-- the sheet --></div>
580
547
  ```
581
548
 
582
- It applies to `bottom` sheets and expects the scroll container to be the growing
583
- child of the sheet's flex column, the way `.bs-content` is. For any other
584
- container, `data-bs-fit-content` on the sheet reads `rest` or `moving` and
585
- `--bs-content-inset` holds the hidden amount: shrink the container by it at
586
- rest, or pad the end of its content by it while moving. On the slim core add
587
- `contentFitFeature()` from `@surdeddd/bottom-sheet/features`.
549
+ [All tokens, themes, stacking and the custom element's parts →](docs/theming.md)
588
550
 
589
- ### Scrim, blur & high contrast
551
+ ## Accessibility
590
552
 
591
- | Token | Default | Region |
592
- | ------------------------ | ----------- | ---------------------------------------------------- |
593
- | `--bs-scrim-color` | transparent | Fill of the scrim layer (`.bs-screen`) |
594
- | `--bs-scrim-blur` | `0px` | `backdrop-filter: blur()` applied to the scrim layer |
595
- | `--bs-header-min-height` | `0px` | Minimum height of the `.bs-header` region |
553
+ What is built in, and what you switch on:
596
554
 
597
- `--bs-scrim-blur` drives a real `backdrop-filter` blur on the scrim — set it
598
- for a frosted backdrop (or use the `monitoring` / `cinematic` scrim presets,
599
- which set both color and blur). Under Windows High Contrast / `forced-colors`
600
- the stylesheet ships a `@media (forced-colors: active)` block so the sheet
601
- surface, handle and focus ring stay visible.
555
+ - **The handle is a slider.** It has a label, a current value and arrow-key
556
+ steps, and it is in the tab order. Name it in your language with
557
+ `handleLabel`; `contentLabel` does the same for the scroll area.
558
+ - **The sheet has a name.** `ariaLabel`, or `ariaLabelledBy` pointing at a
559
+ heading. With `focusTrap` it is a `dialog` with `aria-modal`.
560
+ - **Focus.** `focusTrap` moves focus into the sheet, keeps Tab inside it and
561
+ returns focus to the opener on close. In a stack, only the top sheet traps.
562
+ - **The page behind.** `inertSiblings` takes everything outside the top sheet
563
+ out of reach of the keyboard and of screen readers.
564
+ - **Announcements.** Each settle is announced through a polite live region.
565
+ - **Motion and contrast.** `prefers-reduced-motion` turns animations into
566
+ jumps, and `forced-colors` gets a border, a visible handle and a system
567
+ focus ring.
602
568
 
603
- ### Padding & layout
569
+ A modal sheet should have `focusTrap` and `inertSiblings` both on.
604
570
 
605
- Every region's padding is a token — override or zero it to go edge-to-edge.
606
- Defaults match the built-in look. Set the variable on the host / `.bs-root` /
607
- `:root`; custom properties inherit through the Web Component's shadow boundary,
608
- so the same override works for every adapter:
571
+ ## Forms and the on-screen keyboard
609
572
 
610
- | Token | Default | Region |
611
- | ---------------------- | --------------------- | ------------------- |
612
- | `--bs-content-padding` | `0 16px 16px` | scrollable body |
613
- | `--bs-handle-padding` | `8px 16px 4px` | grabber strip (top) |
614
- | `--bs-header-padding` | `0 16px 8px` | header slot |
615
- | `--bs-footer-padding` | `12px 16px (+ inset)` | footer slot |
573
+ A bottom sheet rests on top of the phone keyboard, not under it, so a field
574
+ being typed in stays visible. With `focusTrap`, focus goes to the handle when
575
+ the sheet opens, so the keyboard stays down until the user taps a field;
576
+ `initialFocus="#email"` starts in a field instead.
616
577
 
617
- (The footer default is `12px 16px calc(12px + env(safe-area-inset-bottom))` — it
618
- keeps action buttons clear of the home indicator.)
578
+ To stop a sheet with unsaved input from closing, veto the move:
619
579
 
620
- ```css
621
- .bs-root { --bs-content-padding: 0; } /* full-bleed content */
622
- .bs-root { --bs-content-padding: 0 24px 24px; } /* custom insets */
623
- bottom-sheet { --bs-handle-padding: 0; } /* Web Component, from the host */
580
+ ```ts
581
+ sheet.on('before-close', ({ cancel }) => {
582
+ if (form.isDirty) cancel();
583
+ });
624
584
  ```
625
585
 
626
- A `maxHeight` (prop, or `max-height` attribute on the element) caps the sheet:
627
- a `'content'` / `'fit'` snap taller than the cap settles flush against the edge
628
- and the body scrolls internally — the sheet never lifts off its anchor and
629
- `--bs-size` always equals the rendered height.
586
+ For React Hook Form and Formik this is one line:
630
587
 
631
- ## Demo
588
+ ```ts
589
+ import { useFormGuard } from '@surdeddd/bottom-sheet/integrations/react-hook-form';
632
590
 
633
- ```bash
634
- npm run demo
635
- # http://localhost:5173
591
+ useFormGuard(sheetRef, form); // stays put while submitting, stays open while dirty
636
592
  ```
637
593
 
638
- The editorial demo showcases the adapters, every gesture, every mode
639
- (`bottom · top · left · right · overlay`), and live engine readouts (active
640
- snap, progress, velocity, FPS). EN/RU and light/dark toggles in the corner.
594
+ ## Server rendering
595
+
596
+ Every adapter can be imported and rendered on the server; the engine attaches
597
+ on the client. A sheet that starts closed is hidden in the server markup, so
598
+ nothing flashes before hydration. This covers Next.js, Nuxt, SvelteKit,
599
+ SolidStart, Qwik City and Astro; the per-framework guides have the details,
600
+ such as `"use client"` in the Next.js App Router.
641
601
 
642
- ## Right-to-left
602
+ ## Slim core
643
603
 
644
- `left` and `right` are physical edges and stay physical. For interfaces that
645
- flip with the writing direction, use the logical modes — `start` and `end`
646
- resolve against the direction that applies to the sheet, so `start` is the left
647
- edge in English and the right edge in Arabic:
604
+ The default engine includes every optional behaviour. If size matters more,
605
+ start from the slim core and add the features you use:
648
606
 
649
607
  ```ts
650
- new BottomSheetEngine({ element, handle, mode: 'start', snapPoints });
608
+ import { BottomSheetCore } from '@surdeddd/bottom-sheet/core';
609
+ import { routeFeature, contentSwipeFeature } from '@surdeddd/bottom-sheet/features';
610
+
611
+ const sheet = new BottomSheetCore({
612
+ element, handle, snapPoints,
613
+ closeOnBack: true,
614
+ features: [routeFeature(), contentSwipeFeature()],
615
+ });
651
616
  ```
652
617
 
653
- [Full RTL docs →](docs/rtl.md)
618
+ | Entry | gzip | What is in it |
619
+ | --- | --- | --- |
620
+ | `@surdeddd/bottom-sheet` | 25.6 KB | The full engine |
621
+ | `/core` | 21.9 KB | Gestures, snap points, focus, scroll lock, scrim, stacking |
622
+ | `/features` | 4.2 KB | `routeFeature`, `visualViewportFeature`, `contentSwipeFeature`, `contentFitFeature`, `persistFeature`, `autoCollapseFeature` |
623
+ | `/react` `/vue` `/svelte` `/solid` `/qwik` | 27 to 33 KB | The full engine plus the adapter |
624
+ | `/element` | 33.3 KB | The full engine, the element and its styles |
625
+ | `/overlay` | 8.4 KB | The standalone [overlay](docs/overlay.md) |
626
+ | `/webgl` | 6.4 KB | Optional [GPU renderer](docs/webgl.md) for the sheet surface |
627
+ | `/debug` | 0.9 KB | `attachDebugOverlay(engine)`: a live panel of the engine's state |
628
+
629
+ An option whose feature is missing warns once in development and is ignored.
654
630
 
655
631
  ## Browser support
656
632
 
657
- Baseline: Chrome/Edge 80, Safari 13.1 (iOS 13.4), Firefox 72 — set by the
658
- ES2020 build target and the APIs the engine cannot work without (Pointer
659
- Events, CSS custom properties, `ResizeObserver`). Everything else, from
660
- `inert` to `backdrop-filter` to WebGL, is capability-checked and degrades.
633
+ Chrome and Edge 80, Safari 13.1 (iOS 13.4), Firefox 72. That floor comes from
634
+ the ES2020 build and from the APIs the engine cannot do without: Pointer
635
+ Events, CSS custom properties, `ResizeObserver`. Everything else, from `inert`
636
+ to `backdrop-filter` to WebGL, is detected and degrades.
637
+ [Support matrix →](docs/browser-support.md)
661
638
 
662
- [Full support matrix →](docs/browser-support.md)
639
+ ## Documentation
663
640
 
664
- ## Testing
641
+ | | |
642
+ | --- | --- |
643
+ | Frameworks | [React](docs/react.md) · [Vue](docs/vue.md) · [Svelte](docs/svelte.md) · [Solid](docs/solid.md) · [Qwik](docs/qwik.md) · [Preact](docs/preact.md) · [Custom element](docs/web-component.md) · [Vanilla](docs/vanilla.md) · [Angular](docs/angular.md) · [Lit](docs/lit.md) · [Astro](docs/astro.md) |
644
+ | Topics | [Theming](docs/theming.md) · [Anchors and scrim stages](docs/anchors.md) · [Content at every snap](docs/content-fit.md) · [Overlay](docs/overlay.md) · [RTL](docs/rtl.md) · [WebGL renderer](docs/webgl.md) · [Plugins](docs/plugins.md) |
645
+ | Recipes | [Ten worked examples](docs/recipes/README.md): filter panel, checkout, comment thread, navigation drawer, mini player and more |
646
+ | Examples | [Runnable projects](https://github.com/Surdeddd/BottomSheet/tree/main/examples) for React, Vue, Svelte, Solid, Lit, the custom element and vanilla |
647
+ | Coming from another library | [From vaul or react-modal-sheet](docs/MIGRATION.md) |
648
+ | Project | [Architecture](docs/ARCHITECTURE.md) · [API stability](docs/api-stability.md) · [Browser support](docs/browser-support.md) · [Contributing](CONTRIBUTING.md) |
665
649
 
666
- ```bash
667
- npm test # 500+ unit tests via vitest + happy-dom
668
- npx playwright test # e2e via Playwright on mobile-Chrome / mobile-Safari / Firefox
669
- npm run typecheck # TypeScript --noEmit
670
- ```
650
+ ## For AI coding assistants
671
651
 
672
- CI runs all three across Node 20.19 / 22 on every PR and tag.
652
+ [`llms.txt`](llms.txt) is a compact, complete description of the API written to
653
+ be read by a model: entry points, every option, the method and event names, and
654
+ the mistakes to avoid. It ships inside the npm package, next to this README.
673
655
 
674
656
  ## Contributing
675
657
 
676
- 1. `npm install`
677
- 2. `npm run dev` — tsup watch mode
678
- 3. `npm run demo` — Vite dev server with live demo
679
- 4. `npm test` — vitest watch
680
- 5. Open a PR with a focused diff. The CI matrix gates merging.
658
+ ```bash
659
+ npm install
660
+ npm run demo # the demo site on localhost:5173
661
+ npm test # unit tests
662
+ npm run e2e # browser tests
663
+ ```
664
+
665
+ See [CONTRIBUTING.md](CONTRIBUTING.md). Bugs and ideas go to
666
+ [issues](https://github.com/Surdeddd/BottomSheet/issues).
681
667
 
682
668
  ## License
683
669
 
684
- MIT — see [LICENSE](LICENSE).
670
+ MIT, see [LICENSE](LICENSE).