# Button

Vandor UI's Button with a subtle raised finish, smooth hover transitions, and configurable press feedback.

> For the complete documentation index, see [llms.txt](/llms.txt). Markdown variants are available by appending `.md` to any URL or sending an `Accept: text/markdown` header. An agent skill is available at [/.well-known/agent-skills/site-skill.md](/.well-known/agent-skills/site-skill.md).





## Playground [#playground]

```tsx
import { Button } from "@/components/ui/button";

export const ButtonDemo = () => (
  <Button>
    {"Button"}
  </Button>
);
```

## Installation [#installation]

Initialize shadcn in your React project, then install this component from the Vandor UI registry:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/button.json
```

## Dependencies [#dependencies]

Installed automatically by the registry CLI. For manual setup:

```bash
npm install class-variance-authority cn motion @base-ui/react
```

## Usage [#usage]

The destructive variant uses `text-destructive-foreground`. CLI installation declares `--destructive-foreground` for both light and dark themes and maps it through Tailwind 4's `--color-destructive-foreground`. Manual installation must supply the same semantic token alongside `--destructive`; do not rely on this documentation website's stylesheet. Customized palettes must keep foreground/background contrast readable, including the dark destructive surface.

```tsx
import { Button } from "@/components/ui/button";

export function Demo() {
  return <Button variant="outline">Button</Button>;
}
```

Use your project's configured UI alias if it differs from `@/components/ui`.

## Examples [#examples]

### Variants [#variants]

The six built-in variants are `default`, `secondary`, `outline`, `ghost`, `destructive`, and `link`.

Solid variants use a subtle top-to-bottom gradient and thin border, without shadows. Outline keeps its border; ghost and link stay flat. All variants retain a keyboard focus ring.

<ComponentPreview name="button-variants" title="button-variants.tsx">
  <ButtonVariants />
</ComponentPreview>

### Sizes [#sizes]

Use `xs`, `sm`, `default`, or `lg` for text buttons. Icon-only buttons use `icon-xs`, `icon-sm`, `icon`, or `icon-lg`. Give icon-only buttons an accessible label.

<ComponentPreview name="button-sizes" title="button-sizes.tsx">
  <ButtonSizes />
</ComponentPreview>

### With Icons [#with-icons]

Use `data-icon="inline-start"` or `data-icon="inline-end"` to indicate an icon's position. Icon sizing is handled by the Button.

<ComponentPreview name="button-icon" title="button-icon.tsx">
  <ButtonIcon />
</ComponentPreview>

### Disabled [#disabled]

Use the native `disabled` prop to prevent interaction.

<ComponentPreview name="button-disabled" title="button-disabled.tsx">
  <ButtonDisabled />
</ComponentPreview>

### Loading [#loading]

Set `isLoading` while an action is pending. The Button shows the Loading component's default `arc` before its label, becomes disabled, and sets `aria-busy`. Icon-only sizes replace their icon with the spinner and retain the supplied `aria-label`.

```tsx
<Button isLoading={isSaving}>Save</Button>
```

Installing Button includes the small licensed `loading-arc.tsx` supporting primitive, not the entire Loading animation catalog. The public Loading item remains separately installable and shares this default Arc primitive. The loading indicator inherits the button's color and respects reduced-motion preferences. The playground's **Is Loading** toggle updates both the preview and generated code.

<ComponentPreview name="button-loading" title="button-loading.tsx">
  <ButtonLoading />
</ComponentPreview>

### As Child [#as-child]

Use `asChild` to style a link without nesting it inside a button. The child retains its native link semantics. Use this for navigation, not the `variant="link"` appearance alone.

Base UI's `render` composition is also supported:

```tsx
<Button
  render={<a href="/docs/components" />}
  nativeButton={false}
  variant="outline"
>
  Browse components
