From 1cb9ffa249e6bae2a3bfa7595420d962584d7650 Mon Sep 17 00:00:00 2001
From: zhom <2717306+zhom@users.noreply.github.com>
Date: Fri, 8 Aug 2025 10:46:00 +0400
Subject: [PATCH] refactor: switch to ripple button
---
package.json | 1 +
pnpm-lock.yaml | 60 +++++++
src/components/app-update-toast.tsx | 9 +-
src/components/camoufox-config-dialog.tsx | 16 +-
src/components/change-version-dialog.tsx | 5 +-
src/components/create-group-dialog.tsx | 11 +-
src/components/create-profile-dialog.tsx | 5 +-
src/components/delete-confirmation-dialog.tsx | 22 ++-
src/components/delete-group-dialog.tsx | 9 +-
src/components/edit-group-dialog.tsx | 9 +-
src/components/group-assignment-dialog.tsx | 9 +-
src/components/group-management-dialog.tsx | 11 +-
src/components/home-header.tsx | 10 +-
src/components/import-profile-dialog.tsx | 19 +--
src/components/loading-button.tsx | 11 +-
src/components/permission-dialog.tsx | 5 +-
src/components/profile-data-table.tsx | 13 +-
src/components/profile-selector-dialog.tsx | 9 +-
src/components/proxy-form-dialog.tsx | 5 +-
src/components/proxy-management-dialog.tsx | 11 +-
src/components/proxy-settings-dialog.tsx | 15 +-
src/components/release-type-selector.tsx | 5 +-
src/components/settings-dialog.tsx | 5 +-
src/components/ui/combobox.tsx | 5 +-
src/components/ui/ripple.tsx | 146 ++++++++++++++++++
25 files changed, 344 insertions(+), 82 deletions(-)
create mode 100644 src/components/ui/ripple.tsx
diff --git a/package.json b/package.json
index ebda7fb..6038b25 100644
--- a/package.json
+++ b/package.json
@@ -46,6 +46,7 @@
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"cmdk": "^1.1.1",
+ "motion": "^12.23.12",
"next": "^15.4.6",
"next-themes": "^0.4.6",
"react": "^19.1.1",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 1ab6db3..ab4eef5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -74,6 +74,9 @@ importers:
cmdk:
specifier: ^1.1.1
version: 1.1.1(@types/react-dom@19.1.7(@types/react@19.1.9))(@types/react@19.1.9)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ motion:
+ specifier: ^12.23.12
+ version: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
next:
specifier: ^15.4.6
version: 15.4.6(@babel/core@7.28.0)(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
@@ -1860,6 +1863,20 @@ packages:
resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==}
engines: {node: '>= 6'}
+ framer-motion@12.23.12:
+ resolution: {integrity: sha512-6e78rdVtnBvlEVgu6eFEAgG9v3wLnYEboM8I5O5EXvfKC8gxGQB8wXJdhkMy10iVcn05jl6CNw7/HTsTCfwcWg==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
fs-constants@1.0.0:
resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==}
@@ -2343,6 +2360,26 @@ packages:
resolution: {integrity: sha512-DXO4L9W+08T+A7h5+xdT32l7IMot8z7WOH+7C1Maol571PnktQ8un7Ni4CyPFp4H+vht/FDA5/tpjRvWMFQDMw==}
engines: {node: '>=10', npm: '>=6'}
+ motion-dom@12.23.12:
+ resolution: {integrity: sha512-RcR4fvMCTESQBD/uKQe49D5RUeDOokkGRmz4ceaJKDBgHYtZtntC/s2vLvY38gqGaytinij/yi3hMcWVcEF5Kw==}
+
+ motion-utils@12.23.6:
+ resolution: {integrity: sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==}
+
+ motion@12.23.12:
+ resolution: {integrity: sha512-8jCD8uW5GD1csOoqh1WhH1A6j5APHVE15nuBkFeRiMzYBdRwyAHmSP/oXSuW0WJPZRXTFdBoG4hY9TFWNhhwng==}
+ peerDependencies:
+ '@emotion/is-prop-valid': '*'
+ react: ^18.0.0 || ^19.0.0
+ react-dom: ^18.0.0 || ^19.0.0
+ peerDependenciesMeta:
+ '@emotion/is-prop-valid':
+ optional: true
+ react:
+ optional: true
+ react-dom:
+ optional: true
+
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -4520,6 +4557,15 @@ snapshots:
hasown: 2.0.2
mime-types: 2.1.35
+ framer-motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ motion-dom: 12.23.12
+ motion-utils: 12.23.6
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+
fs-constants@1.0.0: {}
fs-minipass@2.1.0:
@@ -4981,6 +5027,20 @@ snapshots:
mmdb-lib@2.2.1: {}
+ motion-dom@12.23.12:
+ dependencies:
+ motion-utils: 12.23.6
+
+ motion-utils@12.23.6: {}
+
+ motion@12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1):
+ dependencies:
+ framer-motion: 12.23.12(react-dom@19.1.1(react@19.1.1))(react@19.1.1)
+ tslib: 2.8.1
+ optionalDependencies:
+ react: 19.1.1
+ react-dom: 19.1.1(react@19.1.1)
+
ms@2.1.3: {}
nano-spawn@1.0.2: {}
diff --git a/src/components/app-update-toast.tsx b/src/components/app-update-toast.tsx
index 1606a07..7030c04 100644
--- a/src/components/app-update-toast.tsx
+++ b/src/components/app-update-toast.tsx
@@ -5,6 +5,7 @@ import { LuCheckCheck, LuCog, LuRefreshCw } from "react-icons/lu";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import type { AppUpdateInfo, AppUpdateProgress } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface AppUpdateToastProps {
updateInfo: AppUpdateInfo;
@@ -160,22 +161,22 @@ export function AppUpdateToast({
{!isUpdating && (
-
-
+
)}
diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx
index bdff7ef..0aa2083 100644
--- a/src/components/camoufox-config-dialog.tsx
+++ b/src/components/camoufox-config-dialog.tsx
@@ -12,6 +12,8 @@ import {
} from "@/components/ui/dialog";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, CamoufoxConfig } from "@/types";
+import { LoadingButton } from "./loading-button";
+import { RippleButton } from "./ui/ripple";
interface CamoufoxConfigDialogProps {
isOpen: boolean;
@@ -117,12 +119,16 @@ export function CamoufoxConfigDialog({
-
-
+
+
+ Save
+
diff --git a/src/components/change-version-dialog.tsx b/src/components/change-version-dialog.tsx
index a219b24..7d6a964 100644
--- a/src/components/change-version-dialog.tsx
+++ b/src/components/change-version-dialog.tsx
@@ -19,6 +19,7 @@ import { Label } from "@/components/ui/label";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import type { BrowserProfile, BrowserReleaseTypes } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ChangeVersionDialogProps {
isOpen: boolean;
@@ -288,9 +289,9 @@ export function ChangeVersionDialog({
-
+
{
diff --git a/src/components/create-group-dialog.tsx b/src/components/create-group-dialog.tsx
index 5175a2a..44cbe0f 100644
--- a/src/components/create-group-dialog.tsx
+++ b/src/components/create-group-dialog.tsx
@@ -16,6 +16,7 @@ import {
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import type { ProfileGroup } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface CreateGroupDialogProps {
isOpen: boolean;
@@ -98,15 +99,19 @@ export function CreateGroupDialog({
-
+
void handleCreate()}
disabled={!groupName.trim()}
>
- Create Group
+ Create
diff --git a/src/components/create-profile-dialog.tsx b/src/components/create-profile-dialog.tsx
index ac55bf6..2e235a0 100644
--- a/src/components/create-profile-dialog.tsx
+++ b/src/components/create-profile-dialog.tsx
@@ -27,6 +27,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useBrowserDownload } from "@/hooks/use-browser-download";
import { getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserReleaseTypes, CamoufoxConfig, StoredProxy } from "@/types";
+import { RippleButton } from "./ui/ripple";
type BrowserTypeString =
| "mullvad-browser"
@@ -580,9 +581,9 @@ export function CreateProfileDialog({
-
+
-
-
+ Cancel
+
+ void handleConfirm()}
+ isLoading={isLoading}
+ >
+ {confirmButtonText}
+
diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx
index d1c1a25..d11e567 100644
--- a/src/components/delete-group-dialog.tsx
+++ b/src/components/delete-group-dialog.tsx
@@ -17,6 +17,7 @@ import { Label } from "@/components/ui/label";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
import { ScrollArea } from "@/components/ui/scroll-area";
import type { BrowserProfile, ProfileGroup } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface DeleteGroupDialogProps {
isOpen: boolean;
@@ -188,9 +189,13 @@ export function DeleteGroupDialog({
-
+
-
+
void handleUpdate()}
diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx
index f4ec345..02916c5 100644
--- a/src/components/group-assignment-dialog.tsx
+++ b/src/components/group-assignment-dialog.tsx
@@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { ProfileGroup } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface GroupAssignmentDialogProps {
isOpen: boolean;
@@ -161,9 +162,13 @@ export function GroupAssignmentDialog({
-
+
void handleAssign()}
diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx
index ee584d1..80dd8e1 100644
--- a/src/components/group-management-dialog.tsx
+++ b/src/components/group-management-dialog.tsx
@@ -26,6 +26,7 @@ import {
TableRow,
} from "@/components/ui/table";
import type { ProfileGroup } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface GroupManagementDialogProps {
isOpen: boolean;
@@ -119,14 +120,14 @@ export function GroupManagementDialog({
{/* Create new group button */}
-
+ Create
+
{error && (
@@ -187,9 +188,9 @@ export function GroupManagementDialog({
-
+
diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx
index f6e6a51..31fa22a 100644
--- a/src/components/home-header.tsx
+++ b/src/components/home-header.tsx
@@ -11,6 +11,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "./ui/dropdown-menu";
+import { RippleButton } from "./ui/ripple";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
type Props = {
@@ -46,7 +47,6 @@ const HomeHeader = ({
) : (
diff --git a/src/components/import-profile-dialog.tsx b/src/components/import-profile-dialog.tsx
index a053bf3..076e1c4 100644
--- a/src/components/import-profile-dialog.tsx
+++ b/src/components/import-profile-dialog.tsx
@@ -26,6 +26,7 @@ import {
import { useBrowserSupport } from "@/hooks/use-browser-support";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { DetectedProfile } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ImportProfileDialogProps {
isOpen: boolean;
@@ -242,7 +243,7 @@ export function ImportProfileDialog({
);
if (profile) {
const browserName = getBrowserDisplayName(profile.browser);
- const defaultName = `Imported ${browserName} Profile`;
+ const defaultName = `Old ${browserName}`;
setAutoDetectProfileName(defaultName);
}
}
@@ -268,7 +269,7 @@ export function ImportProfileDialog({
{/* Mode Selection */}
- {
setImportMode("auto-detect");
@@ -277,8 +278,8 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Auto-Detect
-
-
+ {
setImportMode("manual");
@@ -287,7 +288,7 @@ export function ImportProfileDialog({
disabled={isLoading}
>
Manual Import
-
+
{/* Auto-Detect Mode */}
@@ -479,9 +480,9 @@ export function ImportProfileDialog({
-
+
Cancel
-
+
{importMode === "auto-detect" ? (
- Import Profile
+ Import
) : (
- Import Profile
+ Import
)}
diff --git a/src/components/loading-button.tsx b/src/components/loading-button.tsx
index 78f169b..4a563ae 100644
--- a/src/components/loading-button.tsx
+++ b/src/components/loading-button.tsx
@@ -1,5 +1,8 @@
import { LuLoaderCircle } from "react-icons/lu";
-import { type ButtonProps, Button as UIButton } from "./ui/button";
+import {
+ type RippleButtonProps as ButtonProps,
+ RippleButton as UIButton,
+} from "./ui/ripple";
type Props = ButtonProps & {
isLoading: boolean;
@@ -7,7 +10,11 @@ type Props = ButtonProps & {
};
export const LoadingButton = ({ isLoading, ...props }: Props) => {
return (
-
+
{isLoading ? (
) : (
diff --git a/src/components/permission-dialog.tsx b/src/components/permission-dialog.tsx
index 23e825e..99857f0 100644
--- a/src/components/permission-dialog.tsx
+++ b/src/components/permission-dialog.tsx
@@ -15,6 +15,7 @@ import {
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
+import { RippleButton } from "./ui/ripple";
interface PermissionDialogProps {
isOpen: boolean;
@@ -148,9 +149,9 @@ export function PermissionDialog({
-
+
{isCurrentPermissionGranted ? "Done" : "Cancel"}
-
+
{!isCurrentPermissionGranted && (
-
+
{tooltipContent && (
@@ -959,15 +960,17 @@ export function ProfilesDataTable({
)}
- {
setProfileToRename(null);
}}
>
Cancel
-
- void handleRename()}>Save
+
+ void handleRename()}>
+ Save
+
diff --git a/src/components/profile-selector-dialog.tsx b/src/components/profile-selector-dialog.tsx
index b8d8431..43ba664 100644
--- a/src/components/profile-selector-dialog.tsx
+++ b/src/components/profile-selector-dialog.tsx
@@ -30,6 +30,7 @@ import {
import { useBrowserState } from "@/hooks/use-browser-state";
import { getBrowserDisplayName, getBrowserIcon } from "@/lib/browser-utils";
import type { BrowserProfile, StoredProxy } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ProfileSelectorDialogProps {
isOpen: boolean;
@@ -196,7 +197,7 @@ export function ProfileSelectorDialog({
- void handleCopyUrl()}
@@ -204,7 +205,7 @@ export function ProfileSelectorDialog({
>
Copy
-
+
{url}
@@ -312,9 +313,9 @@ export function ProfileSelectorDialog({
-
+
Cancel
-
+
diff --git a/src/components/proxy-form-dialog.tsx b/src/components/proxy-form-dialog.tsx
index fbb5e33..f43c021 100644
--- a/src/components/proxy-form-dialog.tsx
+++ b/src/components/proxy-form-dialog.tsx
@@ -22,6 +22,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import type { StoredProxy } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ProxyFormData {
name: string;
@@ -264,13 +265,13 @@ export function ProxyFormDialog({
-
Cancel
-
+
-
Create Proxy
-
+
{/* Proxy List - Scrollable */}
@@ -150,10 +151,10 @@ export function ProxyManagementDialog({
Create your first proxy configuration to get started
-
+
Create First Proxy
-
+
) : (
@@ -221,7 +222,7 @@ export function ProxyManagementDialog({
- Close
+ Close
diff --git a/src/components/proxy-settings-dialog.tsx b/src/components/proxy-settings-dialog.tsx
index 98e4849..36b99ba 100644
--- a/src/components/proxy-settings-dialog.tsx
+++ b/src/components/proxy-settings-dialog.tsx
@@ -23,6 +23,7 @@ import {
} from "@/components/ui/tooltip";
import { cn } from "@/lib/utils";
import type { StoredProxy } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ProxySettingsDialogProps {
isOpen: boolean;
@@ -142,15 +143,15 @@ export function ProxySettingsDialog({
-
- Create New
-
+ Create
+
Create a new proxy configuration
@@ -263,12 +264,12 @@ export function ProxySettingsDialog({
-
+
Cancel
-
-
+
+
Save
-
+
diff --git a/src/components/release-type-selector.tsx b/src/components/release-type-selector.tsx
index 159fa59..7bbabf1 100644
--- a/src/components/release-type-selector.tsx
+++ b/src/components/release-type-selector.tsx
@@ -19,6 +19,7 @@ import {
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { BrowserReleaseTypes } from "@/types";
+import { RippleButton } from "./ui/ripple";
interface ReleaseTypeSelectorProps {
selectedReleaseType: "stable" | "nightly" | null;
@@ -85,7 +86,7 @@ export function ReleaseTypeSelector({
{showDropdown ? (
-
{selectedDisplayText}
-
+
diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx
index 0b25e46..536c812 100644
--- a/src/components/settings-dialog.tsx
+++ b/src/components/settings-dialog.tsx
@@ -33,6 +33,7 @@ import {
showSuccessToast,
showUnifiedVersionUpdateToast,
} from "@/lib/toast-utils";
+import { RippleButton } from "./ui/ripple";
interface AppSettings {
set_as_default_browser: boolean;
@@ -529,9 +530,9 @@ export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
-
+
Cancel
-
+
{
diff --git a/src/components/ui/combobox.tsx b/src/components/ui/combobox.tsx
index 14e57a1..18bf624 100644
--- a/src/components/ui/combobox.tsx
+++ b/src/components/ui/combobox.tsx
@@ -18,6 +18,7 @@ import {
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
+import { RippleButton } from "./ripple";
interface ComboboxOption {
value: string;
@@ -47,7 +48,7 @@ export function Combobox({
return (
- option.value === value)?.label
: placeholder}
-
+
diff --git a/src/components/ui/ripple.tsx b/src/components/ui/ripple.tsx
new file mode 100644
index 0000000..85d9c3c
--- /dev/null
+++ b/src/components/ui/ripple.tsx
@@ -0,0 +1,146 @@
+"use client";
+
+import { cva, type VariantProps } from "class-variance-authority";
+import { type HTMLMotionProps, motion, type Transition } from "motion/react";
+import * as React from "react";
+
+import { cn } from "@/lib/utils";
+
+const buttonVariants = cva(
+ "relative overflow-hidden cursor-pointer inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-lg text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
+ {
+ variants: {
+ variant: {
+ default: "bg-primary text-primary-foreground hover:bg-primary/90",
+ destructive:
+ "bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
+ outline:
+ "border bg-background hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
+ secondary:
+ "bg-secondary text-secondary-foreground hover:bg-secondary/80",
+ ghost:
+ "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
+ },
+ size: {
+ default: "h-10 px-4 py-2 has-[>svg]:px-3",
+ sm: "h-9 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
+ lg: "h-11 px-8 has-[>svg]:px-6",
+ icon: "size-10",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ size: "default",
+ },
+ },
+);
+
+const rippleVariants = cva("absolute rounded-full size-5 pointer-events-none", {
+ variants: {
+ variant: {
+ default: "bg-primary-foreground",
+ destructive: "bg-destructive",
+ outline: "bg-input",
+ secondary: "bg-secondary",
+ ghost: "bg-accent",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+});
+
+type Ripple = {
+ id: number;
+ x: number;
+ y: number;
+};
+
+type RippleButtonProps = HTMLMotionProps<"button"> & {
+ children: React.ReactNode;
+ rippleClassName?: string;
+ scale?: number;
+ transition?: Transition;
+} & VariantProps;
+
+function RippleButton({
+ ref,
+ children,
+ onClick,
+ className,
+ rippleClassName,
+ variant,
+ size,
+ scale = 10,
+ transition = { duration: 0.6, ease: "easeOut" },
+ ...props
+}: RippleButtonProps) {
+ const [ripples, setRipples] = React.useState([]);
+ const buttonRef = React.useRef(null);
+ React.useImperativeHandle(ref, () => buttonRef.current as HTMLButtonElement);
+
+ const createRipple = React.useCallback(
+ (event: React.MouseEvent) => {
+ const button = buttonRef.current;
+ if (!button) return;
+
+ const rect = button.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top;
+
+ const newRipple: Ripple = {
+ id: Date.now(),
+ x,
+ y,
+ };
+
+ setRipples((prev) => [...prev, newRipple]);
+
+ setTimeout(() => {
+ setRipples((prev) => prev.filter((r) => r.id !== newRipple.id));
+ }, 600);
+ },
+ [],
+ );
+
+ const handleClick = React.useCallback(
+ (event: React.MouseEvent) => {
+ createRipple(event);
+ if (onClick) {
+ onClick(event);
+ }
+ },
+ [createRipple, onClick],
+ );
+
+ return (
+
+ {children}
+ {ripples.map((ripple) => (
+
+ ))}
+
+ );
+}
+
+export { RippleButton, type RippleButtonProps };