// Copy paste from https://github.com/asplunds/use-ripple import { type RefObject, useCallback, useRef } from "react" export type Options = { duration: number color: string timingFunction: string disabled?: boolean className: string containerClassName: string ignoreNonLeftClick: boolean onSpawn?: (ctx: { /** the ripple element */ readonly ripple: HTMLDivElement /** cancels the current ripple animation */ readonly cancelRipple: () => void /** the ref to the ripple host element */ readonly ref: RefObject /** the event that triggered the ripple (ts: casting required) */ readonly event: unknown /** the ripple container element */ readonly container: HTMLDivElement }) => void cancelAutomatically: boolean ref: RefObject } export type MinimalEvent = { clientX: number clientY: number nativeEvent?: { which?: number type?: string } } const self = () => document const completedFactor = 0.4 const className = "__ripple" const containerClassName = "__ripple-container" export function useRipple( inputOptions?: Partial>, ): readonly [RefObject, (event: MinimalEvent) => void] { const internalRef = useRef(null) const { ref, ...options }: Options = { duration: 450, color: "rgba(255, 255, 255, .3)", cancelAutomatically: false, timingFunction: "cubic-bezier(.42,.36,.28,.88)", disabled: false, className, containerClassName, ignoreNonLeftClick: true, ref: internalRef, ...(inputOptions ?? {}), } const event = useCallback( (event: MinimalEvent) => { if ( !ref.current || options.disabled || (options.ignoreNonLeftClick && event.nativeEvent?.which !== 1 && event.nativeEvent?.type === "mousedown") ) return const target = ref.current if (window.getComputedStyle(target).position === "static") void applyStyles([["position", "relative"]], target) if (!target) return const existingContainer = target.querySelector(`:scope > .${options.containerClassName}`) const container = existingContainer ?? createRippleContainer(options.containerClassName) if (!existingContainer) target.appendChild(container) // Used to ensure overflow: hidden is registered properly on IOS Safari before ripple is shown void requestAnimationFrame(() => { const begun = Date.now() const ripple = centerElementToPointer(event, target, createRipple(target, event, options)) const events = ["mouseup", "touchend"] as const const cancelRipple = () => { const now = Date.now() const diff = now - begun // Ensure the transform animation is complete before cancellation void setTimeout( () => { void cancelRippleAnimation(ripple, options) }, diff > 0.4 * options.duration ? 0 : completedFactor * options.duration - diff, ) for (const event of events) void self().removeEventListener(event, cancelRipple) } if (!options.cancelAutomatically && !isTouchDevice()) for (const event of events) void self().addEventListener(event, cancelRipple) else setTimeout( () => void cancelRippleAnimation(ripple, options), options.duration * completedFactor, ) void container.appendChild(ripple) void options.onSpawn?.({ ripple, cancelRipple, event, ref, container, }) }) }, // biome-ignore lint/correctness/useExhaustiveDependencies: Ignore this [ref, options], ) return [ref, event] } /** * HOF useRipple - Generate a custom ripple hook with predefined options * * After generating a HOF useRipple you can then override some or all predefined options by passing a new option object. * @author Jonathan Asplund * @param inputOptions ripple options * @returns Custom HOC useRipple hook */ export function customRipple( inputOptions?: Partial, "ref">>, ) { return (overrideOptions?: Partial>) => useRipple({ ...inputOptions, ...overrideOptions, }) } function centerElementToPointer( event: MinimalEvent, ref: HTMLElement, element: T, ): T { const { top, left } = ref.getBoundingClientRect() void element.style.setProperty("top", px(event.clientY - top)) void element.style.setProperty("left", px(event.clientX - left)) return element } function px(arg: string | number) { return `${arg}px` } function createRipple( ref: T, event: MinimalEvent, { duration, color, timingFunction, className }: Omit, ctx = document, ): HTMLDivElement { const element = ctx.createElement("div") const { clientX, clientY } = event const { height, width, top, left } = ref.getBoundingClientRect() const maxHeight = Math.max(clientY - top, height - clientY + top) const maxWidth = Math.max(clientX - left, width - clientX + left) const size = px(Math.hypot(maxHeight, maxWidth) * 2) const styles = [ ["position", "absolute"], ["height", size], ["width", size], ["transform", "translate(-50%, -50%) scale(0)"], ["pointer-events", "none"], ["border-radius", "50%"], ["opacity", ".6"], ["background", color], [ "transition", `transform ${duration * 0.6}ms ${timingFunction}, opacity ${Math.max( duration * 0.05, 140, )}ms ease-out`, ], ] void element.classList.add(className) void window.requestAnimationFrame(() => { void applyStyles([["transform", "translate(-50%, -50%) scale(1)"]], element) }) return applyStyles(styles, element) } function applyStyles(styles: string[][], target: T): T { if (!target) return target for (const [property, value] of styles) { void target.style.setProperty(property, value) } return target } function cancelRippleAnimation( element: T, options: Omit, "color" | "ref" | "onSpawn" | "cancelAutomatically">, ) { const { duration, timingFunction } = options void applyStyles( [ ["opacity", "0"], [ "transition", `transform ${duration * 0.6}ms ${timingFunction}, opacity ${ duration * 0.65 }ms ease-in-out ${duration * 0.13}ms`, ], ], element, ) void window.requestAnimationFrame(() => { void element.addEventListener("transitionend", e => { if (e.propertyName === "opacity") void element.remove() }) }) } function createRippleContainer(className: string) { const container = self().createElement("div") void container.classList.add(className) return applyStyles( [ ["position", "absolute"], ["height", "100%"], ["width", "100%"], ["border-radius", "inherit"], ["top", "0"], ["left", "0"], ["pointer-events", "none"], ["overflow", "hidden"], ], container, ) } /** taken from https://stackoverflow.com/a/4819886/13188385 */ function isTouchDevice(): boolean { return ( "ontouchstart" in window || navigator.maxTouchPoints > 0 || ((navigator as any)?.msMaxTouchPoints ?? 0) > 0 ) }