Unified UI

Button

The primary interactive element for triggering actions. Supports 4 variants, 3 sizes, loading state, icon support, and polymorphic rendering.

Basic

A button allows users to perform actions through clicks, presses, taps, or keystrokes.

Installation

Install the component via the CLI in one command.

npx @work-rjkashyap/unified-ui add button
pnpm dlx @work-rjkashyap/unified-ui add button
npx @work-rjkashyap/unified-ui add button
bunx @work-rjkashyap/unified-ui add button

If you haven't initialized your project yet, run npx @work-rjkashyap/unified-ui init first. See the CLI docs for details.

Or install the full package

Use this approach if you prefer to install the entire design system as a dependency instead of copying individual components.

npm install @work-rjkashyap/unified-ui
pnpm add @work-rjkashyap/unified-ui
yarn add @work-rjkashyap/unified-ui
bun add @work-rjkashyap/unified-ui

Manual installation

Use this approach if you prefer to install and wire up the component yourself instead of using the CLI.

npm install class-variance-authority tailwind-merge
pnpm add class-variance-authority tailwind-merge
yarn add class-variance-authority tailwind-merge
bun add class-variance-authority tailwind-merge

Copy the code below and paste it into your component folder.

button.tsx
"use client";

import { twMerge as cn } from "tailwind-merge";
import { cva, type VariantProps } from "class-variance-authority";
import { type ElementType, forwardRef, type ReactNode } from "react";

const focusRingClasses =
  "focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background focus-visible:outline-none";

export const buttonVariants = cva(
  [
    "not-prose no-underline",
    "inline-flex items-center justify-center gap-2",
    "text-sm font-medium leading-5",
    "rounded-md",
    "transition-[color,background-color,border-color,box-shadow,opacity,transform]",
    "duration-fast ease-standard",
    focusRingClasses,
    "disabled:pointer-events-none disabled:opacity-50",
    "cursor-pointer disabled:cursor-not-allowed",
    "select-none",
    "active:scale-[0.98] disabled:active:scale-100",
  ],
  {
    variants: {
      variant: {
        primary: [
          "bg-primary text-primary-foreground",
          "hover:bg-primary-hover",
          "active:bg-primary-active",
        ],
        secondary: [
          "bg-secondary text-secondary-foreground",
          "border border-border",
          "hover:bg-secondary-hover",
          "active:bg-secondary-active",
        ],
        ghost: [
          "bg-transparent text-foreground",
          "hover:bg-muted hover:text-foreground",
          "active:bg-secondary-active",
        ],
        danger: [
          "bg-danger text-danger-foreground",
          "hover:bg-danger-hover",
          "active:bg-danger-active",
        ],
      },
      size: {
        sm: "h-8 px-3 text-xs gap-1.5",
        md: "h-9 px-4 text-sm gap-2",
        lg: "h-10 px-5 text-sm gap-2",
      },
      fullWidth: { true: "w-full", false: "" },
      iconOnly: { true: "!px-0", false: "" },
    },
    compoundVariants: [
      { iconOnly: true, size: "sm", className: "w-8" },
      { iconOnly: true, size: "md", className: "w-9" },
      { iconOnly: true, size: "lg", className: "w-10" },
    ],
    defaultVariants: {
      variant: "primary",
      size: "md",
      fullWidth: false,
      iconOnly: false,
    },
  },
);

function ButtonSpinner({ className }: { className?: string }) {
  return (
    <svg className={cn("animate-spin size-4", className)} xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" aria-hidden="true">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
    </svg>
  );
}

export type ButtonVariant = "primary" | "secondary" | "ghost" | "danger";
export type ButtonSize = "sm" | "md" | "lg";

export interface ButtonProps
  extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, "disabled">,
    VariantProps<typeof buttonVariants> {
  variant?: ButtonVariant;
  size?: ButtonSize;
  fullWidth?: boolean;
  loading?: boolean;
  loadingText?: string;
  iconLeft?: ReactNode;
  iconRight?: ReactNode;
  iconOnly?: boolean;
  disabled?: boolean;
  as?: ElementType;
  children?: ReactNode;
  className?: string;
}

export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
  function Button(
    {
      variant = "primary",
      size = "md",
      fullWidth = false,
      loading = false,
      loadingText,
      iconLeft,
      iconRight,
      iconOnly = false,
      disabled = false,
      as: Component = "button",
      className,
      children,
      ...rest
    },
    ref,
  ) {
    const isDisabled = disabled || loading;
    const iconSizeClass = size === "sm" ? "[&>svg]:size-3.5" : "[&>svg]:size-4";

    return (
      <Component
        ref={ref}
        type={Component === "button" ? "button" : undefined}
        disabled={isDisabled}
        aria-disabled={isDisabled || undefined}
        aria-busy={loading || undefined}
        className={cn(
          buttonVariants({ variant, size, fullWidth, iconOnly }),
          iconSizeClass,
          className,
        )}
        {...rest}
      >
        {loading && <ButtonSpinner className={size === "sm" ? "size-3.5" : "size-4"} />}
        {loading && loadingText ? (
          <span>{loadingText}</span>
        ) : (
          <>
            {!loading && iconLeft && <span className="shrink-0" aria-hidden="true">{iconLeft}</span>}
            {children && <span className={cn(loading && !loadingText && "invisible")}>{children}</span>}
            {!loading && iconRight && <span className="shrink-0" aria-hidden="true">{iconRight}</span>}
          </>
        )}
      </Component>
    );
  },
);

Button.displayName = "Button";

Anatomy

import { Button } from "@work-rjkashyap/unified-ui";
import { Settings, Trash2 } from "lucide-react";
<>
  {/* Variants */}
  <Button variant="primary">Primary</Button>
  <Button variant="secondary">Secondary</Button>
  <Button variant="ghost">Ghost</Button>
  <Button variant="danger">Danger</Button>

  {/* Sizes */}
  <Button size="sm">Small</Button>
  <Button size="md">Medium</Button>
  <Button size="lg">Large</Button>

  {/* With icons */}
  <Button iconLeft={<Settings />}>Settings</Button>
  <Button iconRight={<Trash2 />}>Delete</Button>

  {/* Icon-only */}
  <Button iconOnly aria-label="Settings">
    <Settings className="size-4" />
  </Button>
</>

Variants

Buttons come in four variants, each with its own color scheme for different levels of prominence and intent.

Size

Buttons are available in three sizes designed to align with Input and Select heights.

SizeHeightPaddingFont Size
sm32px12px12px
md36px16px14px
lg40px20px14px

With icon

You can add icons to buttons using iconLeft and iconRight props. Their color will match the button's color automatically.

Icon-only

Square buttons for compact actions like toolbars and table rows. Requires aria-label for accessibility.

Disabled

A disabled button cannot be interacted with and is visually styled to reflect its state.

Loading

A button can indicate a loading state using the loading prop. This shows a spinner, disables interaction, and sets aria-busy="true" for assistive technologies.

Full width

Stretches the button to fill its container. Useful for forms and mobile layouts.

Use the as prop to render the button as an anchor or any other element like Next.js Link.

Combined

All features can be composed together for real-world use cases.


Props

PropTypeDefaultDescription
variant"primary" | "secondary" | "ghost" | "danger""primary"Visual variant of the button.
size"sm" | "md" | "lg""md"Size of the button.
fullWidthbooleanfalseWhether the button fills its container width.
loadingbooleanfalseShows spinner and disables interaction.
loadingTextstringText to display alongside the loading spinner.
iconLeftReactNodeIcon rendered before the label.
iconRightReactNodeIcon rendered after the label.
iconOnlybooleanfalseRenders a square button (requires aria-label).
disabledbooleanfalseDisables the button.
asElementType"button"The element or component to render as.
classNamestringAdditional CSS classes.

Accessibility

Icon-only buttons must include an aria-label to be accessible to screen readers.

  • Focus ring — Visible on keyboard navigation via focus-visible.
  • Disabled — Uses both disabled attribute and aria-disabled.
  • Loading — Sets aria-busy="true" and disables interaction.
  • Icon-only — Requires aria-label for screen readers.
  • Press feedbackactive:scale-[0.98] provides tactile feedback.
  • Type safety — Defaults to type="button" to prevent accidental form submission.

Design Tokens

The Button uses the following design system tokens:

TokenUsage
--primaryPrimary variant background
--primary-hoverPrimary variant hover background
--secondarySecondary variant background
--dangerDanger variant background
--radius-mdBorder radius
--duration-fastTransition speed for hover/focus
--easing-standardEasing curve for transitions

On this page