<bpdm/ui />
Form

Button

Trigger an action or event — accessible, themeable, in React (@bpdm/ui) and Angular (@bpdm/ng).

The Button triggers an action. It ships from one shared token set, so the React (@bpdm/ui) and Angular (@bpdm/ng) versions render identically — same colours, same motion (overshoot ease + a subtle press scale). It has two independent axes — variant (colour) and appearance (solid / outline / ghost) — plus size, shape, a loading state, and asChild.

Usage

In React, Button is a component; in Angular, bpdmButton is an attribute directive you put on a native <button> (or <a>), so disabled, focus and keyboard behaviour stay native. type defaults to button, so a button never submits a surrounding <form> by accident.

button-usage.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonUsage() {
  const save = () => console.log('saved');

  return <Button onClick={save}>Save changes</Button>;
}
button-usage.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-usage',
  imports: [BpdmButton],
  templateUrl: './button-usage.html',
})
export class ButtonUsage {
  save() {
    console.log('saved');
  }
}
button-usage.html
<button bpdmButton (click)="save()">Save changes</button>

Variants

The variant prop sets the semantic colour: primary, secondary, success, info, warning, help, destructive, or contrast.

button-variants.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonVariants() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button variant="primary">Primary</Button>
      <Button variant="secondary">Secondary</Button>
      <Button variant="success">Success</Button>
      <Button variant="info">Info</Button>
      <Button variant="warning">Warning</Button>
      <Button variant="help">Help</Button>
      <Button variant="destructive">Destructive</Button>
      <Button variant="contrast">Contrast</Button>
    </div>
  );
}
button-variants.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-variants',
  imports: [BpdmButton],
  templateUrl: './button-variants.html',
})
export class ButtonVariants {}
button-variants.html
<div class="flex flex-wrap gap-3">
  <button bpdmButton variant="primary">Primary</button>
  <button bpdmButton variant="secondary">Secondary</button>
  <button bpdmButton variant="success">Success</button>
  <button bpdmButton variant="info">Info</button>
  <button bpdmButton variant="warning">Warning</button>
  <button bpdmButton variant="help">Help</button>
  <button bpdmButton variant="destructive">Destructive</button>
  <button bpdmButton variant="contrast">Contrast</button>
</div>

Appearances

The appearance prop sets the style — solid (default, filled), outline (bordered with a transparent fill), and ghost (no border or fill, for low-emphasis actions and toolbars). It composes with every variant.

button-appearances.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonAppearances() {
  return (
    <div className="flex flex-wrap gap-3">
      <Button appearance="solid">Solid</Button>
      <Button appearance="outline">Outline</Button>
      <Button appearance="ghost">Ghost</Button>
    </div>
  );
}
button-appearances.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-appearances',
  imports: [BpdmButton],
  templateUrl: './button-appearances.html',
})
export class ButtonAppearances {}
button-appearances.html
<button bpdmButton appearance="solid">Solid</button>
<button bpdmButton appearance="outline">Outline</button>
<button bpdmButton appearance="ghost">Ghost</button>

Sizes

The size prop offers sm, md (default), and lg for text buttons.

button-sizes.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonSizes() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button size="sm">Small</Button>
      <Button size="md">Medium</Button>
      <Button size="lg">Large</Button>
    </div>
  );
}
button-sizes.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-sizes',
  imports: [BpdmButton],
  templateUrl: './button-sizes.html',
})
export class ButtonSizes {}
button-sizes.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton size="sm">Small</button>
  <button bpdmButton size="md">Medium</button>
  <button bpdmButton size="lg">Large</button>
</div>

Custom size

size="none" drops the preset height and padding, so you control the sizing entirely with your own classes (merged via tailwind-merge, so yours win).

button-custom-size.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonCustomSize() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button size="none" variant="secondary" appearance="outline" className="h-7 rounded-md px-2 text-xs">
        Tiny
      </Button>
      <Button size="none" variant="primary" className="h-14 rounded-2xl px-8 text-lg">
        Chunky
      </Button>
    </div>
  );
}
button-custom-size.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-custom-size',
  imports: [BpdmButton],
  templateUrl: './button-custom-size.html',
})
export class ButtonCustomSize {}
button-custom-size.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton size="none" variant="secondary" appearance="outline" class="h-7 rounded-md px-2 text-xs">
    Tiny
  </button>
  <button bpdmButton size="none" variant="primary" class="h-14 rounded-2xl px-8 text-lg">
    Chunky
  </button>
</div>

Shape

shape="round" makes a text button a pill, or an icon button a circle.

button-shape.tsx
import { Heart } from 'lucide-react';
import { Button } from '@bpdm/ui/button';

