@surdeddd/bottom-sheet 0.23.3 → 0.24.0

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