feat: add animated button component
This commit is contained in:
@@ -16,8 +16,10 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@ai-ui/tokens": "workspace:*",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"motion": "^12.38.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user