feat: add animated button component

This commit is contained in:
2026-03-19 15:17:31 +08:00
parent 3960e0a0e7
commit 1dcd138763
6 changed files with 426 additions and 0 deletions
+120
View File
@@ -0,0 +1,120 @@
import { Slot, Slottable } from "@radix-ui/react-slot";
import { forwardRef, useState } from "react";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { buttonVariants } from "./button.variants";
import { cn } from "../lib/cn";
import type { VariantProps } from "../lib/cva";
import type { ButtonLikeElementProps } from "../lib/contracts";
import { createDataAttributes, createSlot } from "../lib/contracts";
export type ButtonProps = Omit<
ButtonLikeElementProps,
"onDrag" | "onDragEnd" | "onDragStart"
> &
VariantProps<typeof buttonVariants>;
function Spinner() {
return (
<motion.span
{...createSlot("icon")}
aria-hidden="true"
animate={{ opacity: 1, rotate: 0, scale: 1 }}
className="size-4 rounded-full border-2 border-current border-r-transparent animate-spin"
exit={{ opacity: 0, rotate: 90, scale: 0.7 }}
initial={{ opacity: 0, rotate: -90, scale: 0.7 }}
transition={{
duration: 0.18,
ease: [0.22, 1, 0.36, 1]
}}
/>
);
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(function Button(
{
asChild = false,
children,
className,
disabled,
loading = false,
onMouseEnter,
onMouseLeave,
size,
type,
variant,
...props
},
ref
) {
const prefersReducedMotion = useReducedMotion();
const [isHovered, setIsHovered] = useState(false);
const isDisabled = disabled || loading;
const Component = asChild ? Slot : "button";
const baseClassName = cn(buttonVariants({ loading, size, variant }), className);
const label = asChild ? (
<Slottable>{children}</Slottable>
) : (
<motion.span
{...createSlot("label")}
animate={{
opacity: loading ? 0.94 : 1,
x: loading ? 1.5 : 0
}}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.18,
ease: [0.22, 1, 0.36, 1]
}}
>
{children}
</motion.span>
);
const sheen = !asChild ? (
<motion.span
animate={
prefersReducedMotion || isDisabled
? { opacity: 0, x: "-120%" }
: isHovered
? { opacity: 0.75, x: "115%" }
: { opacity: 0, x: "-120%" }
}
aria-hidden="true"
className="pointer-events-none absolute inset-y-0 left-0 w-1/2 rounded-[inherit] bg-[linear-gradient(120deg,transparent_0%,rgba(255,255,255,0.32)_45%,transparent_100%)] mix-blend-screen"
initial={false}
transition={{
duration: prefersReducedMotion ? 0.01 : 0.55,
ease: [0.16, 1, 0.3, 1]
}}
/>
) : null;
return (
<Component
{...props}
{...createSlot("root")}
{...createDataAttributes({
disabled: isDisabled,
loading,
size,
variant
})}
className={baseClassName}
disabled={asChild ? undefined : isDisabled}
onMouseEnter={(event) => {
setIsHovered(true);
onMouseEnter?.(event as never);
}}
onMouseLeave={(event) => {
setIsHovered(false);
onMouseLeave?.(event as never);
}}
ref={ref}
type={asChild ? undefined : type ?? "button"}
>
{sheen}
<AnimatePresence initial={false}>{loading ? <Spinner /> : null}</AnimatePresence>
{label}
</Component>
);
});
@@ -0,0 +1,43 @@
import { cva } from "../lib/cva";
import { getMotionRecipeClassNames } from "../lib/motion";
export const buttonVariants = cva(
[
"relative isolate inline-flex shrink-0 items-center justify-center gap-2 overflow-hidden whitespace-nowrap",
"rounded-[var(--radius-sm)] border font-medium select-none",
"outline-none focus-visible:ring-2 focus-visible:ring-[var(--color-ring)]",
"focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--color-background)]",
"disabled:pointer-events-none disabled:opacity-55",
getMotionRecipeClassNames("pressable", "ring")
],
{
variants: {
size: {
sm: "h-9 px-3 text-sm",
md: "h-10 px-4 text-sm",
lg: "h-12 px-5 text-base",
icon: "h-10 w-10 text-sm"
},
variant: {
primary:
"border-transparent bg-[var(--color-primary)] text-[var(--color-primary-foreground)] shadow-[var(--shadow-xs)]",
secondary:
"border-[var(--color-border-strong)] bg-[var(--color-secondary)] text-[var(--color-secondary-foreground)]",
ghost:
"border-transparent bg-transparent text-[var(--color-foreground)] hover:bg-[var(--color-surface)]",
subtle:
"border-[var(--color-border)] bg-[var(--color-card)] text-[var(--color-foreground)] shadow-[var(--shadow-xs)]",
destructive:
"border-transparent bg-[var(--color-destructive)] text-[var(--color-destructive-foreground)] shadow-[var(--shadow-xs)]"
},
loading: {
false: "",
true: "cursor-wait"
}
},
defaultVariants: {
size: "md",
variant: "primary"
}
}
);
+2
View File
@@ -1,3 +1,5 @@
export { Button, type ButtonProps } from "./components/button";
export { buttonVariants } from "./components/button.variants";
export { cn } from "./lib/cn";
export { cva, cx, type VariantProps } from "./lib/cva";
export {