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.
import { Button } from '@bpdm/ui/button';
export function ButtonUsage() {
const save = () => console.log('saved');
return <Button onClick={save}>Save changes</Button>;
}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 bpdmButton (click)="save()">Save changes</button>Variants
The variant prop sets the semantic colour: primary, secondary, success,
info, warning, help, destructive, or contrast.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-variants',
imports: [BpdmButton],
templateUrl: './button-variants.html',
})
export class ButtonVariants {}<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.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-appearances',
imports: [BpdmButton],
templateUrl: './button-appearances.html',
})
export class ButtonAppearances {}<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.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-sizes',
imports: [BpdmButton],
templateUrl: './button-sizes.html',
})
export class ButtonSizes {}<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).
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>
);
}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 {}<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.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-shape',
imports: [BpdmButton],
templateUrl: './button-shape.html',
})
export class ButtonShape {}<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.
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>
);
}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 {}<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:
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-icons',
imports: [BpdmButton],
templateUrl: './button-icons.html',
})
export class ButtonIcons {}<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.
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>
);
}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 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.)
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-disabled',
imports: [BpdmButton],
templateUrl: './button-disabled.html',
})
export class ButtonDisabled {}<div class="flex flex-wrap items-center gap-3">
<button bpdmButton disabled>Disabled</button>
<button bpdmButton appearance="outline" disabled>Disabled</button>
</div>As a link
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.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-link',
imports: [BpdmButton],
templateUrl: './button-link.html',
})
export class ButtonLink {}<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
| Prop | Type | Default | Description |
|---|---|---|---|
variant | primary | secondary | success | info | warning | help | destructive | contrast | primary | Semantic colour. |
appearance | solid | outline | ghost | solid | Visual weight — filled, bordered, or text-only. |
size | sm | md | lg | iconSm | icon | iconLg | none | md | Text sizes, square icon sizes, or none to bring your own. |
shape | default | round | default | Corner radius — token radius, or round (pill / circle). |
loading | boolean | false | Show a spinner, set aria-busy, and block interaction. |
loadingLabel | string | Loading | Screen-reader text announced while loading (i18n). |
type | button | submit | reset | button | Native button type. Defaults to button so it never submits a form by accident (not applied to anchors). |
asChild | boolean | false | React only — render as the child element (e.g. a link) via Radix Slot. |
className (React) / class (Angular) | string | — | Extra 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.
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>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-a11y',
imports: [BpdmButton],
templateUrl: './button-a11y.html',
})
export class ButtonA11y {}<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-2with an offset) shows on keyboard focus only, not on mouse click. type: defaults tobutton, so a button inside a<form>won't submit it — settype="submit"explicitly for the submit action.loading: setsaria-busy="true", keeps the accessible name, appends theloadingLabelfor screen readers, and blocks activation while busy.- As a link:
asChild/<a bpdmButton>renders alinkrole — Space does not activate a link (that's correct), only Enter follows it.
Keyboard
| Key | Action |
|---|---|
| Tab | Moves focus to the button. |
| Enter | Activates the button (or follows the link). |
| Space | Activates 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.
import { Button } from '@bpdm/ui/button';
export function ButtonI18n() {
return (
<Button loading loadingLabel="Wird geladen">
Speichern
</Button>
);
}import { Component } from '@angular/core';
import { BpdmButton } from '@bpdm/ng';
@Component({
selector: 'button-i18n',
imports: [BpdmButton],
templateUrl: './button-i18n.html',
})
export class ButtonI18n {}<button bpdmButton loading loadingLabel="Wird geladen">Speichern</button>