diff --git a/BREAKING.md b/BREAKING.md
index e48fe76f98d..863a3d09e66 100644
--- a/BREAKING.md
+++ b/BREAKING.md
@@ -28,6 +28,7 @@ This is a comprehensive list of the breaking changes introduced in the major ver
- [Item Divider](#version-9x-item-divider)
- [Menu Toggle](#version-9x-menu-toggle)
- [Radio Group](#version-9x-radio-group)
+ - [Ripple Effect](#version-9x-ripple-effect)
- [Row](#version-9x-row)
- [Spinner](#version-9x-spinner)
- [Text](#version-9x-text)
@@ -352,6 +353,25 @@ If you were targeting the internals of `ion-radio-group` in your CSS, you will n
Additionally, the `radio-group-wrapper` div element has been removed, causing slotted elements to be direct children of the `ion-radio-group`.
+
Ripple Effect
+
+The following breaking changes apply to `ion-ripple-effect`:
+
+1. The previously undocumented `--ripple-opacity` CSS variable has been renamed to `--ion-ripple-effect-opacity`. [1](#version-9x-ripple-effect-opacity-variable)
+2. Theme classes (`ion-ripple-effect.md`, `ion-ripple-effect.ios`) are no longer supported. [2](#version-9x-ripple-effect-theme-classes)
+
+
Opacity variable
+
+The ripple fade opacity is now part of the centralized Ionic Theming system. Use the new token structure for global styles, or the corresponding CSS variable for component-specific overrides:
+
+| Old (8.x) | New token (global) | New CSS variable (component-specific) |
+|---|---|---|
+| `--ripple-opacity` | `IonRippleEffect.opacity` | `--ion-ripple-effect-opacity` |
+
+
Theme classes
+
+Remove any instances that target the theme classes: `ion-ripple-effect.md`, `ion-ripple-effect.ios`.
+
Row
The following breaking changes apply to `ion-row`:
diff --git a/core/api.txt b/core/api.txt
index 36040cb0c13..125b08bd490 100644
--- a/core/api.txt
+++ b/core/api.txt
@@ -2230,9 +2230,9 @@ ion-reorder-group,event,ionReorderStart,void,true
ion-ripple-effect,shadow
ion-ripple-effect,prop,mode,"ios" | "md",undefined,false,false
-ion-ripple-effect,prop,theme,"ios" | "md" | "ionic",undefined,false,false
ion-ripple-effect,prop,type,"bounded" | "unbounded",'bounded',false,false
ion-ripple-effect,method,addRipple,addRipple(x: number, y: number) => Promise<() => void>
+ion-ripple-effect,css-prop,--ion-ripple-effect-opacity
ion-route,none
ion-route,prop,beforeEnter,(() => NavigationHookResult | Promise) | undefined,undefined,false,false
diff --git a/core/src/components.d.ts b/core/src/components.d.ts
index 0e1bafc4319..b6060ae638b 100644
--- a/core/src/components.d.ts
+++ b/core/src/components.d.ts
@@ -36,6 +36,7 @@ import { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/r
import { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
import { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface";
import { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface";
+import { IonRippleEffectType } from "./components/ripple-effect/ripple-effect.interface";
import { NavigationHookCallback } from "./components/route/route-interface";
import { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
import { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
@@ -80,6 +81,7 @@ export { RadioGroupChangeEventDetail, RadioGroupCompareFn } from "./components/r
export { PinFormatter, RangeChangeEventDetail, RangeKnobMoveEndEventDetail, RangeKnobMoveStartEventDetail, RangeValue } from "./components/range/range-interface";
export { RefresherEventDetail, RefresherPullEndEventDetail } from "./components/refresher/refresher-interface";
export { ItemReorderEventDetail, ReorderEndEventDetail, ReorderMoveEventDetail } from "./components/reorder-group/reorder-group-interface";
+export { IonRippleEffectType } from "./components/ripple-effect/ripple-effect.interface";
export { NavigationHookCallback } from "./components/route/route-interface";
export { SearchbarChangeEventDetail, SearchbarInputEventDetail } from "./components/searchbar/searchbar-interface";
export { SegmentChangeEventDetail, SegmentValue } from "./components/segment/segment-interface";
@@ -3318,15 +3320,11 @@ export namespace Components {
* The mode determines the platform behaviors of the component.
*/
"mode"?: "ios" | "md";
- /**
- * The theme determines the visual appearance of the component.
- */
- "theme"?: "ios" | "md" | "ionic";
/**
* Sets the type of ripple-effect: - `bounded`: the ripple effect expands from the user's click position - `unbounded`: the ripple effect expands from the center of the button and overflows the container. NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, while surfaces for unbounded ripples should have it set to visible.
* @default 'bounded'
*/
- "type": 'bounded' | 'unbounded';
+ "type": IonRippleEffectType;
}
interface IonRoute {
/**
@@ -9354,15 +9352,11 @@ declare namespace LocalJSX {
* The mode determines the platform behaviors of the component.
*/
"mode"?: "ios" | "md";
- /**
- * The theme determines the visual appearance of the component.
- */
- "theme"?: "ios" | "md" | "ionic";
/**
* Sets the type of ripple-effect: - `bounded`: the ripple effect expands from the user's click position - `unbounded`: the ripple effect expands from the center of the button and overflows the container. NOTE: Surfaces for bounded ripples should have the overflow property set to hidden, while surfaces for unbounded ripples should have it set to visible.
* @default 'bounded'
*/
- "type"?: 'bounded' | 'unbounded';
+ "type"?: IonRippleEffectType;
}
interface IonRoute {
/**
@@ -11183,7 +11177,7 @@ declare namespace LocalJSX {
"disabled": boolean;
}
interface IonRippleEffectAttributes {
- "type": 'bounded' | 'unbounded';
+ "type": IonRippleEffectType;
}
interface IonRouteAttributes {
"url": string;
diff --git a/core/src/components/button/button.ionic.scss b/core/src/components/button/button.ionic.scss
index 89e9ec27e0d..22344274858 100644
--- a/core/src/components/button/button.ionic.scss
+++ b/core/src/components/button/button.ionic.scss
@@ -49,7 +49,6 @@
--background-hover-opacity: 1;
--background: #{globals.ion-color(primary, base)};
--color: #{globals.ion-color(primary, contrast)};
- --ripple-opacity: var(--background-activated-opacity, 1);
--ripple-color: var(--background-activated);
}
@@ -65,7 +64,6 @@
--background-hover-opacity: 1;
--border-color: #{globals.ion-color(primary, base)};
--color: #{globals.ion-color(primary, base)};
- --ripple-opacity: var(--background-activated-opacity, 1);
--ripple-color: var(--background-activated);
}
@@ -85,13 +83,20 @@
--background-hover: #{globals.ion-color(primary, shade, $subtle: true)};
--background-hover-opacity: 1;
--color: #{globals.ion-color(primary, foreground)};
- --ripple-opacity: var(--background-activated-opacity, 1);
--ripple-color: var(--background-activated);
}
// Ripple Effect
// -------------------------------------------------------------------------------
+// Set via the custom property (not `opacity` directly) because the ripple's
+// opacity is animated in @keyframes, which would override a direct value.
+:host(.button-solid) ion-ripple-effect,
+:host(.button-outline) ion-ripple-effect,
+:host(.button-clear) ion-ripple-effect {
+ --ion-ripple-effect-opacity: var(--background-activated-opacity, 1);
+}
+
:host(.button-solid.ion-color) ion-ripple-effect {
color: globals.current-color(shade);
}
diff --git a/core/src/components/ripple-effect/ripple-effect.common.scss b/core/src/components/ripple-effect/ripple-effect.common.scss
deleted file mode 100644
index ed5fc4b9b85..00000000000
--- a/core/src/components/ripple-effect/ripple-effect.common.scss
+++ /dev/null
@@ -1,79 +0,0 @@
-@import "../../themes/native/native.globals";
-
-// Material Design Ripple Effect
-// --------------------------------------------------
-
-$scale-duration: 225ms;
-$fade-in-duration: 75ms;
-$fade-out-duration: 150ms;
-
-:host {
- @include position(0, 0, 0, 0);
-
- position: absolute;
-
- contain: strict;
- pointer-events: none;
-}
-
-:host(.unbounded) {
- contain: layout size style;
-}
-
-.ripple-effect {
- @include border-radius(50%);
-
- position: absolute;
-
- // Should remain static for performance reasons
- background-color: currentColor;
- color: inherit;
-
- contain: strict;
- opacity: 0;
- animation: $scale-duration rippleAnimation forwards, $fade-in-duration fadeInAnimation forwards;
-
- will-change: transform, opacity;
- pointer-events: none;
-}
-
-.fade-out {
- transform: translate(var(--translate-end)) scale(var(--final-scale, 1));
- animation: $fade-out-duration fadeOutAnimation forwards;
-}
-
-@keyframes rippleAnimation {
- from {
- animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
-
- transform: scale(1);
- }
-
- to {
- transform: translate(var(--translate-end)) scale(var(--final-scale, 1));
- }
-}
-
-@keyframes fadeInAnimation {
- from {
- animation-timing-function: linear;
-
- opacity: 0;
- }
-
- to {
- opacity: 0.16;
- }
-}
-
-@keyframes fadeOutAnimation {
- from {
- animation-timing-function: linear;
-
- opacity: 0.16;
- }
-
- to {
- opacity: 0;
- }
-}
diff --git a/core/src/components/ripple-effect/ripple-effect.interface.ts b/core/src/components/ripple-effect/ripple-effect.interface.ts
new file mode 100644
index 00000000000..02c149f37f4
--- /dev/null
+++ b/core/src/components/ripple-effect/ripple-effect.interface.ts
@@ -0,0 +1,6 @@
+export type IonRippleEffectRecipe = {
+ opacity?: string;
+};
+
+export const ION_RIPPLE_EFFECT_TYPES = ['bounded', 'unbounded'] as const;
+export type IonRippleEffectType = (typeof ION_RIPPLE_EFFECT_TYPES)[number];
diff --git a/core/src/components/ripple-effect/ripple-effect.ionic.scss b/core/src/components/ripple-effect/ripple-effect.ionic.scss
deleted file mode 100644
index fd855b56cbf..00000000000
--- a/core/src/components/ripple-effect/ripple-effect.ionic.scss
+++ /dev/null
@@ -1,49 +0,0 @@
-@use "./ripple-effect.common";
-@use "../../themes/ionic/ionic.globals.scss" as globals;
-
-// Ionic Ripple Effect
-// --------------------------------------------------
-
-.ripple-effect {
- animation-name: rippleAnimation, fadeInAnimation;
-}
-
-.fade-out {
- animation-name: fadeOutAnimation;
-}
-
-@keyframes rippleAnimation {
- from {
- animation-timing-function: globals.$ion-transition-curve-expressive;
-
- transform: scale(1);
- }
-
- to {
- transform: translate(var(--translate-end)) scale(var(--final-scale, 1));
- }
-}
-
-@keyframes fadeInAnimation {
- from {
- animation-timing-function: linear;
-
- opacity: 0;
- }
-
- to {
- opacity: var(--ripple-opacity, 0.16);
- }
-}
-
-@keyframes fadeOutAnimation {
- from {
- animation-timing-function: linear;
-
- opacity: var(--ripple-opacity, 0.16);
- }
-
- to {
- opacity: 0;
- }
-}
diff --git a/core/src/components/ripple-effect/ripple-effect.scss b/core/src/components/ripple-effect/ripple-effect.scss
new file mode 100644
index 00000000000..a065c29f370
--- /dev/null
+++ b/core/src/components/ripple-effect/ripple-effect.scss
@@ -0,0 +1,94 @@
+@use "../../themes/mixins" as mixins;
+
+// Ripple Effect: Common Styles
+// --------------------------------------------------
+
+/*
+ * Duration of the ripple scale animation in milliseconds. This MUST stay in
+ * sync with SCALE_DURATION in ripple-effect.tsx: the ripple cleanup is
+ * scheduled off this value, so changing one without the other desyncs the
+ * animation from the removal timing.
+ */
+$scale-duration: 225ms;
+$fade-in-duration: 75ms;
+$fade-out-duration: 150ms;
+
+:host {
+ /**
+ * @prop --ion-ripple-effect-opacity: Peak opacity the ripple fades in to.
+ */
+
+ @include mixins.position(0, 0, 0, 0);
+
+ position: absolute;
+
+ contain: strict;
+ pointer-events: none;
+}
+
+:host(.unbounded) {
+ contain: layout size style;
+}
+
+.ripple-effect {
+ @include mixins.border-radius(50%);
+
+ position: absolute;
+
+ /*
+ * Keep `background-color` and `color` as static keywords (never `var()`)
+ * for performance issues. A new .ripple-effect div is created and animated
+ * on every tap, so a custom property lookup for every ripple would be
+ * costly. The color is inherited from its host.
+ */
+ background-color: currentColor;
+ color: inherit;
+
+ contain: strict;
+ opacity: 0;
+ animation: $scale-duration rippleAnimation forwards, $fade-in-duration fadeInAnimation forwards;
+
+ will-change: transform, opacity;
+ pointer-events: none;
+}
+
+.fade-out {
+ transform: translate(var(--internal-translate-end)) scale(var(--internal-final-scale, 1));
+ animation: $fade-out-duration fadeOutAnimation forwards;
+}
+
+@keyframes rippleAnimation {
+ from {
+ animation-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
+
+ transform: scale(1);
+ }
+
+ to {
+ transform: translate(var(--internal-translate-end)) scale(var(--internal-final-scale, 1));
+ }
+}
+
+@keyframes fadeInAnimation {
+ from {
+ animation-timing-function: linear;
+
+ opacity: 0;
+ }
+
+ to {
+ opacity: var(--ion-ripple-effect-opacity, 0.16);
+ }
+}
+
+@keyframes fadeOutAnimation {
+ from {
+ animation-timing-function: linear;
+
+ opacity: var(--ion-ripple-effect-opacity, 0.16);
+ }
+
+ to {
+ opacity: 0;
+ }
+}
diff --git a/core/src/components/ripple-effect/ripple-effect.tsx b/core/src/components/ripple-effect/ripple-effect.tsx
index 11d48b55db0..8c132418cf3 100644
--- a/core/src/components/ripple-effect/ripple-effect.tsx
+++ b/core/src/components/ripple-effect/ripple-effect.tsx
@@ -1,19 +1,14 @@
import type { ComponentInterface } from '@stencil/core';
import { Component, Element, Host, Method, Prop, h, readTask, writeTask } from '@stencil/core';
-import { getIonTheme } from '../../global/ionic-global';
+import type { IonRippleEffectType } from './ripple-effect.interface';
/**
* @virtualProp {"ios" | "md"} mode - The mode determines the platform behaviors of the component.
- * @virtualProp {"ios" | "md" | "ionic"} theme - The theme determines the visual appearance of the component.
*/
@Component({
tag: 'ion-ripple-effect',
- styleUrls: {
- ios: 'ripple-effect.common.scss',
- md: 'ripple-effect.common.scss',
- ionic: 'ripple-effect.ionic.scss',
- },
+ styleUrl: 'ripple-effect.scss',
shadow: true,
})
export class RippleEffect implements ComponentInterface {
@@ -28,7 +23,7 @@ export class RippleEffect implements ComponentInterface {
* NOTE: Surfaces for bounded ripples should have the overflow property set to hidden,
* while surfaces for unbounded ripples should have it set to visible.
*/
- @Prop() type: 'bounded' | 'unbounded' = 'bounded';
+ @Prop() type: IonRippleEffectType = 'bounded';
/**
* Adds the ripple effect to the parent element.
@@ -60,14 +55,24 @@ export class RippleEffect implements ComponentInterface {
const moveY = height * 0.5 - posY;
writeTask(() => {
+ /*
+ * The host may have been removed from the DOM between the read and write
+ * tasks. Resolve with a no-op so callers awaiting `addRipple` never hang,
+ * and skip appending a ripple to a detached host.
+ */
+ if (!this.el.isConnected) {
+ resolve(noop);
+ return;
+ }
+
const div = document.createElement('div');
div.classList.add('ripple-effect');
const style = div.style;
style.top = styleY + 'px';
style.left = styleX + 'px';
style.width = style.height = initialSize + 'px';
- style.setProperty('--final-scale', `${finalScale}`);
- style.setProperty('--translate-end', `${moveX}px, ${moveY}px`);
+ style.setProperty('--internal-final-scale', `${finalScale}`);
+ style.setProperty('--internal-translate-end', `${moveX}px, ${moveY}px`);
const container = this.el.shadowRoot || this.el;
container.appendChild(div);
@@ -75,7 +80,7 @@ export class RippleEffect implements ComponentInterface {
resolve(() => {
removeRipple(div);
});
- }, 225 + 100);
+ }, SCALE_DURATION + 100);
});
});
});
@@ -86,12 +91,10 @@ export class RippleEffect implements ComponentInterface {
}
render() {
- const theme = getIonTheme(this);
return (
@@ -106,5 +109,16 @@ const removeRipple = (ripple: HTMLElement) => {
}, 200);
};
+// Callable no-op cleanup returned by addRipple when there is nothing to remove
+const noop = () => undefined;
+
const PADDING = 10;
const INITIAL_ORIGIN_SCALE = 0.5;
+
+/*
+ * Duration of the ripple scale animation in milliseconds. This MUST stay in
+ * sync with $scale-duration in ripple-effect.scss: the ripple cleanup is
+ * scheduled off this value, so changing one without the other desyncs the
+ * animation from the removal timing.
+ */
+const SCALE_DURATION = 225;
diff --git a/core/src/components/ripple-effect/test/basic/index.html b/core/src/components/ripple-effect/test/basic/index.html
index d9016411e73..332d3806d90 100644
--- a/core/src/components/ripple-effect/test/basic/index.html
+++ b/core/src/components/ripple-effect/test/basic/index.html
@@ -20,7 +20,6 @@
color: white;
width: 300px;
height: 100px;
- margin: 1rem;
}
.block {
@@ -31,7 +30,30 @@
width: 300px;
height: 300px;
border-radius: 20px;
+ }
+
+ .grid {
+ display: grid;
+ grid-template-columns: repeat(3, max-content);
+ gap: 1rem;
+ align-items: start;
+ }
+
+ .ripple-demo {
+ position: relative;
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 200px;
+ height: 120px;
margin: 1rem;
+ background: #262626;
+ color: white;
+ border-radius: 8px;
+ }
+
+ .ripple-demo ion-ripple-effect {
+ border-radius: inherit;
}
@@ -47,37 +69,34 @@
Small
-
-
Large
-
-
Large
-
-
Large
-
-
- This is just a div + effect behind
- Nested button
-
-
- This is just a div + effect on top
- Nested button
-
-
-
- This is just a div + effect
-
-
+
+
+
+ This is just a div + effect behind
+ Nested button
+
+
+ This is just a div + effect on top
+ Nested button
+
+