export function ButtonShape() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button shape="round">Pill button</Button>
      <Button shape="round" variant="success">Success</Button>
      <Button shape="round" size="icon" variant="secondary" appearance="outline" aria-label="Like">
        <Heart className="size-[18px]" />
      </Button>
    </div>
  );
}
button-shape.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-shape',
  imports: [BpdmButton],
  templateUrl: './button-shape.html',
})
export class ButtonShape {}
button-shape.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton shape="round">Pill button</button>
  <button bpdmButton shape="round" variant="success">Success</button>
  <button bpdmButton shape="round" size="icon" variant="secondary" appearance="outline" aria-label="Like">
    <svg viewBox="0 0 24 24" class="size-[18px]" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M20.8 4.6a5.5 5.5 0 0 0-7.8 0L12 5.6l-1-1a5.5 5.5 0 1 0-7.8 7.8l1 1L12 21l7.8-7.6 1-1a5.5 5.5 0 0 0 0-7.8Z" />
    </svg>
  </button>
</div>

With icon and text

Drop an icon into the children alongside the label — the button's base gap spaces it automatically, and the order mirrors correctly under RTL.

button-icon-text.tsx
import { Plus, ArrowRight } from 'lucide-react';
import { Button } from '@bpdm/ui/button';

export function ButtonIconText() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button>
        <Plus className="size-4" /> New item
      </Button>
      <Button variant="secondary" appearance="outline">
        Continue <ArrowRight className="size-4" />
      </Button>
    </div>
  );
}
button-icon-text.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-icon-text',
  imports: [BpdmButton],
  templateUrl: './button-icon-text.html',
})
export class ButtonIconText {}
button-icon-text.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton>
    <svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
      <path d="M12 5v14M5 12h14" />
    </svg>
    New item
  </button>
  <button bpdmButton variant="secondary" appearance="outline">
    Continue
    <svg viewBox="0 0 24 24" class="size-4" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M5 12h14M13 6l6 6-6 6" />
    </svg>
  </button>
</div>

Icon buttons

For square, icon-only buttons use the icon sizes iconSm, icon, and iconLg. Always set an aria-label since there's no visible text — a dev-mode warning fires if you forget.

Icon buttons work with every variant — pair shape="round" for circles:

button-icons.tsx
import { Plus, Star, Trash2 } from 'lucide-react';
import { Button } from '@bpdm/ui/button';

export function ButtonIcons() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button size="icon" shape="round" aria-label="Add">
        <Plus className="size-[18px]" />
      </Button>
      <Button size="icon" shape="round" variant="secondary" appearance="outline" aria-label="Favorite">
        <Star className="size-[18px]" />
      </Button>
      <Button size="icon" shape="round" variant="destructive" aria-label="Delete">
        <Trash2 className="size-[18px]" />
      </Button>
    </div>
  );
}
button-icons.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-icons',
  imports: [BpdmButton],
  templateUrl: './button-icons.html',
})
export class ButtonIcons {}
button-icons.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton size="icon" shape="round" aria-label="Add">
    <svg viewBox="0 0 24 24" class="size-[18px]" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
      <path d="M12 5v14M5 12h14" />
    </svg>
  </button>
  <button bpdmButton size="icon" shape="round" variant="secondary" appearance="outline" aria-label="Favorite">
    <svg viewBox="0 0 24 24" class="size-[18px]" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M12 2l2.9 6.3 6.9.6-5.2 4.5 1.6 6.8L12 17.3 5.8 20.8l1.6-6.8L2.2 9.5l6.9-.6L12 2Z" />
    </svg>
  </button>
  <button bpdmButton size="icon" shape="round" variant="destructive" aria-label="Delete">
    <svg viewBox="0 0 24 24" class="size-[18px]" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
      <path d="M3 6h18M8 6V4h8v2M19 6l-1 14H6L5 6" />
    </svg>
  </button>
</div>

Loading

Set loading to show a spinner, mark the button aria-busy, and block clicks while a request is in flight. The spinner is a leading indicator; the label stays for context. loadingLabel (default "Loading") is the screen-reader text announced while busy.

button-loading.tsx
import { useState } from 'react';
import { Button } from '@bpdm/ui/button';

export function ButtonLoading() {
  const [saving, setSaving] = useState(false);

  const save = () => {
    setSaving(true);
    // …await your request, then:
    setTimeout(() => setSaving(false), 1600);
  };

  return (
    <Button loading={saving} onClick={save}>
      {saving ? 'Saving…' : 'Save changes'}
    </Button>
  );
}
button-loading.ts
import { Component, signal } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-loading',
  imports: [BpdmButton],
  templateUrl: './button-loading.html',
})
export class ButtonLoading {
  readonly saving = signal(false);

  save() {
    this.saving.set(true);
    // …await your request, then:
    setTimeout(() => this.saving.set(false), 1600);
  }
}
button-loading.html
<button bpdmButton [loading]="saving()" (click)="save()">
  {{ saving() ? 'Saving…' : 'Save changes' }}
</button>

Disabled

Use the native disabled attribute — focus and pointer events are removed and the button dims automatically. (For asChild/anchor buttons, disabled is translated to aria-disabled + an untabbable, non-interactive link.)

button-disabled.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonDisabled() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button disabled>Disabled</Button>
      <Button disabled appearance="outline">Disabled</Button>
    </div>
  );
}
button-disabled.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-disabled',
  imports: [BpdmButton],
  templateUrl: './button-disabled.html',
})
export class ButtonDisabled {}
button-disabled.html
<div class="flex flex-wrap items-center gap-3">
  <button bpdmButton disabled>Disabled</button>
  <button bpdmButton appearance="outline" disabled>Disabled</button>
