From 909239be2d25aa9c1f38256e10c2d1c356deab38 Mon Sep 17 00:00:00 2001 From: MOH Date: Sun, 8 Mar 2026 07:17:06 +0100 Subject: [PATCH] Refine button hover origin effect --- components/ui/button.tsx | 166 ++++++++++++++++++++++++++++-- components/ui/ui-kit-showcase.tsx | 4 +- 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 8aedc0c..9626b18 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -1,20 +1,27 @@ +"use client"; + import * as React from "react"; -import { Slot } from "@radix-ui/react-slot"; +import { Slot, Slottable } from "@radix-ui/react-slot"; import { cva, type VariantProps } from "class-variance-authority"; import { cn } from "@/lib/utils"; const buttonVariants = cva( - "inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", + "relative isolate inline-flex items-center justify-center gap-2 overflow-hidden whitespace-nowrap rounded-nested text-sm font-medium ring-offset-background transition-[background-color,border-color,color,box-shadow] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:relative [&_svg]:z-[1]", { variants: { variant: { - default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92", - secondary: "bg-secondary text-secondary-foreground hover:bg-secondary/90", - outline: "border border-input bg-background hover:border-border-strong hover:bg-accent hover:text-accent-foreground", - ghost: "text-muted-foreground hover:bg-muted hover:text-foreground", + default: + "bg-primary text-primary-foreground shadow-sm hover:bg-primary/92 [--button-origin-overlay:hsl(var(--primary-foreground)/0.32)]", + secondary: + "bg-secondary text-secondary-foreground hover:bg-secondary/90 [--button-origin-overlay:hsl(var(--foreground)/0.12)]", + outline: + "border border-input bg-background hover:border-border-strong hover:bg-accent hover:text-accent-foreground [--button-origin-overlay:hsl(var(--foreground)/0.1)]", + ghost: + "text-muted-foreground hover:bg-muted hover:text-foreground [--button-origin-overlay:hsl(var(--foreground)/0.1)]", link: "rounded-none text-primary underline-offset-4 hover:underline", - destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90", + destructive: + "bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90 [--button-origin-overlay:hsl(var(--destructive-foreground)/0.26)]", }, size: { default: "h-10 px-4 py-2", @@ -34,18 +41,157 @@ export interface ButtonProps extends React.ButtonHTMLAttributes, VariantProps { asChild?: boolean; + cursorAware?: boolean; } +type ButtonOriginStyle = React.CSSProperties & { + "--button-origin-size"?: string; + "--button-origin-overlay"?: string; +}; + +type ButtonOriginState = { + left: string; + top: string; + scale: number; + opacity: number; + durationMs: number; + easing: string; +}; + const Button = React.forwardRef( - ({ className, variant, size, asChild = false, ...props }, ref) => { + ( + { + className, + variant, + size, + asChild = false, + cursorAware = variant !== "link", + onPointerEnter, + onPointerLeave, + style, + children, + ...props + }, + ref, + ) => { const Comp = asChild ? Slot : "button"; + const originStyle = style as ButtonOriginStyle | undefined; + const resolvedStyle: ButtonOriginStyle = { + "--button-origin-size": originStyle?.["--button-origin-size"] ?? "0px", + ...originStyle, + }; + const [origin, setOrigin] = React.useState({ + left: "50%", + top: "50%", + scale: 0.35, + opacity: 0, + durationMs: 180, + easing: "cubic-bezier(0.22, 1, 0.36, 1)", + }); + + const updateOrigin = React.useCallback((target: EventTarget | null, clientX: number, clientY: number) => { + if (!(target instanceof HTMLElement)) { + return 0; + } + + const rect = target.getBoundingClientRect(); + const size = Math.max(rect.height * 1.35, Math.min(rect.width * 0.58, 88)); + const left = clientX - rect.left - size / 2; + const top = clientY - rect.top - size / 2; + + target.style.setProperty("--button-origin-size", `${size}px`); + return { + left: `${left}px`, + top: `${top}px`, + }; + }, []); + + const handlePointerEnter = React.useCallback( + (event: React.PointerEvent) => { + onPointerEnter?.(event); + + if (!cursorAware || event.defaultPrevented) { + return; + } + + const nextOrigin = updateOrigin(event.currentTarget, event.clientX, event.clientY); + + if (!nextOrigin) { + return; + } + + setOrigin({ + left: nextOrigin.left, + top: nextOrigin.top, + scale: 1, + opacity: 1, + durationMs: 220, + easing: "cubic-bezier(0.16, 1, 0.3, 1)", + }); + }, + [cursorAware, onPointerEnter, updateOrigin], + ); + + const handlePointerLeave = React.useCallback( + (event: React.PointerEvent) => { + onPointerLeave?.(event); + + if (!cursorAware || event.defaultPrevented) { + return; + } + + const nextOrigin = updateOrigin(event.currentTarget, event.clientX, event.clientY); + + if (!nextOrigin) { + return; + } + + setOrigin({ + left: nextOrigin.left, + top: nextOrigin.top, + scale: 0.4, + opacity: 0, + durationMs: 160, + easing: "cubic-bezier(0.4, 0, 1, 1)", + }); + }, + [cursorAware, onPointerLeave, updateOrigin], + ); return ( + > + {cursorAware && variant !== "link" ? ( + ); }, ); diff --git a/components/ui/ui-kit-showcase.tsx b/components/ui/ui-kit-showcase.tsx index 152b4e7..23f3b31 100644 --- a/components/ui/ui-kit-showcase.tsx +++ b/components/ui/ui-kit-showcase.tsx @@ -57,7 +57,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) { cardsDesc: "Aussenflaechen, innere Karten und verschachtelte Ebenen mit einheitlicher Hierarchie.", buttonsDesc: - "Globale Button Varianten und Groessen aus einer zentralen Quelle.", + "Globale Button Varianten und Groessen aus einer zentralen Quelle mit cursor-aware Hover Effekt fuer Standard Buttons.", inputsDesc: "Gemeinsame Feldzustande mit derselben inneren Radius- und Oberflaechenlogik.", badgesDesc: @@ -154,7 +154,7 @@ export function UiKitShowcase({ localeKey }: UiKitShowcaseProps) { cardsDesc: "Outer surfaces, inner cards, and nested levels with one consistent hierarchy.", buttonsDesc: - "Global button variants and sizes from one shared source of truth.", + "Global button variants and sizes from one shared source of truth with a cursor-aware hover effect on standard buttons.", inputsDesc: "Shared field states using the same inner radius and surface logic.", badgesDesc: