Button

Primary interaction trigger with five variants, four sizes, loading state, and polymorphic asChild support.

Button is the primary action trigger in DesignForge. It ships with five semantic variants, four size options, a built-in loading spinner, and asChild polymorphism via Radix UI Slot — so it can render as an <a>, Next.js <Link>, or any other component while keeping all button styles and accessibility attributes.

Installation

npm install @designforge/ui

Usage

import { Button } from "@designforge/ui";
 
export default function App() {
  return <Button>Save changes</Button>;
}

Examples

All variants

Five variants cover every common action type. default is your primary CTA; destructive for dangerous or irreversible actions; outline for secondary actions; ghost for low-emphasis actions in dense UIs; link for navigation-style actions that should look like hyperlinks.

import { Button } from "@designforge/ui";
 
export default function AllVariants() {
  return (
    <div className="flex flex-wrap gap-3 items-center">
      <Button variant="default">Default</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="outline">Outline</Button>
      <Button variant="ghost">Ghost</Button>
      <Button variant="link">Link</Button>
    </div>
  );
}

All sizes

Four sizes from compact (sm) to prominent (lg) plus an icon variant for square icon-only buttons.

import { Button } from "@designforge/ui";
import { Plus } from "lucide-react";
 
export default function AllSizes() {
  return (
    <div className="flex items-center gap-3">
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
      <Button size="icon" aria-label="Add item">
        <Plus />
      </Button>
    </div>
  );
}

With icons

Icons can be placed before or after the label text. Button automatically sizes <svg> children to 16×16 via [&_svg]:size-4.

import { Button } from "@designforge/ui";
import { Mail, Trash2, Plus, Download } from "lucide-react";
 
export default function WithIcons() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button>
        <Mail />
        Send email
      </Button>
      <Button variant="destructive">
        <Trash2 />
        Delete
      </Button>
      <Button variant="outline">
        <Plus />
        Add item
      </Button>
      <Button variant="ghost">
        <Download />
        Export
      </Button>
    </div>
  );
}

Loading state

Pass loading={true} to replace the content with an animated spinner and automatically disable the button. The spinner uses aria-hidden="true" and the button receives aria-disabled so screen readers announce the loading state correctly.

import { Button } from "@designforge/ui";
 
export default function Loading() {
  return (
    <div className="flex gap-3">
      <Button loading>Saving...</Button>
      <Button loading variant="outline">
        Loading
      </Button>
      <Button loading variant="destructive">
        Deleting...
      </Button>
    </div>
  );
}

When loading is true, disabled is also implicitly set to true — you do not need to pass both.

Disabled state

Use disabled for buttons whose action is unavailable in the current context. Disabled buttons are non-interactive and visually dimmed via disabled:opacity-50.

import { Button } from "@designforge/ui";
 
export default function Disabled() {
  return (
    <div className="flex gap-3">
      <Button disabled>Disabled</Button>
      <Button disabled variant="outline">
        Disabled outline
      </Button>
      <Button disabled variant="ghost">
        Disabled ghost
      </Button>
    </div>
  );
}

As a link — asChild

Use asChild with Radix UI Slot to render the button as an <a> element (or any other component) while keeping all button styles, sizes, and variants. The button's <button> wrapper is replaced by the child element.

import { Button } from "@designforge/ui";
 
export default function AsLink() {
  return (
    <Button asChild>
      <a href="https://designforge-storybook.vercel.app" target="_blank" rel="noreferrer">
        View Storybook →
      </a>
    </Button>
  );
}

As a Next.js Link

import Link from "next/link";
import { Button } from "@designforge/ui";
 
export default function AsNextLink() {
  return (
    <Button variant="outline" asChild>
      <Link href="/docs/installation">
        Get started
      </Link>
    </Button>
  );
}

Icon-only button

Use size="icon" for a perfectly square button. Always provide an aria-label so screen reader users understand the button's purpose.

import { Button } from "@designforge/ui";
import { Settings, Share2, Bookmark } from "lucide-react";
 
export default function IconButtons() {
  return (
    <div className="flex gap-2">
      <Button size="icon" variant="ghost" aria-label="Settings">
        <Settings />
      </Button>
      <Button size="icon" variant="outline" aria-label="Share">
        <Share2 />
      </Button>
      <Button size="icon" aria-label="Bookmark">
        <Bookmark />
      </Button>
    </div>
  );
}

Form submit button with loading

Combine loading with controlled state to give users feedback during form submission.

import { useState } from "react";
import { Button } from "@designforge/ui";
 
export default function FormSubmit() {
  const [loading, setLoading] = useState(false);
 
  async function handleSubmit() {
    setLoading(true);
    await new Promise((res) => setTimeout(res, 2000)); // simulate API call
    setLoading(false);
  }
 
  return (
    <Button loading={loading} onClick={handleSubmit}>
      {loading ? "Saving..." : "Save changes"}
    </Button>
  );
}

Destructive confirmation

Pair destructive with a confirmation dialog (see AlertDialog) to prevent accidental irreversible actions.

import { Button } from "@designforge/ui";
import { Trash2 } from "lucide-react";
 
export default function DestructiveAction() {
  return (
    <div className="flex gap-3">
      <Button variant="outline">Cancel</Button>
      <Button variant="destructive">
        <Trash2 />
        Delete account
      </Button>
    </div>
  );
}

API Reference

<Button>

Renders a <button> element by default. When asChild={true}, renders the first child element instead, merging all props and styles onto it via Radix UI Slot.

PropTypeDefaultDescription
variant"default" | "destructive" | "outline" | "ghost" | "link""default"Controls the visual style of the button.
size"sm" | "md" | "lg" | "icon""md"Controls the height, padding, and font size of the button. "icon" produces a 40×40 square for icon-only buttons.
loadingbooleanfalseWhen true, replaces the button content with an animated Loader2 spinner and sets the button to disabled. Use alongside descriptive loading text for best UX.
asChildbooleanfalseWhen true, uses Radix UI Slot to merge all button props and styles onto the single child element. Use this to render the button as an <a>, Link, or other component.
disabledbooleanfalsePrevents interaction. Pointer events are removed and opacity is reduced to 50%.
refRef<HTMLButtonElement>A React ref forwarded to the underlying <button> DOM node.
classNamestringAdditional CSS classes merged with the variant/size styles via cn().
childrenReactNodeButton label content. Can include text, icons, or any React nodes.

All other native <button> attributes (type, form, formAction, onClick, onFocus, aria-*, data-*, etc.) are forwarded to the underlying element.

Size reference:

SizeHeightPaddingFont size
sm36px (h-9)px-3text-xs (12px)
md40px (h-10)px-4 py-2text-sm (14px)
lg44px (h-11)px-8text-base (16px)
icon40×40px

Accessibility

  • Button renders as a native <button type="button"> — it is keyboard-focusable and operable via Enter or Space out of the box.
  • When loading={true}, aria-disabled is set on the button so screen readers announce the disabled state, while disabled prevents pointer interaction. The spinner icon is aria-hidden="true" to avoid being announced separately.
  • Icon-only buttons (size="icon") must include an aria-label — the icon alone provides no text for assistive technologies.
  • When using asChild with an <a> element, ensure the rendered anchor has an href attribute; otherwise it will not be keyboard-focusable in all browsers.
  • Focus styles use focus-visible:ring-2 — they only appear on keyboard navigation, not on mouse click, following the :focus-visible CSS spec.
  • Avoid placing interactive elements inside a Button — nested interactive content creates invalid HTML and keyboard navigation issues.

Live View

Here is a live contextual rendering of the component directly from our isolated Storybook environment.

Open in Storybook ↗