feat: add animated button component
This commit is contained in:
@@ -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>
|
||||
)
|
||||
};
|
||||
@@ -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 {
|
||||
|
||||
Generated
+94
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user