"use client"; import { mergeProps } from "@base-ui/react/merge-props"; import { useRender } from "@base-ui/react/use-render"; import { cva, type VariantProps } from "class-variance-authority"; import { animate, motion, useMotionValue, useReducedMotion, useSpring, useTransform } from "motion/react"; import { type KeyboardEvent, type PointerEvent, type ReactElement, useEffect, useId, useRef, useState, } from "react"; import { cn } from "@/lib/utils"; const dropletButtonVariants = cva( "group/droplet relative isolate inline-flex [--droplet-shadow:color-mix(in_srgb,var(--goo)_28%,transparent)] dark:[--droplet-shadow:color-mix(in_srgb,var(--color-black)_48%,transparent)] shrink-0 cursor-pointer touch-manipulation select-none items-center justify-center whitespace-nowrap rounded-full font-medium outline-none [-webkit-tap-highlight-color:transparent] focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:not-data-loading:opacity-64 data-loading:pointer-events-none pointer-coarse:after:absolute pointer-coarse:after:inset-x-0 pointer-coarse:after:top-1/2 pointer-coarse:after:min-h-11 pointer-coarse:after:-translate-y-1/2 [&_svg:not([class*='size-'])]:size-4 [&_svg]:pointer-events-none [&_svg]:shrink-0", { variants: { tone: { primary: "text-primary-foreground [--goo:var(--color-primary)] hover:[--goo:color-mix(in_srgb,var(--color-primary)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-primary)_84%,var(--color-background))]", neutral: "text-foreground [--goo:color-mix(in_srgb,var(--color-foreground)_7%,var(--color-background))] hover:[--goo:color-mix(in_srgb,var(--color-foreground)_10%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-foreground)_13%,var(--color-background))]", info: "text-white [--goo:var(--color-info)] hover:[--goo:color-mix(in_srgb,var(--color-info)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-info)_84%,var(--color-background))]", success: "text-white [--goo:var(--color-success)] hover:[--goo:color-mix(in_srgb,var(--color-success)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-success)_84%,var(--color-background))]", destructive: "text-white [--goo:var(--color-destructive)] hover:[--goo:color-mix(in_srgb,var(--color-destructive)_90%,var(--color-background))] data-pressed:[--goo:color-mix(in_srgb,var(--color-destructive)_84%,var(--color-background))]", }, size: { sm: "h-8 gap-1.5 px-3.5 text-sm sm:text-xs [&_svg:not([class*='size-'])]:size-3.5", default: "h-10 gap-2 px-5 text-base sm:text-sm", lg: "h-12 gap-2 px-6 text-base [&_svg:not([class*='size-'])]:size-4.5", }, }, defaultVariants: { tone: "primary", size: "default" }, }, ); const SIZES = { sm: { drop: 4.5, satellite: 3, rise: 13, blur: 3, dot: 5 }, default: { drop: 5.5, satellite: 3.75, rise: 17, blur: 3.5, dot: 6 }, lg: { drop: 6.5, satellite: 4.5, rise: 20, blur: 4, dot: 7 }, } as const; type Size = keyof typeof SIZES; const squash = { stiffness: 700, damping: 20, mass: 1 }; const rise = [0.2, 0.75, 0.4, 1] as const; const fall = [0.6, 0, 0.85, 0.35] as const; function useSafeReducedMotion() { const prefers = useReducedMotion(); const [mounted, setMounted] = useState(false); useEffect(() => setMounted(true), []); return mounted && !!prefers; } interface Splash { id: number; x: number; width: number; } function Drop({ from, to, height, radius, duration, delay = 0, onLand, }: { from: number; to: number; height: number; radius: number; duration: number; delay?: number; onLand?: () => void; }) { const sunk = radius + 2; return ( ); } function SplashCrown({ splash, size, onLand }: { splash: Splash; size: Size; onLand: () => void }) { const { drop, satellite, rise: height } = SIZES[size]; const flat = Math.min(splash.width / 2, 24); const clamp = (x: number) => Math.min(Math.max(x, flat), splash.width - flat); const x = clamp(splash.x); const lean = (splash.width / 2 - x) * 0.06; const spread = height * 1.3; return ( <> ); } function LiquidDots({ size, reduce }: { size: Size; reduce: boolean }) { const filterId = `droplet-dots-${useId().replace(/:/g, "")}`; const { dot } = SIZES[size]; const travel = dot * 1.5; const swing = { duration: 0.62, repeat: Number.POSITIVE_INFINITY, repeatType: "mirror", ease: "easeInOut" } as const; return ( {[-1, 1].map((side) => ( ))} ); } export interface DropletButtonProps extends useRender.ComponentProps<"button">, VariantProps { loading?: boolean; splash?: boolean; } export function DropletButton({ className, tone, size, render, children, loading = false, splash: splashEnabled = true, disabled, ...props }: DropletButtonProps): ReactElement { const key = size ?? "default"; const { rise: height, drop, blur } = SIZES[key]; const reduce = useSafeReducedMotion(); const filterId = `droplet-${useId().replace(/:/g, "")}`; const boxRef = useRef(null); const [pressed, setPressed] = useState(false); const [splashes, setSplashes] = useState([]); const holding = useRef(false); const tapTimer = useRef>(undefined); const nextId = useRef(0); const origin = useRef(0.5); const scaleXTarget = useMotionValue(1); const scaleYTarget = useMotionValue(1); const scaleX = useSpring(scaleXTarget, squash); const springY = useSpring(scaleYTarget, squash); const ripple = useMotionValue(1); const scaleY = useTransform(() => springY.get() * ripple.get()); const transformOrigin = useMotionValue("50% 100%"); useEffect(() => () => clearTimeout(tapTimer.current), []); const room = { x: blur * 3 + 10, top: height + drop * 2 + blur * 3 + 4, bottom: blur * 3 + 4 }; const animated = !reduce && splashEnabled; const squeeze = (x?: number) => { setPressed(true); const width = boxRef.current?.offsetWidth ?? 0; origin.current = x === undefined || !width ? 0.5 : Math.min(Math.max(x / width, 0.2), 0.8); transformOrigin.set(`${origin.current * 100}% 100%`); if (!animated) return; scaleXTarget.set(1.035); scaleYTarget.set(0.88); }; const letGo = (burst: boolean) => { setPressed(false); scaleXTarget.set(1); scaleYTarget.set(1); if (!animated || !burst) return; const width = boxRef.current?.offsetWidth ?? 0; const id = nextId.current++; setSplashes((current) => [...current.slice(-2), { id, x: origin.current * width, width }]); }; const press = (x?: number) => { if (holding.current) return; holding.current = true; squeeze(x); }; const release = (burst: boolean) => { if (!holding.current) return; holding.current = false; letGo(burst); }; // Enter clicks on keydown, often before a keyup can arrive, so it plays the whole press at once. const tap = () => { clearTimeout(tapTimer.current); squeeze(); tapTimer.current = setTimeout(() => letGo(true), 70); }; const land = (id: number) => { setSplashes((current) => current.filter((s) => s.id !== id)); animate(ripple, [1, 0.965, 1.012, 1], { duration: 0.3, ease: "easeOut" }); }; const localX = (event: PointerEvent) => event.clientX - (boxRef.current?.getBoundingClientRect().left ?? 0); const isDisabled = Boolean(disabled || loading); const defaultProps = { className: cn(dropletButtonVariants({ tone, size }), className), "aria-busy": loading || undefined, "data-loading": loading ? "" : undefined, "data-pressed": pressed ? "" : undefined, "data-slot": "droplet-button", disabled: isDisabled, type: render ? undefined : ("button" as const), onPointerDown: (event: PointerEvent) => { if (event.button === 0) press(localX(event)); }, onPointerUp: () => release(true), onPointerLeave: () => release(false), onPointerCancel: () => release(false), onKeyDown: (event: KeyboardEvent) => { if (event.repeat) return; if (event.key === "Enter") tap(); if (event.key === " ") press(); }, onKeyUp: (event: KeyboardEvent) => { if (event.key === " ") release(true); }, onBlur: () => release(false), children: ( <> {splashes.map((s) => ( land(s.id)} size={key} splash={s} /> ))} {children} {loading && } ), }; return useRender({ defaultTagName: "button", props: mergeProps<"button">(defaultProps, props), render, }); } export { dropletButtonVariants };