</Button>
```

<ComponentPreview name="button-as-child" title="button-as-child.tsx">
  <ButtonAsChild />
</ComponentPreview>

## Storybook Stories (Optional) [#storybook-stories-optional]

Add editable stories to **your existing React Storybook**. This is a stories file, not a hosted Storybook or a replacement for your configuration. The normal Button installation does not include it.

Install Button first using the Installation instructions above, then run:

```bash
npx shadcn@latest add https://vandor-ui.vercel.app/r/button-stories.json
```

The stories-only item installs `button.stories.tsx` beside Button in your configured UI directory. It does not reinstall Button or Loading, add packages, or modify `.storybook/`. If a stories file already exists, review the CLI diff rather than overwriting your customizations.

The stories import `Button` relatively (`./button`) and use only `@storybook/react` types and `lucide-react` icons. Ensure these packages are available in your project; keep `@storybook/react` on the same version as the rest of your Storybook packages. The stories are typechecked and composed against Storybook React 10.6.1; other versions are not verified. A working Button and its `loading-arc.tsx` supporting module remain required.

### Discover the stories [#discover-the-stories]

Include your UI directory in the `stories` patterns of your existing `.storybook/main.ts`. For the default directory, add this entry without replacing your existing patterns:

```ts
"../components/ui/**/*.stories.@(js|jsx|mjs|ts|tsx)";
```

Adjust the path if your UI directory differs. In `.storybook/preview.ts`, import your application's global stylesheet so Tailwind utilities and the Button theme tokens are available:

```ts
import "../styles/globals.css";
```

Use your own stylesheet path and ensure your Storybook framework processes Tailwind CSS, including the stories directory in source scanning. The stories inherit your existing decorators and theme setup. For dark mode, apply your application's dark-theme selector inside the preview iframe; changing only the Storybook manager theme will not theme the component. The `autodocs` tag requires `@storybook/addon-docs` configured in your Storybook to show a generated Docs page; Canvas stories and Controls do not require that addon.

### Customize and compare [#customize-and-compare]

Under **Vandor UI / Button**, use **Playground** to edit the label, variant, size, disabled/loading state, custom classes, and accessible label. Set an `aria-label` when selecting an icon-only size. Dedicated stories show all six variants, all eight sizes, disabled/loading states, icons, native link composition, and disabled press animation. Multi-example stories disable controls for the props they demonstrate explicitly.

Edit your installed `button.tsx` to customize the component, and edit `button.stories.tsx` to add application-specific examples or rename the sidebar group. Hover, focus, and pressed states can be inspected directly in Canvas; use your browser or OS reduced-motion setting to check motion preferences. These stories do not configure automated visual regression testing.

For manual installation, copy the `content` of the stories file from the [stories registry artifact](https://vandor-ui.vercel.app/r/button-stories.json) into `button.stories.tsx` beside your installed Button. Keep the relative import, or update it if you move the file elsewhere.

## Behavior & Accessibility [#behavior--accessibility]

The Button scales to `0.96` while pressed and returns on release, with a `0.12s` transition. Its appearance darkens by 10% while active, preserving the gradient. Hover colors and press brightness transition over `200ms`. Disabled buttons do not darken or animate. Reduced-motion preferences disable scaling and CSS transitions, but retain the instant color feedback.

Set `whileTap={false}` to turn off the press animation. Active color feedback remains enabled. Motion's animation props and callbacks are supported.

```tsx
<Button whileTap={false}>No scale feedback</Button>
```

* Native buttons support Enter and Space activation. The press animation uses Motion's pointer and Enter gestures.
* A visible focus ring identifies keyboard focus. Give icon-only buttons an `aria-label`.
* Buttons default to `type="button"`. Set `type="submit"` for form submission.
* With `asChild`, the child retains its own keyboard behavior and attributes. Use links for navigation; native `disabled` does not disable a link.
* `isLoading` blocks clicks, including on `asChild` links, and disables press feedback. The spinner is decorative; the button retains its accessible name and communicates the pending state through `aria-busy`.

## Props [#props]

| Prop         | Type                                                                               | Default                              | Description                                                                                                                          |
| ------------ | ---------------------------------------------------------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------ |
| children     | ReactNode                                                                          | Not set                              | Content inside the Button. The playground uses a text label, or an icon for icon-only sizes.                                         |
| variant      | "default" \| "secondary" \| "outline" \| "ghost" \| "destructive" \| "link"        | "default"                            | Visual style. The link variant still renders a button unless asChild is used.                                                        |
| size         | "xs" \| "sm" \| "default" \| "lg" \| "icon-xs" \| "icon-sm" \| "icon" \| "icon-lg" | "default"                            | Button dimensions. Give icon-only buttons an accessible name using aria-label.                                                       |
| disabled     | boolean                                                                            | false                                | Prevents interaction and applies disabled styling to the native button.                                                              |
| asChild      | boolean                                                                            | false                                | Uses the child element instead of a button, preserving the child's semantics. See the As Child example.                              |
| isLoading    | boolean                                                                            | false                                | Shows the Loading arc and prevents interaction. Keeps text labels; replaces icons for icon-only sizes. Sets aria-busy automatically. |
| className    | string                                                                             | Not set                              | Additional classes merged with the selected variant and size.                                                                        |
| render       | ReactElement \| ComponentRenderFn                                                  | Not set                              | Base UI composition: replace the button with an element or render function. For links, also set nativeButton=\{false}.               |
| nativeButton | boolean                                                                            | true                                 | Whether the rendered element is a native button. Set false when render supplies a link or other non-button element.                  |
| type         | "button" \| "submit" \| "reset"                                                    | "button"                             | Native button type: "button", "submit", or "reset". Set type="submit" for form submission.                                           |
| onClick      | MouseEventHandler\<HTMLButtonElement>                                              | Not set                              | Native React click handler. Add it in your application to handle actions.                                                            |
| aria-label   | string                                                                             | Not set                              | Accessible name for icon-only buttons or buttons without visible text.                                                               |
| whileTap     | MotionProps\["whileTap"] \| false                                                  | \{ scale: 0.96 }                     | Motion target while pressed. Set false to disable press feedback. Ignored when disabled or reduced motion is preferred.              |
| transition   | Transition                                                                         | \{ duration: 0.12, ease: "easeOut" } | Motion transition for the press and release animation.                                                                               |

The reference lists component-specific props and selected native props, not the complete React API. Other native attributes are forwarded to the rendered element.

## Source [#source]

```tsx
"use client";

