diff --git a/docs/api/modal.md b/docs/api/modal.md
index 1249e2a813b..2dc192bb1d3 100644
--- a/docs/api/modal.md
+++ b/docs/api/modal.md
@@ -7,6 +7,7 @@ import Methods from '@ionic-internal/component-api/v8/modal/methods.md';
import Parts from '@ionic-internal/component-api/v8/modal/parts.md';
import CustomProps from '@ionic-internal/component-api/v8/modal/custom-props.mdx';
import Slots from '@ionic-internal/component-api/v8/modal/slots.md';
+import SheetDragEvents from '@site/static/usage/v8/modal/sheet/drag-events/index.md';
ion-modal: Ionic Mobile App Custom Modal API Component
@@ -210,6 +211,33 @@ A few things to keep in mind when creating custom dialogs:
* `ion-content` is intended to be used in full-page modals, cards, and sheets. If your custom dialog has a dynamic or unknown size, `ion-content` should not be used.
* Creating custom dialogs provides a way of ejecting from the default modal experience. As a result, custom dialogs should not be used with card or sheet modals.
+## Event Handling
+
+### Using Drag Events
+
+When using card or sheet modals, Ionic emits several events related to the dragging gesture. These events allow developers to perform specific actions or UI updates based on the movement of the modal.
+
+#### Using `ionDragStart`
+
+The `ionDragStart` event is emitted as soon as the user begins a dragging gesture on the modal. This event fires at the moment the user initiates contact with the handle or modal surface, before any actual displacement occurs. It is particularly useful for preparing the interface for a transition, such as blurring background content or disabling certain interactive elements to ensure a smooth dragging experience.
+
+#### Using `ionDragMove`
+
+The `ionDragMove` event is emitted continuously while the user is actively dragging the modal. This event provides a `ModalDragEventDetail` [object](#modaldrageventdetail) containing real-time data:
+
+- `currentY` and `deltaY`: Track the absolute position and the change in distance since the last frame, useful for calculating drag direction.
+- `velocityY`: Measures the speed of the drag, which can be used to trigger specific animations if a user "flicks" the modal.
+- `progress`: A normalized value between 0 and 1 representing how far the modal is open. This is ideal for dynamically adjusting the opacity of an overlay or scaling background content as the modal moves.
+- `predictedBreakpoint`: For sheet modals, this identifies which breakpoint the modal will snap to if released at that moment.
+
+This event is essential for creating highly responsive UI updates that react instantly to the user's touch. For example, the `progress` value can be used to dynamically darken the backdrop's opacity as the modal is dragged upward.
+
+#### Using `ionDragEnd`
+
+The `ionDragEnd` event is emitted when the user completes the dragging gesture by releasing the modal. Like the move event, it includes the final `ModalDragEventDetail` [object](#modaldrageventdetail). This event is commonly used to finalize state changes once the modal has come to a rest. For example, you might use the `predictedBreakpoint` property to determine which content to load or to update the application's routing state once the user has finished swiping the sheet to a specific height.
+
+
+
## Interfaces
### ModalOptions
@@ -251,6 +279,59 @@ interface ModalCustomEvent extends CustomEvent {
}
```
+### ModalDragEventDetail
+
+When using the `ionDragMove` and `ionDragEnd` events, the event detail contains the following properties:
+
+```typescript
+interface ModalDragEventDetail {
+ /**
+ * The current Y position of the modal.
+ *
+ * This can be used to determine how far the modal has been dragged.
+ */
+ currentY: number;
+ /**
+ * The change in Y position since the gesture started.
+ *
+ * This can be used to determine the direction of the drag.
+ */
+ deltaY: number;
+ /**
+ * The velocity of the drag in the Y direction.
+ *
+ * This can be used to determine how fast the modal is being dragged.
+ */
+ velocityY: number;
+ /**
+ * A number between 0 and 1.
+ *
+ * In a sheet modal, progress represents the relative position between
+ * the lowest and highest defined breakpoints.
+ *
+ * In a card modal, it measures the relative position between the
+ * bottom of the screen and the top of the modal when it is fully
+ * open.
+ *
+ * This can be used to style content based on how far the modal has
+ * been dragged.
+ */
+ progress: number;
+ /**
+ * If the modal is a sheet modal, this will be the breakpoint that
+ * the modal will snap to if the user lets go of the modal at the
+ * current moment.
+ *
+ * If it's a card modal, this property will not be included in the
+ * event payload.
+ *
+ * This can be used to style content based on where the modal will
+ * snap to upon release.
+ */
+ predictedBreakpoint?: number;
+}
+```
+
## Accessibility
### Keyboard Interactions
diff --git a/static/code/stackblitz/v8/angular/package.json b/static/code/stackblitz/v8/angular/package.json
index 1b5bd920983..82814bfdfeb 100644
--- a/static/code/stackblitz/v8/angular/package.json
+++ b/static/code/stackblitz/v8/angular/package.json
@@ -15,8 +15,8 @@
"@angular/platform-browser": "^20.0.0",
"@angular/platform-browser-dynamic": "^20.0.0",
"@angular/router": "^20.0.0",
- "@ionic/angular": "8.7.14",
- "@ionic/core": "8.7.14",
+ "@ionic/angular": "8.7.17-dev.11772118942.181221d4",
+ "@ionic/core": "8.7.17-dev.11772118942.181221d4",
"ionicons": "8.0.13",
"rxjs": "^7.8.1",
"tslib": "^2.5.0",
diff --git a/static/code/stackblitz/v8/html/package.json b/static/code/stackblitz/v8/html/package.json
index e0c5262152f..1122bd961da 100644
--- a/static/code/stackblitz/v8/html/package.json
+++ b/static/code/stackblitz/v8/html/package.json
@@ -9,7 +9,7 @@
"start": "vite preview"
},
"dependencies": {
- "@ionic/core": "8.7.14",
+ "@ionic/core": "8.7.17-dev.11772118942.181221d4",
"ionicons": "8.0.13"
},
"devDependencies": {
diff --git a/static/code/stackblitz/v8/react/package.json b/static/code/stackblitz/v8/react/package.json
index bbbf6308c01..41436604bc0 100644
--- a/static/code/stackblitz/v8/react/package.json
+++ b/static/code/stackblitz/v8/react/package.json
@@ -3,8 +3,8 @@
"version": "0.1.0",
"private": true,
"dependencies": {
- "@ionic/react": "8.7.14",
- "@ionic/react-router": "8.7.14",
+ "@ionic/react": "8.7.17-dev.11772118942.181221d4",
+ "@ionic/react-router": "8.7.17-dev.11772118942.181221d4",
"@types/node": "^24.0.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
diff --git a/static/code/stackblitz/v8/vue/package.json b/static/code/stackblitz/v8/vue/package.json
index dfe2750877e..421a8a70e32 100644
--- a/static/code/stackblitz/v8/vue/package.json
+++ b/static/code/stackblitz/v8/vue/package.json
@@ -8,8 +8,8 @@
"preview": "vite preview"
},
"dependencies": {
- "@ionic/vue": "8.7.14",
- "@ionic/vue-router": "8.7.14",
+ "@ionic/vue": "8.7.17-dev.11772118942.181221d4",
+ "@ionic/vue-router": "8.7.17-dev.11772118942.181221d4",
"vue": "^3.2.25",
"vue-router": "5.0.1"
},
diff --git a/static/usage/v8/modal/sheet/drag-events/angular/example_component_html.md b/static/usage/v8/modal/sheet/drag-events/angular/example_component_html.md
new file mode 100644
index 00000000000..491f8d5e215
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/angular/example_component_html.md
@@ -0,0 +1,28 @@
+```html
+
+
+ App
+
+
+
+ Open Sheet Modal
+
+
+
+
+
+ Drag the handle to adjust the modal's opacity based on a custom max opacity.
+
+
+
+
+
+```
diff --git a/static/usage/v8/modal/sheet/drag-events/angular/example_component_ts.md b/static/usage/v8/modal/sheet/drag-events/angular/example_component_ts.md
new file mode 100644
index 00000000000..98fa60ff5cd
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/angular/example_component_ts.md
@@ -0,0 +1,108 @@
+```ts
+import { Component, ElementRef, ViewChild } from '@angular/core';
+import { IonButton, IonContent, IonHeader, IonLabel, IonModal, IonTitle, IonToolbar } from '@ionic/angular/standalone';
+import type { ModalDragEventDetail } from '@ionic/angular/standalone';
+
+@Component({
+ selector: 'app-example',
+ templateUrl: 'example.component.html',
+ standalone: true,
+ imports: [IonButton, IonContent, IonHeader, IonLabel, IonModal, IonTitle, IonToolbar],
+})
+export class ExampleComponent {
+ @ViewChild('modal', { read: ElementRef })
+ modal!: ElementRef;
+
+ private baseOpacity!: number;
+ private readonly MAX_OPACITY = 0.8;
+
+ onDragStart() {
+ const modalEl = this.modal.nativeElement;
+ const style = getComputedStyle(modalEl);
+
+ // Fetch the current variable value
+ this.baseOpacity = parseFloat(style.getPropertyValue('--backdrop-opacity'));
+
+ // Ensure transitions are off during the active drag
+ modalEl.style.transition = 'none';
+ }
+
+ onDragMove(event: CustomEvent) {
+ // `progress` is a value from 1 (top) to 0 (bottom)
+ const { progress } = event.detail;
+ const modalEl = this.modal.nativeElement;
+ const initialBreakpoint = modalEl.initialBreakpoint!;
+ let dynamicOpacity: number;
+
+ /**
+ * Dragging Down: Progress is between 0 and the initial breakpoint
+ */
+ if (progress <= initialBreakpoint) {
+ /**
+ * Calculate how far down the user has dragged from the initial
+ * breakpoint as a ratio
+ */
+ const downwardProgressRatio = progress / initialBreakpoint;
+ /**
+ * Multiplying this by `baseOpacity` ensures that as progress hits 0,
+ * the opacity also hits 0 without a sudden jump
+ */
+ dynamicOpacity = downwardProgressRatio * this.baseOpacity;
+ } else {
+ /**
+ * Dragging Up: Progress is between the initial breakpoint and 1.0
+ */
+
+ /**
+ * Calculate how far up the user has dragged from the initial
+ * breakpoint as a ratio
+ */
+ const distanceFromStart = progress - initialBreakpoint;
+ /**
+ * Calculate the total available space to drag up from the initial
+ * breakpoint to the top (1.0)
+ */
+ const range = 1 - initialBreakpoint;
+ /**
+ * Normalizes that distance into a 0.0 to 1.0 value relative to
+ * the available upward space
+ */
+ const currentGain = distanceFromStart / range;
+
+ // Scale from `baseOpacity` up to `MAX_OPACITY`
+ dynamicOpacity = this.baseOpacity + currentGain * (this.MAX_OPACITY - this.baseOpacity);
+ }
+
+ modalEl.style.setProperty('--backdrop-opacity', dynamicOpacity.toString());
+ }
+
+ onDragEnd(event: CustomEvent) {
+ // `currentBreakpoint` tells us which snap point the modal will animate to after the drag ends
+ const { currentBreakpoint } = event.detail;
+ const modalEl = this.modal.nativeElement;
+
+ /**
+ * If the modal is snapping to the closed state (0), reset the
+ * styles.
+ */
+ if (currentBreakpoint === 0) {
+ modalEl.style.removeProperty('--backdrop-opacity');
+ return;
+ }
+
+ // Determine the target opacity for the snap-back animation
+ let targetOpacity = this.baseOpacity;
+ /**
+ * If snapping to the top (1), set opacity to MAX_OPACITY for a nice
+ * visual effect.
+ */
+ if (currentBreakpoint === 1) {
+ targetOpacity = this.MAX_OPACITY;
+ }
+
+ // Re-enable transition for a smooth snap-back
+ modalEl.style.transition = '--backdrop-opacity 0.3s ease';
+ modalEl.style.setProperty('--backdrop-opacity', targetOpacity.toString());
+ }
+}
+```
diff --git a/static/usage/v8/modal/sheet/drag-events/demo.html b/static/usage/v8/modal/sheet/drag-events/demo.html
new file mode 100644
index 00000000000..2835d936bc4
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/demo.html
@@ -0,0 +1,127 @@
+
+
+
+
+
+ Modal
+
+
+
+
+
+
+
+
+
+
+ App
+
+
+
+ Open Sheet Modal
+
+
+
+
+ Drag the handle to adjust the modal's opacity based on a custom max opacity.
+
+
+
+
+
+
+
+
+
diff --git a/static/usage/v8/modal/sheet/drag-events/index.md b/static/usage/v8/modal/sheet/drag-events/index.md
new file mode 100644
index 00000000000..d947355b9d7
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/index.md
@@ -0,0 +1,28 @@
+import Playground from '@site/src/components/global/Playground';
+
+import javascript from './javascript.md';
+
+import react from './react.md';
+
+import vue from './vue.md';
+
+import angular_example_component_html from './angular/example_component_html.md';
+import angular_example_component_ts from './angular/example_component_ts.md';
+
+
diff --git a/static/usage/v8/modal/sheet/drag-events/javascript.md b/static/usage/v8/modal/sheet/drag-events/javascript.md
new file mode 100644
index 00000000000..00270f7654e
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/javascript.md
@@ -0,0 +1,113 @@
+```html
+
+
+ App
+
+
+
+ Open Sheet Modal
+
+
+
+
+ Drag the handle to adjust the modal's opacity based on a custom max opacity.
+
+
+
+
+
+
+```
diff --git a/static/usage/v8/modal/sheet/drag-events/react.md b/static/usage/v8/modal/sheet/drag-events/react.md
new file mode 100644
index 00000000000..34e62d7d652
--- /dev/null
+++ b/static/usage/v8/modal/sheet/drag-events/react.md
@@ -0,0 +1,132 @@
+```tsx
+import React, { useRef } from 'react';
+import { IonButton, IonModal, IonHeader, IonContent, IonToolbar, IonTitle, IonPage, IonLabel } from '@ionic/react';
+import type { ModalDragEventDetail } from '@ionic/react';
+
+function Example() {
+ const modal = useRef(null);
+ const baseOpacity = useRef(undefined);
+ const MAX_OPACITY = 0.8;
+
+ const onDragStart = () => {
+ const modalEl = modal.current!;
+ const style = getComputedStyle(modalEl);
+
+ // Fetch the current variable value
+ baseOpacity.current = parseFloat(style.getPropertyValue('--backdrop-opacity'));
+
+ // Ensure transitions are off during the active drag
+ modalEl.style.transition = 'none';
+ };
+
+ const onDragMove = (event: CustomEvent) => {
+ // `progress` is a value from 1 (top) to 0 (bottom)
+ const { progress } = event.detail;
+ const modalEl = modal.current!;
+ const initialBreakpoint = modalEl.initialBreakpoint!;
+ let dynamicOpacity: number;
+
+ /**
+ * Dragging Down: Progress is between 0 and the initial breakpoint
+ */
+ if (progress <= initialBreakpoint) {
+ /**
+ * Calculate how far down the user has dragged from the initial
+ * breakpoint as a ratio
+ */
+ const downwardProgressRatio = progress / initialBreakpoint;
+ /**
+ * Multiplying this by `baseOpacity` ensures that as progress hits 0,
+ * the opacity also hits 0 without a sudden jump
+ */
+ dynamicOpacity = downwardProgressRatio * baseOpacity.current!;
+ } else {
+ /**
+ * Dragging Up: Progress is between the initial breakpoint and 1.0
+ */
+
+ /**
+ * Calculate how far up the user has dragged from the initial
+ * breakpoint as a ratio
+ */
+ const distanceFromStart = progress - initialBreakpoint;
+ /**
+ * Calculate the total available space to drag up from the initial
+ * breakpoint to the top (1.0)
+ */
+ const range = 1 - initialBreakpoint;
+ /**
+ * Normalizes that distance into a 0.0 to 1.0 value relative to
+ * the available upward space
+ */
+ const currentGain = distanceFromStart / range;
+
+ // Scale from `baseOpacity` up to `MAX_OPACITY`
+ dynamicOpacity = baseOpacity.current! + currentGain * (MAX_OPACITY - baseOpacity.current!);
+ }
+
+ modalEl.style.setProperty('--backdrop-opacity', dynamicOpacity.toString());
+ };
+
+ const onDragEnd = (event: CustomEvent) => {
+ // `currentBreakpoint` tells us which snap point the modal will animate to after the drag ends
+ const { currentBreakpoint } = event.detail;
+ const modalEl = modal.current!;
+
+ /**
+ * If the modal is snapping to the closed state (0), reset the
+ * styles.
+ */
+ if (currentBreakpoint === 0) {
+ modalEl.style.removeProperty('--backdrop-opacity');
+ return;
+ }
+
+ // Determine the target opacity for the snap-back animation
+ let targetOpacity = baseOpacity.current;
+ /**
+ * If snapping to the top (1), set opacity to MAX_OPACITY for a nice
+ * visual effect.
+ */
+ if (currentBreakpoint === 1) {
+ targetOpacity = MAX_OPACITY;
+ }
+
+ // Re-enable transition for a smooth snap-back
+ modalEl.style.transition = '--backdrop-opacity 0.3s ease';
+ modalEl.style.setProperty('--backdrop-opacity', targetOpacity!.toString());
+ };
+
+ return (
+
+
+
+ App
+
+
+
+
+ Open Sheet Modal
+
+
+
+
+ Drag the handle to adjust the modal's opacity based on a custom max opacity.
+