For the complete documentation index, see 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.
1
Support

Button

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

Playground

Live preview
Customize Button
Customize
0.96
button-demo.tsx
import { Button } from "@/components/ui/button";

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

Installation

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

$ pnpm dlx shadcn@latest add https://vandor-ui.vercel.app/r/button.json

Dependencies

Installed automatically by the registry CLI. For manual setup:

Dependencies4 required packages
  • class-variance-authority
  • cn
  • motion
  • @base-ui/react
$ pnpm add class-variance-authority cn motion @base-ui/react

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.

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

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.

button-variants.tsx
import { Button } from "@/components/ui/button";

export const ButtonVariants = () => (
  <div className="flex flex-wrap items-center gap-2">
    <Button>Default</Button>
    <Button variant="secondary">Secondary</Button>
    <Button variant="outline">Outline</Button>
    <Button variant="ghost">Ghost</Button>
    <Button variant="destructive">Destructive</Button>
    <Button variant="link">Link</Button>
  </div>
);

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.

button-sizes.tsx
import { ArrowUpRightIcon } from "lucide-react";

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

export const ButtonSizes = () => (
  <div className="flex flex-wrap items-center gap-4">
    <div className="flex items-center gap-2">
      <Button variant="outline" size="xs">
        Extra small
      </Button>
      <Button variant="outline" size="icon-xs" aria-label="Open extra small">
        <ArrowUpRightIcon />
      </Button>
    </div>
    <div className="flex items-center gap-2">
      <Button variant="outline" size="sm">
        Small
      </Button>
      <Button variant="outline" size="icon-sm" aria-label="Open small">
        <ArrowUpRightIcon />
      </Button>
    </div>
    <div className="flex items-center gap-2">
      <Button variant="outline">Default</Button>
      <Button variant="outline" size="icon" aria-label="Open default">
        <ArrowUpRightIcon />
      </Button>
    </div>
    <div className="flex items-center gap-2">
      <Button variant="outline" size="lg">
        Large
      </Button>
      <Button variant="outline" size="icon-lg" aria-label="Open large">
        <ArrowUpRightIcon />
      </Button>
    </div>
  </div>
);

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.

button-icon.tsx
import { ArrowRightIcon, PlusIcon } from "lucide-react";

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

export const ButtonIcon = () => (
  <div className="flex flex-wrap items-center gap-2">
    <Button variant="outline">
      <PlusIcon data-icon="inline-start" /> Add item
    </Button>
    <Button>
      Continue <ArrowRightIcon data-icon="inline-end" />
    </Button>
    <Button variant="outline" size="icon" aria-label="Add item">
      <PlusIcon />
    </Button>
  </div>
);

Disabled

Use the native disabled prop to prevent interaction.

button-disabled.tsx
import { Button } from "@/components/ui/button";

export const ButtonDisabled = () => (
  <div className="flex flex-wrap items-center gap-2">
    <Button disabled>Disabled</Button>
    <Button variant="outline" disabled>
      Disabled outline
    </Button>
  </div>
);

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.

<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.

button-loading.tsx
import { PlusIcon } from "lucide-react";

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

export const ButtonLoading = () => (
  <div className="flex flex-wrap items-center gap-2">
    <Button isLoading>Save</Button>
    <Button isLoading variant="outline">
      Upload
    </Button>
    <Button isLoading size="icon" aria-label="Add item">
      <PlusIcon />
    </Button>
  </div>
);

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:

<Button
  render={<a href="/docs/components" />}
  nativeButton={false}
  variant="outline"
>
  Browse components
</Button>
button-as-child.tsx
import Link from "next/link";

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

export const ButtonAsChild = () => (
  <Button asChild variant="outline">
    <Link href="/docs/components">Browse components</Link>
  </Button>
);

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:

$ pnpm dlx 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

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:

"../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:

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

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 into button.stories.tsx beside your installed Button. Keep the relative import, or update it if you move the file elsewhere.

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.

<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

Component props, types, defaults, and descriptions
PropType / DescriptionDefault
childrenReactNode

Content inside the Button. The playground uses a text label, or an icon for icon-only sizes.

Not set
variant"default" | "secondary" | "outline" | "ghost" | "destructive" | "link"

Visual style. The link variant still renders a button unless asChild is used.

"default"
size"xs" | "sm" | "default" | "lg" | "icon-xs" | "icon-sm" | "icon" | "icon-lg"

Button dimensions. Give icon-only buttons an accessible name using aria-label.

"default"
disabledboolean

Prevents interaction and applies disabled styling to the native button.

false
asChildboolean

Uses the child element instead of a button, preserving the child's semantics. See the As Child example.

false
isLoadingboolean

Shows the Loading arc and prevents interaction. Keeps text labels; replaces icons for icon-only sizes. Sets aria-busy automatically.

false
classNamestring

Additional classes merged with the selected variant and size.

Not set
renderReactElement | ComponentRenderFn

Base UI composition: replace the button with an element or render function. For links, also set nativeButton={false}.

Not set
nativeButtonboolean

Whether the rendered element is a native button. Set false when render supplies a link or other non-button element.

true
type"button" | "submit" | "reset"

Native button type: "button", "submit", or "reset". Set type="submit" for form submission.

"button"
onClickMouseEventHandler<HTMLButtonElement>

Native React click handler. Add it in your application to handle actions.

Not set
aria-labelstring

Accessible name for icon-only buttons or buttons without visible text.

Not set
whileTapMotionProps["whileTap"] | false

Motion target while pressed. Set false to disable press feedback. Ignored when disabled or reduced motion is preferred.

{ scale: 0.96 }
transitionTransition

Motion transition for the press and release animation.

{ duration: 0.12, ease: "easeOut" }

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

"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

  • shadcn/ui: Original Button code and foundation. Vandor UI adds a raised finish, smooth hover transitions, configurable press feedback, and direct cn integration.