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
+165
View File
@@ -0,0 +1,165 @@
import { Button } from "@ai-ui/ui";
import type { Meta, StoryObj } from "@storybook/react";
const meta = {
title: "Components/Button",
component: Button,
args: {
children: "Save changes",
size: "md",
variant: "primary"
},
argTypes: {
asChild: {
control: "boolean",
description: "Render through the child element using Radix Slot."
},
children: {
control: "text",
description: "Primary button label."
},
className: {
control: false
},
disabled: {
control: "boolean",
description: "Disables interaction and sets `data-disabled`."
},
loading: {
control: "boolean",
description: "Shows a spinner, disables interaction, and sets `data-loading`."
},
size: {
control: "select",
options: ["sm", "md", "lg", "icon"],
description: "Controls density and spacing."
},
type: {
control: "radio",
options: ["button", "submit", "reset"],
description: "Native button type when not using `asChild`."
},
variant: {
control: "select",
options: ["primary", "secondary", "ghost", "subtle", "destructive"],
description: "Semantic appearance style."
}
},
parameters: {
docs: {
description: {
component:
"The first production-style component in the system. It demonstrates the Phase 3 pattern: semantic variants, token-driven styling, motion recipes, loading state, stable `data-slot` / `data-*` hooks, and early integration of the `motion` React runtime for refined hover and press animation."
}
},
layout: "centered"
},
tags: ["autodocs"]
} satisfies Meta<typeof Button>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Playground: Story = {};
export const Variants: Story = {
render: () => (
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
<Button variant="primary">Primary action</Button>
<Button variant="secondary">Secondary action</Button>
<Button variant="subtle">Subtle action</Button>
<Button variant="ghost">Ghost action</Button>
<Button variant="destructive">Delete item</Button>
</div>
)
};
export const Sizes: Story = {
render: () => (
<div className="flex flex-wrap items-center gap-3">
<Button size="sm">Small</Button>
<Button size="md">Medium</Button>
<Button size="lg">Large</Button>
<Button aria-label="Favorite" size="icon">
</Button>
</div>
)
};
export const States: Story = {
render: () => (
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
<Button>Default</Button>
<Button disabled>Disabled</Button>
<Button loading>Saving</Button>
<Button variant="destructive" loading>
Deleting
</Button>
</div>
)
};
export const Motion: Story = {
parameters: {
docs: {
description: {
story:
"Hover and press these buttons directly in the canvas. Primary and subtle variants now use `motion/react` for a restrained lift-and-settle interaction, while loading keeps the label and spinner transitions smooth."
}
}
},
render: () => (
<div className="grid w-[720px] gap-3 sm:grid-cols-2">
<Button>Premium primary</Button>
<Button variant="subtle">Subtle surface</Button>
<Button variant="secondary">Secondary action</Button>
<Button loading>Saving changes</Button>
</div>
)
};
export const AsLink: Story = {
render: () => (
<Button asChild variant="ghost">
<a href="https://example.com">Read release notes</a>
</Button>
)
};
export const Anatomy: Story = {
render: () => (
<div className="w-[680px] rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-card)] p-6 text-[var(--color-foreground)] shadow-[var(--shadow-sm)]">
<div className="space-y-3">
<p className="text-xs uppercase tracking-[var(--tracking-caps)] text-[var(--color-muted-foreground)]">
Button anatomy
</p>
<Button loading variant="secondary">
Save draft
</Button>
<div className="grid gap-3 text-sm text-[var(--color-muted-foreground)]">
<p>
<code className="text-[var(--color-foreground)]">data-slot="root"</code> on the
interactive surface.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="icon"</code> on the
loading spinner when present.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-slot="label"</code> on the
visible button text.
</p>
<p>
<code className="text-[var(--color-foreground)]">data-loading</code>,{" "}
<code className="text-[var(--color-foreground)]">data-disabled</code>,{" "}
<code className="text-[var(--color-foreground)]">data-size</code>, and{" "}
<code className="text-[var(--color-foreground)]">data-variant</code> drive stateful
styling.
</p>
</div>
</div>
</div>
)
};
+2
View File
@@ -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": {
+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 {
+94
View File
@@ -91,12 +91,18 @@ importers:
'@ai-ui/tokens':
specifier: workspace:*
version: link:../tokens
'@radix-ui/react-slot':
specifier: ^1.2.4
version: 1.2.4(@types/react@18.3.28)(react@18.3.1)
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
clsx:
specifier: ^2.1.1
version: 2.1.1
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tailwind-merge:
specifier: ^3.5.0
version: 3.5.0
@@ -589,6 +595,24 @@ packages:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
'@radix-ui/react-compose-refs@1.1.2':
resolution: {integrity: sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@radix-ui/react-slot@1.2.4':
resolution: {integrity: sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==}
peerDependencies:
'@types/react': '*'
react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc
peerDependenciesMeta:
'@types/react':
optional: true
'@rollup/pluginutils@5.3.0':
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
engines: {node: '>=14.0.0'}
@@ -1512,6 +1536,20 @@ packages:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -1854,6 +1892,26 @@ packages:
mlly@1.8.1:
resolution: {integrity: sha512-SnL6sNutTwRWWR/vcmCYHSADjiEesp5TGQQ0pXyLhW5IoeibRlF/CbSLailbB3CNqJUk9cVJ9dUDnbD7GrcHBQ==}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
motion@12.38.0:
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -2718,6 +2776,19 @@ snapshots:
'@pkgjs/parseargs@0.11.0':
optional: true
'@radix-ui/react-compose-refs@1.1.2(@types/react@18.3.28)(react@18.3.1)':
dependencies:
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.28
'@radix-ui/react-slot@1.2.4(@types/react@18.3.28)(react@18.3.1)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.28)(react@18.3.1)
react: 18.3.1
optionalDependencies:
'@types/react': 18.3.28
'@rollup/pluginutils@5.3.0(rollup@4.59.0)':
dependencies:
'@types/estree': 1.0.8
@@ -3714,6 +3785,15 @@ snapshots:
cross-spawn: 7.0.6
signal-exit: 4.1.0
framer-motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
fsevents@2.3.3:
optional: true
@@ -4000,6 +4080,20 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
motion@12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
dependencies:
framer-motion: 12.38.0(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
tslib: 2.8.1
optionalDependencies:
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
ms@2.1.3: {}
mz@2.7.0: