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 buttonpnpm dlx @work-rjkashyap/unified-ui add buttonnpx @work-rjkashyap/unified-ui add buttonbunx @work-rjkashyap/unified-ui add buttonIf 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-uipnpm add @work-rjkashyap/unified-uiyarn add @work-rjkashyap/unified-uibun add @work-rjkashyap/unified-uiManual 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-mergepnpm add class-variance-authority tailwind-mergeyarn add class-variance-authority tailwind-mergebun add class-variance-authority tailwind-mergeCopy the code below and paste it into your component folder.
"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.
| Size | Height | Padding | Font Size |
|---|---|---|---|
sm | 32px | 12px | 12px |
md | 36px | 16px | 14px |
lg | 40px | 20px | 14px |
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.
Link
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
| Prop | Type | Default | Description |
|---|---|---|---|
variant | "primary" | "secondary" | "ghost" | "danger" | "primary" | Visual variant of the button. |
size | "sm" | "md" | "lg" | "md" | Size of the button. |
fullWidth | boolean | false | Whether the button fills its container width. |
loading | boolean | false | Shows spinner and disables interaction. |
loadingText | string | — | Text to display alongside the loading spinner. |
iconLeft | ReactNode | — | Icon rendered before the label. |
iconRight | ReactNode | — | Icon rendered after the label. |
iconOnly | boolean | false | Renders a square button (requires aria-label). |
disabled | boolean | false | Disables the button. |
as | ElementType | "button" | The element or component to render as. |
className | string | — | Additional 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
disabledattribute andaria-disabled. - Loading — Sets
aria-busy="true"and disables interaction. - Icon-only — Requires
aria-labelfor screen readers. - Press feedback —
active: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:
| Token | Usage |
|---|---|
--primary | Primary variant background |
--primary-hover | Primary variant hover background |
--secondary | Secondary variant background |
--danger | Danger variant background |
--radius-md | Border radius |
--duration-fast | Transition speed for hover/focus |
--easing-standard | Easing curve for transitions |