import { Button as BaseButton } from "@base-ui/react/button";
import { useRender } from "@base-ui/react/use-render";
import { cva } from "class-variance-authority";
import type { VariantProps } from "class-variance-authority";
import { cn } from "cn";
import { motion, useReducedMotion } from "motion/react";
import type { HTMLMotionProps } from "motion/react";
import type * as React from "react";
import { cloneElement, isValidElement } from "react";

import { LoadingArc } from "./loading-arc";

const buttonVariants = cva(
  "inline-flex shrink-0 cursor-pointer items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-[background-color,color,border-color,box-shadow,filter] duration-200 ease-out motion-reduce:transition-none [&:active:not([disabled]):not([aria-disabled=true])]:brightness-90 outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
  {
    defaultVariants: {
      size: "default",
      variant: "default",
    },
    variants: {
      size: {
        default: "h-9 px-4 py-2 has-[>svg]:px-3",
        icon: "size-9",
        "icon-lg": "size-10",
        "icon-sm": "size-8",
        "icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
        lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
        sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
        xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
      },
      variant: {
        default:
          "border border-primary border-t-primary/70 bg-primary bg-linear-to-b from-white/10 to-black/20 text-primary-foreground hover:bg-primary/90",
        destructive:
          "border border-destructive border-t-destructive/70 bg-destructive bg-linear-to-b from-white/10 to-black/10 text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
        ghost:
          "hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
        link: "text-primary underline-offset-4 hover:underline",
        outline:
          "border bg-background hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
        secondary:
          "border border-input bg-secondary bg-linear-to-b from-white/10 to-black/10 text-secondary-foreground hover:bg-secondary/80",
      },
    },
  }
);

const Slot = ({ children, ref, ...props }: React.ComponentProps<"button">) =>
  useRender({
    props,
    ref,
    render: children as React.ReactElement,
  });
const MotionSlot = motion.create(Slot);
const MotionButton = motion.create(BaseButton);

const blockLoadingClick = (event: React.MouseEvent) => {
  event.preventDefault();
  event.stopPropagation();
};

type ButtonProps = Omit<HTMLMotionProps<"button">, "children" | "whileTap"> &
  VariantProps<typeof buttonVariants> & {
    asChild?: boolean;
    isLoading?: boolean;
    render?: React.ComponentProps<typeof BaseButton>["render"];
    nativeButton?: boolean;
    children?: React.ReactNode;
    whileTap?: HTMLMotionProps<"button">["whileTap"] | false;
  };

const Button = ({
  className,
  variant = "default",
  size = "default",
  asChild = false,
  isLoading = false,
  children,
  disabled,
  whileTap = { scale: 0.96 },
  transition = { duration: 0.12, ease: "easeOut" },
  ...props
}: ButtonProps) => {
  const shouldReduceMotion = useReducedMotion();
  const Comp = asChild ? MotionSlot : MotionButton;
  const isDisabled = disabled || isLoading;
  const iconOnly = size?.startsWith("icon");
  const loadingContent = (content: React.ReactNode) => (
    <>
      <LoadingArc
        size={size === "xs" || size === "icon-xs" ? 12 : 16}
        data-icon="inline-start"
        aria-hidden="true"
      />
      {iconOnly ? null : content}
    </>
  );
  let content = children;
  if (isLoading) {
    content =
      asChild && isValidElement<React.ComponentProps<"button">>(children)
        ? cloneElement(children, {
            "aria-busy": true,
            "aria-disabled": true,
            children: loadingContent(children.props.children),
            onClickCapture: blockLoadingClick,
          })
        : loadingContent(children);
  }

  return (
    <Comp
      data-slot="button"
      data-variant={variant}
      data-size={size}
      className={cn(buttonVariants({ className, size, variant }))}
      {...props}
      disabled={isDisabled}
      aria-busy={isLoading ? true : props["aria-busy"]}
      aria-disabled={isLoading ? true : props["aria-disabled"]}
      onClickCapture={isLoading ? blockLoadingClick : props.onClickCapture}
      transition={transition}
      whileTap={
        isDisabled || shouldReduceMotion || whileTap === false
          ? undefined
          : whileTap
      }
    >
      {content}
    </Comp>
  );
};

export { Button, buttonVariants };

```

## Credits [#credits]

* [shadcn/ui](https://ui.shadcn.com/docs/components/base/button): Original Button code and foundation. Vandor UI adds a raised finish, smooth hover transitions, configurable press feedback, and direct cn integration.
