diff --git a/MIGRATION.md b/MIGRATION.md
new file mode 100644
index 0000000..ca40df5
--- /dev/null
+++ b/MIGRATION.md
@@ -0,0 +1,155 @@
+# Migration: v1 → v2
+
+`@present-day/drawer` v2 collapses the `sizing` prop and `DRAWER_SIZING`
+preset object into a single `snapPoints` array, renames the change callback
+to match Vaul / shadcn drawer, and reuses `SNAP_POINT.FULL` for the `'full'`
+token. The migration is mechanical and codemod-friendly: every change is a
+1:1 rename or a small literal substitution.
+
+## TL;DR find/replace
+
+| Before | After |
+| ----------------------------------------------- | ---------------------------------- |
+| `sizing="auto"` / `sizing={DRAWER_SIZING.AUTO}` | omit (default) or `snapPoints={['auto']}` |
+| `sizing="full"` / `sizing={DRAWER_SIZING.FULL}` | `snapPoints={['full']}` |
+| `sizing={[…]}` | `snapPoints={[…]}` |
+| `onSnapPointChange={fn}` | `setActiveSnapPoint={fn}` |
+| `import { DRAWER_SIZING } from '@present-day/drawer'` | _delete_ (no replacement) |
+| `SnapPointValue` (type) | `SnapPoint` |
+| `DrawerSizing` (type) | `SnapPoint[]` (or just remove the annotation) |
+| `DrawerSizingPreset` (type) | _delete_ (no replacement) |
+| `SNAP_POINT.FULL` (value `0.9`) | `SNAP_POINT.NEAR_FULL` (same `0.9`) **or** `SNAP_POINT.MAX` (`1`) |
+
+The new `SNAP_POINT.FULL` is the string token `'full'` (full available drawer
+height). If you previously relied on `SNAP_POINT.FULL === 0.9`, you almost
+certainly wanted `SNAP_POINT.MAX` — the old name was an artifact of an early
+implementation that capped at 90%.
+
+## Why these changes
+
+**`sizing` and `snapPoints` were doing the same job.** The v1 `sizing` prop
+accepted either a preset string (`'auto'` | `'full'`) or an array of snap
+stops. But `['auto']` already expressed "content-sized" and `['full']`
+already expressed "full-height" — the preset variant was sugar for what the
+array form could already do, and we paid for it with two parallel constants
+(`DRAWER_SIZING` and `SNAP_POINT`) that overlapped on `'auto'`. v2 keeps
+only the array form.
+
+**`onSnapPointChange` is now `setActiveSnapPoint`** to match the Vaul /
+shadcn drawer convention. The signature is a strict superset of theirs —
+we still pass the resolved `index` as a second argument, so existing
+handlers continue to work after the rename.
+
+**`SnapPoint` (renamed from `SnapPointValue`)** is the public type for any
+single snap stop: `number | 'auto' | 'full'`. Use it in your own helpers and
+state when the value can be any of those.
+
+## Step-by-step
+
+### 1. Rename the prop
+
+```tsx
+// before
+
+
+
+
+
+// after
+
+
+
+
+```
+
+`'auto'` is now the default `snapPoints` value, so most call sites can drop
+the prop entirely.
+
+### 2. Rename the callback
+
+```tsx
+// before
+ …} />
+
+// after
+ …} />
+```
+
+The signature is unchanged.
+
+### 3. Update imports and types
+
+```ts
+// before
+import {
+ DRAWER_SIZING,
+ type DrawerSizing,
+ type DrawerSizingPreset,
+ type SnapPointValue,
+} from '@present-day/drawer'
+
+const stops: DrawerSizing = ['auto', 480, 'full']
+const [active, setActive] = useState(0.5)
+
+// after
+import { type SnapPoint } from '@present-day/drawer'
+
+const stops: SnapPoint[] = ['auto', 480, 'full']
+const [active, setActive] = useState(0.5)
+```
+
+### 4. Update `SNAP_POINT.FULL` users
+
+`SNAP_POINT.FULL` used to equal `0.9`. In v2 it equals `'full'`.
+
+```ts
+// before
+ // resolved to 0.9 of available
+
+// after — pick whichever you actually wanted:
+ // same 0.9 behavior
+ // true full height
+ // also true full height (token)
+```
+
+`SNAP_POINT.MAX` (`1`) and `SNAP_POINT.FULL` (`'full'`) both resolve to the
+full available drawer height. The token form composes more cleanly when
+mixing with `'auto'`, since the resolver tags the resulting stop as `'full'`
+in callbacks rather than `1`.
+
+## Behavior notes
+
+- **Default sizing.** `` with no `snapPoints` is now equivalent to
+ the v1 `sizing={DRAWER_SIZING.AUTO}` — content-fit via `ResizeObserver`.
+ No code change needed; this is a good time to remove redundant
+ `sizing="auto"` props.
+- **Single-stop arrays.** `snapPoints={['full']}` reports `'full'` (the
+ string token) as the active raw value in `setActiveSnapPoint` and
+ `getActiveSnapPoint()`. Under v1, `sizing={DRAWER_SIZING.FULL}` reported
+ `1`. If you have code branching on the active raw value, accept both
+ `'full'` and `1` during the upgrade.
+- **`resolveSizingToHeights` (advanced API).** The internal helper exported
+ from `useDrawerSnap` was renamed to `resolveSnapPointsToHeights` and now
+ accepts only `SnapPoint[]`. Pass `['auto']` instead of the v1
+ `DRAWER_SIZING.AUTO`, and `['full']` instead of `DRAWER_SIZING.FULL`.
+
+## Vaul / shadcn drawer parity
+
+After v2 the prop names line up with Vaul:
+
+| Vaul | This package |
+| ------------------------- | --------------------------------- |
+| `snapPoints` | `snapPoints` ✓ |
+| `activeSnapPoint` | `activeSnapPoint` ✓ |
+| `setActiveSnapPoint` | `setActiveSnapPoint` (richer signature: also passes `index`) |
+| `dismissible` | `dismissible` ✓ |
+| `modal` | `modal` ✓ |
+| `open` / `onOpenChange` | `open` / `onOpenChange` ✓ |
+| _(no equivalent)_ | `defaultSnapPoint` |
+| _(no equivalent)_ | `'auto'` token (content-fit) |
+| _(no equivalent)_ | `topInsetPx` |
+
+If you’re migrating away from Vaul, the hot path is renaming
+`` to `` and removing Vaul’s compound parts in favor
+of this package’s `Drawer.Content`, `Drawer.Handle`, etc. The snap-point
+props and the controlled-state setter behave the same.
diff --git a/README.md b/README.md
index a4d767e..93097d8 100644
--- a/README.md
+++ b/README.md
@@ -58,7 +58,7 @@ function AdvancedExample() {
@@ -159,22 +159,24 @@ When the drawer is **open**, it listens to `window.visualViewport` (`resize` and
### Drawer Props
-| Prop | Type | Default | Description |
-| ------------------ | ------------------------- | -------- | --------------------------------- |
-| `open` | `boolean` | - | Controls the open state |
-| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes |
-| `sizing` | `DrawerSizing` | `'auto'` | Snap point configuration |
-| `defaultSnapPoint` | `number` | - | Initial snap point |
-| `dismissible` | `boolean` | `true` | Allow dismissing by dragging down; Escape closes when true |
-| `modal` | `boolean` | `true` | Show overlay and lock body scroll |
-| `focusTrap` | `boolean` | `true` | When `modal`, trap keyboard focus in the panel; set `false` to opt out |
-| `title` | `ReactNode` | - | Screen-reader title (`aria-labelledby`); use for accessible name if you have no visible title |
-| `description` | `ReactNode` | - | Optional; `aria-describedby` |
-| `ariaLabel` | `string` | - | Alternative accessible name if `title` is omitted |
-| `overlayClassName` | `string` | - | Merged with default modal overlay |
-| `slots` | `DrawerSlots` | - | Optional class names for content / handle |
-| `topInsetPx` | `number` | map inset | Pixels subtracted from visual height for snap math |
-| `onViewportChange` | `(viewport: ViewportInfo) => void` | - | Fired when the visual viewport updates while open; includes `layoutBottomInset`, `height`, `offsetTop`, `keyboardHeight`, `isKeyboardOpen` |
+| Prop | Type | Default | Description |
+| --------------------- | ------------------------------------------------- | ---------- | --------------------------------- |
+| `open` | `boolean` | - | Controls the open state |
+| `onOpenChange` | `(open: boolean) => void` | - | Called when open state changes |
+| `snapPoints` | `SnapPoint[]` | `['auto']` | Snap stops; each entry is a fraction (`≤ 1`), pixel value (`> 1`), `'auto'`, or `'full'` |
+| `defaultSnapPoint` | `SnapPoint` | last stop | Initial snap point |
+| `activeSnapPoint` | `SnapPoint` | - | Controlled active snap |
+| `setActiveSnapPoint` | `(point: SnapPoint, index: number) => void` | - | Called when the active snap changes (drag, ref controls, programmatic). Name matches Vaul / shadcn drawer; the `index` is provided as a convenience |
+| `dismissible` | `boolean` | `true` | Allow dismissing by dragging down; Escape closes when true |
+| `modal` | `boolean` | `true` | Show overlay and lock body scroll |
+| `focusTrap` | `boolean` | `true` | When `modal`, trap keyboard focus in the panel; set `false` to opt out |
+| `title` | `ReactNode` | - | Screen-reader title (`aria-labelledby`); use for accessible name if you have no visible title |
+| `description` | `ReactNode` | - | Optional; `aria-describedby` |
+| `ariaLabel` | `string` | - | Alternative accessible name if `title` is omitted |
+| `overlayClassName` | `string` | - | Merged with default modal overlay |
+| `slots` | `DrawerSlots` | - | Optional class names for content / handle |
+| `topInsetPx` | `number` | map inset | Pixels subtracted from visual height for snap math |
+| `onViewportChange` | `(viewport: ViewportInfo) => void` | - | Fired when the visual viewport updates while open; includes `layoutBottomInset`, `height`, `offsetTop`, `keyboardHeight`, `isKeyboardOpen` |
### Components
@@ -185,12 +187,28 @@ When the drawer is **open**, it listens to `window.visualViewport` (`resize` and
## Migration
-Breaking changes: the `BottomSheet` compatibility layer and `BOTTOM_SHEET_*` names are removed. Use the `Drawer` API everywhere, and update any custom CSS or `data-*` hooks that targeted the old names.
+### v1 → v2
+
+The `sizing` prop and `DRAWER_SIZING` constant are removed in favor of a single, array-shaped `snapPoints` prop that aligns with [Vaul](https://vaul.emilkowal.ski) and [shadcn drawer](https://ui.shadcn.com/docs/components/drawer). See [`MIGRATION.md`](./MIGRATION.md) for a full diff and codemod-friendly find/replace list.
+
+Quick summary:
+
+- `sizing="auto"` → omit (now the default) or pass `snapPoints={['auto']}`
+- `sizing="full"` → `snapPoints={['full']}`
+- `sizing={[…]}` → `snapPoints={[…]}`
+- `onSnapPointChange` → `setActiveSnapPoint` (matches Vaul’s controlled-setter convention)
+- `SnapPointValue` type → `SnapPoint`
+- `DrawerSizing` / `DrawerSizingPreset` types removed
+- `SNAP_POINT.FULL` (was `0.9`) → either `SNAP_POINT.NEAR_FULL` (the same `0.9` value) or `SNAP_POINT.MAX` (`1`, full height). The new `SNAP_POINT.FULL` token resolves to `'full'`.
+
+### Earlier: BottomSheet → Drawer
+
+The `BottomSheet` compatibility layer and `BOTTOM_SHEET_*` names were removed in v1. Use the `Drawer` API everywhere, and update any custom CSS or `data-*` hooks that targeted the old names.
- **Components**: `BottomSheet` → `Drawer`, `BottomSheetContent` / `Handle` / `Overlay` / `Scrollable` → the corresponding `Drawer*` exports, composed as `Drawer.Content` and `Drawer`’s other static properties.
- **Context / hooks**: `useBottomSheetContext` → `useDrawerContext`, `useBottomSheetDrag` → `useDrawerDrag`, `useBottomSheetSnap` → `useDrawerSnap`, `useBottomSheetKeyboardSnapMobile` → `useDrawerKeyboardSnapMobile`. (There is no separate `BottomSheetContext` export; the underlying context is `DrawerContext` if you need it in advanced code.)
-- **Types**: all `BottomSheet*` types → the matching `Drawer*` / `*Drawer*` names (e.g. `BottomSheetProps` → `DrawerProps`, `BottomSheetRef` → `DrawerRef`, `BottomSheetSizing` → `DrawerSizing`).
-- **Constants**: `BOTTOM_SHEET_SIZING` → `DRAWER_SIZING`, `BOTTOM_SHEET_TOP_INSET_PX` → `DRAWER_TOP_INSET_PX`, `BOTTOM_SHEET_CONTEXT_CONSUMER` → `DRAWER_CONTEXT_CONSUMER` (kept in source; not re-exported from the package `index` — use `Drawer` and its parts in normal use), `BOTTOM_SHEET_DRAG_SLOP_PX` → `DRAWER_DRAG_SLOP_PX` (internal tuning constant).
+- **Types**: all `BottomSheet*` types → the matching `Drawer*` / `*Drawer*` names (e.g. `BottomSheetProps` → `DrawerProps`, `BottomSheetRef` → `DrawerRef`).
+- **Constants**: `BOTTOM_SHEET_TOP_INSET_PX` → `DRAWER_TOP_INSET_PX`, `BOTTOM_SHEET_CONTEXT_CONSUMER` → `DRAWER_CONTEXT_CONSUMER` (kept in source; not re-exported from the package `index` — use `Drawer` and its parts in normal use), `BOTTOM_SHEET_DRAG_SLOP_PX` → `DRAWER_DRAG_SLOP_PX` (internal tuning constant).
- **Data attributes**: `data-bottom-sheet-scroll` → `data-drawer-scroll`, `data-bottom-sheet-no-drag` → `data-drawer-no-drag`.
- **CSS custom properties** on the motion panel: `--bottom-sheet-height` → `--drawer-height`, `--bottom-sheet-progress` → `--drawer-progress`, `--bottom-sheet-available-height` → `--drawer-available-height`. New: `--drawer-layout-bottom-inset` (visual-viewport bottom anchoring; see *Mobile Safari, soft keyboard* above).
diff --git a/package.json b/package.json
index e49c43d..221928f 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@present-day/drawer",
- "version": "1.2.10",
+ "version": "2.0.0",
"description": "A flexible drawer component with smooth animations and snap points",
"main": "dist/index.js",
"module": "dist/index.mjs",
diff --git a/playground/src/PlaygroundApp.tsx b/playground/src/PlaygroundApp.tsx
index 5630fa6..b6f15bd 100644
--- a/playground/src/PlaygroundApp.tsx
+++ b/playground/src/PlaygroundApp.tsx
@@ -1,11 +1,10 @@
import {
- DRAWER_SIZING,
type DragEndInfo,
Drawer,
type DrawerProps,
type DrawerRef,
SNAP_POINT,
- type SnapPointValue,
+ type SnapPoint,
} from '@present-day/drawer'
import { type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
@@ -84,8 +83,9 @@ function searchWithListContent(kind: 'auto' | 'snaps') {
{kind === 'auto' ? (
<>
Focus the field to show the on-screen keyboard. With{' '}
- sizing=auto, height follows
- content and the sheet should stay aligned above the keyboard.
+ snapPoints=['auto'], height
+ follows content and the sheet should stay aligned above the
+ keyboard.
>
) : (
<>
@@ -170,9 +170,9 @@ function ImperativeDemo({
key="imperative"
open={open}
onOpenChange={onOpenChange}
- sizing={[0.35, 0.6, 0.9]}
+ snapPoints={[0.35, 0.6, 0.9]}
title="Imperative API"
- onSnapPointChange={(p, i) => onLog(`snap: raw=${p} index=${i}`)}
+ setActiveSnapPoint={(p, i) => onLog(`snap: raw=${p} index=${i}`)}
onAnimationComplete={(p) => onLog(`anim done: ${p}`)}
onDragEnd={(_e, info) =>
onLog(
@@ -239,7 +239,7 @@ function ControlledSnapDemo({
onOpenChange: (open: boolean) => void
onLog: (line: string) => void
}) {
- const [active, setActive] = useState(0.3)
+ const [active, setActive] = useState(0.5)
return (
<>
{open ? (
@@ -268,12 +268,12 @@ function ControlledSnapDemo({
key="controlled"
open={open}
onOpenChange={onOpenChange}
- sizing={[0.25, 0.5, 0.75]}
- defaultSnapPoint={0.3}
+ snapPoints={[0.25, 0.5, 0.75]}
+ defaultSnapPoint={0.5}
activeSnapPoint={active}
- onSnapPointChange={(p, i) => {
+ setActiveSnapPoint={(p, i) => {
setActive(p)
- onLog(`onSnapPointChange: raw=${p} i=${i}`)
+ onLog(`setActiveSnapPoint: raw=${p} i=${i}`)
}}
>
{baseContent('Active snap is driven by the buttons in the top-right', [
@@ -314,9 +314,9 @@ function AutoLoadingDemo({
key="auto-loading"
open={open}
onOpenChange={onOpenChange}
- sizing={DRAWER_SIZING.AUTO}
+ snapPoints={['auto']}
title="AUTO and async content"
- onSnapPointChange={(p, i) => onLog(`onSnapPointChange: ${p} i=${i}`)}
+ setActiveSnapPoint={(p, i) => onLog(`setActiveSnapPoint: ${p} i=${i}`)}
onAnimationComplete={(p) =>
onLog(`onAnimationComplete: snap ${String(p)}`)
}
@@ -355,7 +355,7 @@ function AutoLoadingDemo({
}
/**
- * Mixed sizing: `'auto'` as one snap, plus explicit pixel/full stops above it.
+ * Mixed snap points: `'auto'` as one snap, plus explicit pixel/full stops above it.
* Demonstrates the loading→taller transition where the auto stop tracks the
* measured content while the higher stops remain fixed and exceed the content.
*
@@ -399,10 +399,10 @@ function SnapsLoadingTallerThanContentDemo({
// the top stop fills the available drawer area ('full'). Default opens
// at 480px — taller than the loading skeleton, so you can verify the
// 'auto' slot moves in/out beneath the active snap as content settles.
- sizing={[SNAP_POINT.AUTO, 480, DRAWER_SIZING.FULL]}
+ snapPoints={[SNAP_POINT.AUTO, 480, SNAP_POINT.FULL]}
defaultSnapPoint={480}
- title="Mixed sizing: AUTO + pixel + FULL"
- onSnapPointChange={(p, i) => onLog(`onSnapPointChange: ${p} i=${i}`)}
+ title="Mixed snap points: AUTO + pixel + FULL"
+ setActiveSnapPoint={(p, i) => onLog(`setActiveSnapPoint: ${p} i=${i}`)}
onAnimationComplete={(p) =>
onLog(`onAnimationComplete: snap ${String(p)}`)
}
@@ -411,7 +411,7 @@ function SnapsLoadingTallerThanContentDemo({
- sizing=[AUTO, 480, FULL] · skeleton, then taller
+ snapPoints=[AUTO, 480, FULL] · skeleton, then taller
Default opens at 480px. Drag down to land on the AUTO slot
@@ -468,7 +468,7 @@ function getScenarioDrawer(
}
> = {
autoShort: {
- drawer: { sizing: DRAWER_SIZING.AUTO },
+ drawer: { snapPoints: ['auto'] },
children: baseContent('Content-sized (AUTO)', [
A short body — height follows intrinsic content. Resize the window to
@@ -477,23 +477,25 @@ function getScenarioDrawer(
]),
},
autoLong: {
- drawer: { sizing: DRAWER_SIZING.AUTO },
+ drawer: { snapPoints: ['auto'] },
children: longScrollableContent(),
},
autoSearch: {
drawer: {
- sizing: DRAWER_SIZING.AUTO,
+ snapPoints: ['auto'],
title: 'Search',
description: 'AUTO height with a search field — keyboard test on iOS',
},
children: searchWithListContent('auto'),
},
full: {
- drawer: { sizing: DRAWER_SIZING.FULL },
+ drawer: { snapPoints: ['full'] },
children: longScrollableContent(),
},
snapsFractions: {
- drawer: { sizing: [SNAP_POINT.PEEK, SNAP_POINT.HALF, SNAP_POINT.MAX] },
+ drawer: {
+ snapPoints: [SNAP_POINT.PEEK, SNAP_POINT.HALF, SNAP_POINT.MAX],
+ },
children: baseContent('Fractions + px-style constants', [
Values > 1 are read as pixel heights. Try slow drags to land
@@ -511,7 +513,7 @@ function getScenarioDrawer(
},
snapsSearch: {
drawer: {
- sizing: [0.45, 0.75, 0.96],
+ snapPoints: [0.45, 0.75, 0.96],
defaultSnapPoint: 0.75,
title: 'Search',
description: 'Fractional snaps with a search field',
@@ -519,7 +521,7 @@ function getScenarioDrawer(
children: searchWithListContent('snaps'),
},
defaultSnap: {
- drawer: { sizing: [0.2, 0.45, 0.7], defaultSnapPoint: 0.45 },
+ drawer: { snapPoints: [0.2, 0.45, 0.7], defaultSnapPoint: 0.45 },
children: baseContent('Opens at middle (defaultSnapPoint=0.45)', [
Close and re-open: intro animation should target the default stop.
@@ -527,7 +529,7 @@ function getScenarioDrawer(
]),
},
notDismissible: {
- drawer: { sizing: [0.35, 0.65], dismissible: false },
+ drawer: { snapPoints: [0.35, 0.65], dismissible: false },
children: baseContent('Cannot drag-dismiss', [
Dragging below the lowest snap should not close the panel. Use the bar
@@ -544,7 +546,7 @@ function getScenarioDrawer(
]),
},
nonModal: {
- drawer: { modal: false, sizing: DRAWER_SIZING.AUTO },
+ drawer: { modal: false, snapPoints: ['auto'] },
children: baseContent(
'Non-modal (no dimmer, no body lock by default pattern)',
[
@@ -556,7 +558,7 @@ function getScenarioDrawer(
),
},
topInset0: {
- drawer: { topInsetPx: 0, sizing: [0.35, 0.65, 0.92] },
+ drawer: { topInsetPx: 0, snapPoints: [0.35, 0.65, 0.92] },
children: baseContent('topInsetPx = 0', [
Full viewport height is available for snap math (no map chrome
@@ -568,7 +570,7 @@ function getScenarioDrawer(
drawer: {
title: 'Accessible title for screen readers',
description: 'Optional description string linked to the dialog.',
- sizing: [0.4, 0.7],
+ snapPoints: [0.4, 0.7],
},
children: baseContent('Visually plain content', [