</div>

To render a link that looks like a button: in React pass asChild and nest an anchor (Radix Slot merges the styles onto it); in Angular put bpdmButton directly on an <a>. Note the role becomes link (not button), so Space no longer activates it — that's correct link behaviour.

button-link.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonLink() {
  return (
    <div className="flex flex-wrap items-center gap-3">
      <Button asChild>
        <a href="/docs">Open docs</a>
      </Button>
      <Button asChild variant="secondary" appearance="outline">
        <a href="https://github.com/BDev-9/bpdm-ui">GitHub</a>
      </Button>
    </div>
  );
}
button-link.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-link',
  imports: [BpdmButton],
  templateUrl: './button-link.html',
})
export class ButtonLink {}
button-link.html
<div class="flex flex-wrap items-center gap-3">
  <a bpdmButton href="/docs">Open docs</a>
  <a bpdmButton variant="secondary" appearance="outline" href="https://github.com/BDev-9/bpdm-ui">
    GitHub
  </a>
</div>

API

PropTypeDefaultDescription
variantprimary | secondary | success | info | warning | help | destructive | contrastprimarySemantic colour.
appearancesolid | outline | ghostsolidVisual weight — filled, bordered, or text-only.
sizesm | md | lg | iconSm | icon | iconLg | nonemdText sizes, square icon sizes, or none to bring your own.
shapedefault | rounddefaultCorner radius — token radius, or round (pill / circle).
loadingbooleanfalseShow a spinner, set aria-busy, and block interaction.
loadingLabelstringLoadingScreen-reader text announced while loading (i18n).
typebutton | submit | resetbuttonNative button type. Defaults to button so it never submits a form by accident (not applied to anchors).
asChildbooleanfalseReact only — render as the child element (e.g. a link) via Radix Slot.
className (React) / class (Angular)stringExtra classes, merged with the variant classes via tailwind-merge (yours win).

All native <button> attributes (onClick, disabled, form, …) are supported. In Angular these are inputs on the bpdmButton directive with identical names (except asChild, which is React-only — in Angular you apply the directive directly to an <a>).

Accessibility

Screen reader

The button renders a native <button> (React) or applies bpdmButton to a native <button>/<a> (Angular), so its role, focus, and keyboard behaviour come from the element itself. Text buttons are announced by their content; icon-only buttons have no text, so pass an aria-label that describes the action — a dev-mode warning fires when an icon* size ships without one.

button-a11y.tsx
import { Search } from 'lucide-react';
import { Button } from '@bpdm/ui/button';

export function ButtonA11y() {
  return (
    <div className="flex items-center gap-3">
      {/* the text content is the accessible name */}
      <Button>Submit</Button>

      {/* icon-only — describe the action with aria-label */}
      <Button size="icon" aria-label="Search">
        <Search className="size-[18px]" />
      </Button>
    </div>
  );
}
button-a11y.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-a11y',
  imports: [BpdmButton],
  templateUrl: './button-a11y.html',
})
export class ButtonA11y {}
button-a11y.html
<div class="flex items-center gap-3">
  <!-- the text content is the accessible name -->
  <button bpdmButton>Submit</button>

  <!-- icon-only — describe the action with aria-label -->
  <button bpdmButton size="icon" aria-label="Search">
    <svg viewBox="0 0 24 24" class="size-[18px]" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round">
      <circle cx="11" cy="11" r="7" />
      <path d="m21 21-4.3-4.3" />
    </svg>
  </button>
</div>

Notes

  • Focus: a visible focus ring (focus-visible:ring-2 with an offset) shows on keyboard focus only, not on mouse click.
  • type: defaults to button, so a button inside a <form> won't submit it — set type="submit" explicitly for the submit action.
  • loading: sets aria-busy="true", keeps the accessible name, appends the loadingLabel for screen readers, and blocks activation while busy.
  • As a link: asChild/<a bpdmButton> renders a link role — Space does not activate a link (that's correct), only Enter follows it.

Keyboard

KeyAction
TabMoves focus to the button.
EnterActivates the button (or follows the link).
SpaceActivates the button (buttons only, not links).

Internationalization

The Button's label is your own content, so there's nothing built-in to translate — except the loading state's screen-reader text. Provide a localized loadingLabel per locale (default "Loading").

Layout is direction-agnostic: it uses logical gap + flexbox and symmetric padding, so an icon + label pair mirrors automatically under dir="rtl" — no configuration needed.

button-i18n.tsx
import { Button } from '@bpdm/ui/button';

export function ButtonI18n() {
  return (
    <Button loading loadingLabel="Wird geladen">
      Speichern
    </Button>
  );
}
button-i18n.ts
import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';

@Component({
  selector: 'button-i18n',
  imports: [BpdmButton],
  templateUrl: './button-i18n.html',
})
export class ButtonI18n {}
button-i18n.html
<button bpdmButton loading loadingLabel="Wird geladen">Speichern</button>

On this page