diff --git a/packages/ui/src/components/button/Button.vue b/packages/ui/src/components/button/Button.vue
index 42b0c5e2f..019cdbd31 100644
--- a/packages/ui/src/components/button/Button.vue
+++ b/packages/ui/src/components/button/Button.vue
@@ -1,5 +1,12 @@
-
diff --git a/packages/ui/src/components/wave/index.ts b/packages/ui/src/components/wave/index.ts
new file mode 100644
index 000000000..881d72675
--- /dev/null
+++ b/packages/ui/src/components/wave/index.ts
@@ -0,0 +1,12 @@
+import { App, Plugin } from 'vue'
+import Wave from './Wave.vue'
+import './style/index.css'
+
+export { default as Wave } from './Wave.vue'
+
+/* istanbul ignore next */
+Wave.install = function (app: App) {
+ app.component('AWave', Wave)
+ return app
+}
+export default Wave as typeof Wave & Plugin
diff --git a/packages/ui/src/components/wave/style/index.css b/packages/ui/src/components/wave/style/index.css
new file mode 100644
index 000000000..401918467
--- /dev/null
+++ b/packages/ui/src/components/wave/style/index.css
@@ -0,0 +1,21 @@
+@reference '../../../style/tailwind.css';
+
+.ant-wave-motion {
+ @apply absolute;
+ @apply bg-transparent;
+ @apply pointer-events-none;
+ @apply box-border;
+ @apply text-accent;
+ @apply opacity-20;
+ box-shadow: 0 0 0 0 currentcolor;
+
+ &:where(.ant-wave-motion-appear) {
+ transition:
+ box-shadow 0.4s cubic-bezier(0.08, 0.82, 0.17, 1),
+ opacity 2s cubic-bezier(0.08, 0.82, 0.17, 1);
+ &:where(.ant-wave-motion-appear-active) {
+ @apply opacity-0;
+ box-shadow: 0 0 0 6px currentcolor;
+ }
+ }
+}
diff --git a/packages/ui/src/components/wave/util.ts b/packages/ui/src/components/wave/util.ts
new file mode 100644
index 000000000..cd5bf6377
--- /dev/null
+++ b/packages/ui/src/components/wave/util.ts
@@ -0,0 +1,35 @@
+export function isNotGrey(color: string) {
+ // eslint-disable-next-line no-useless-escape
+ const match = (color || '').match(/rgba?\((\d*), (\d*), (\d*)(, [\d.]*)?\)/);
+ if (match && match[1] && match[2] && match[3]) {
+ return !(match[1] === match[2] && match[2] === match[3]);
+ }
+ return true;
+}
+
+export function isValidWaveColor(color: string) {
+ return (
+ color &&
+ color !== '#fff' &&
+ color !== '#ffffff' &&
+ color !== 'rgb(255, 255, 255)' &&
+ color !== 'rgba(255, 255, 255, 1)' &&
+ isNotGrey(color) &&
+ !/rgba\((?:\d*, ){3}0\)/.test(color) && // any transparent rgba color
+ color !== 'transparent'
+ );
+}
+
+export function getTargetWaveColor(node: HTMLElement) {
+ const { borderTopColor, borderColor, backgroundColor } = getComputedStyle(node);
+ if (isValidWaveColor(borderTopColor)) {
+ return borderTopColor;
+ }
+ if (isValidWaveColor(borderColor)) {
+ return borderColor;
+ }
+ if (isValidWaveColor(backgroundColor)) {
+ return backgroundColor;
+ }
+ return null;
+}
diff --git a/packages/ui/src/utils/isVisible.ts b/packages/ui/src/utils/isVisible.ts
new file mode 100644
index 000000000..9dfc2b754
--- /dev/null
+++ b/packages/ui/src/utils/isVisible.ts
@@ -0,0 +1,25 @@
+export default (element: HTMLElement | SVGGraphicsElement): boolean => {
+ if (!element) {
+ return false;
+ }
+
+ if ((element as HTMLElement).offsetParent) {
+ return true;
+ }
+
+ if ((element as SVGGraphicsElement).getBBox) {
+ const box = (element as SVGGraphicsElement).getBBox();
+ if (box.width || box.height) {
+ return true;
+ }
+ }
+
+ if ((element as HTMLElement).getBoundingClientRect) {
+ const box = (element as HTMLElement).getBoundingClientRect();
+ if (box.width || box.height) {
+ return true;
+ }
+ }
+
+ return false;
+};
diff --git a/packages/ui/src/utils/raf.ts b/packages/ui/src/utils/raf.ts
new file mode 100644
index 000000000..160c73c80
--- /dev/null
+++ b/packages/ui/src/utils/raf.ts
@@ -0,0 +1,47 @@
+let raf = (callback: FrameRequestCallback) => setTimeout(callback, 16) as any;
+let caf = (num: number) => clearTimeout(num);
+
+if (typeof window !== 'undefined' && 'requestAnimationFrame' in window) {
+ raf = (callback: FrameRequestCallback) => window.requestAnimationFrame(callback);
+ caf = (handle: number) => window.cancelAnimationFrame(handle);
+}
+
+let rafUUID = 0;
+const rafIds = new Map();
+
+function cleanup(id: number) {
+ rafIds.delete(id);
+}
+
+export default function wrapperRaf(callback: () => void, times = 1): number {
+ rafUUID += 1;
+ const id = rafUUID;
+
+ function callRef(leftTimes: number) {
+ if (leftTimes === 0) {
+ // Clean up
+ cleanup(id);
+
+ // Trigger
+ callback();
+ } else {
+ // Next raf
+ const realId = raf(() => {
+ callRef(leftTimes - 1);
+ });
+
+ // Bind real raf id
+ rafIds.set(id, realId);
+ }
+ }
+
+ callRef(times);
+
+ return id;
+}
+
+wrapperRaf.cancel = (id: number) => {
+ const realId = rafIds.get(id);
+ cleanup(realId);
+ return caf(realId);
+};