From 1dcd13876387377e59ca5ae55c9ad942c14ec05d Mon Sep 17 00:00:00 2001 From: kurihada Date: Thu, 19 Mar 2026 15:17:31 +0800 Subject: [PATCH] feat: add animated button component --- apps/docs/src/components/button.stories.tsx | 165 ++++++++++++++++++ packages/ui/package.json | 2 + packages/ui/src/components/button.tsx | 120 +++++++++++++ packages/ui/src/components/button.variants.ts | 43 +++++ packages/ui/src/index.ts | 2 + pnpm-lock.yaml | 94 ++++++++++ 6 files changed, 426 insertions(+) create mode 100644 apps/docs/src/components/button.stories.tsx create mode 100644 packages/ui/src/components/button.tsx create mode 100644 packages/ui/src/components/button.variants.ts diff --git a/apps/docs/src/components/button.stories.tsx b/apps/docs/src/components/button.stories.tsx new file mode 100644 index 0000000..eabe765 --- /dev/null +++ b/apps/docs/src/components/button.stories.tsx @@ -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; + +export default meta; + +type Story = StoryObj; + +export const Playground: Story = {}; + +export const Variants: Story = { + render: () => ( +
+ + + + + +
+ ) +}; + +export const Sizes: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +export const States: Story = { + render: () => ( +
+ + + + +
+ ) +}; + +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: () => ( +
+ + + + +
+ ) +}; + +export const AsLink: Story = { + render: () => ( + + ) +}; + +export const Anatomy: Story = { + render: () => ( +
+
+

+ Button anatomy +

+ +
+

+ data-slot="root" on the + interactive surface. +

+

+ data-slot="icon" on the + loading spinner when present. +

+

+ data-slot="label" on the + visible button text. +

+

+ data-loading,{" "} + data-disabled,{" "} + data-size, and{" "} + data-variant drive stateful + styling. +

+
+
+
+ ) +}; diff --git a/packages/ui/package.json b/packages/ui/package.json index c5bcf8f..7200d90 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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": { diff --git a/packages/ui/src/components/button.tsx b/packages/ui/src/components/button.tsx new file mode 100644 index 0000000..cf39cad --- /dev/null +++ b/packages/ui/src/components/button.tsx @@ -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; + +function Spinner() { + return ( +