Material Design 3 Button web component — framework-agnostic, built with Lit.
An accessible M3 Button web component following the Material Design 3 button specifications. Features expressive styling, 5 variants, 5 sizes, shape morphing, loading states, and full accessibility support. Works in Angular, React, Vue, Svelte, or plain HTML — no build step required.
- ✅ 5 Button Variants: Filled, Elevated, Tonal, Outlined, and Text
- 📐 5 Size Options: Extra-small to Extra-large (Material 3 Expressive)
- 🔷 2 Shape Styles: Round and Square with dynamic morphing on press
- 📏 Flexible Padding: Default (24dp) and Small (16dp) options
- ♿ Fully Accessible: WCAG 2.1 compliant with ARIA support and keyboard navigation
- 🎨 Material Design 3: Follows official M3 specifications and design tokens
- 🔄 Loading State: Built-in loading spinner with proper ARIA attributes
- 📱 Touch-friendly: Minimum 48x48px touch target
- 🎯 Icon Support: Optional leading icons with proper spacing
- 🌐 Framework-agnostic: Works with React, Angular, Vue, or vanilla JavaScript
- 🎭 Customizable: CSS custom properties for theming
npm install @banegasn/m3-button
# or
pnpm add @banegasn/m3-button
# or
yarn add @banegasn/m3-button<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>M3 Button Demo</title>
<script type="module" src="https://cdn.jsdelivr.net/npm/@banegasn/m3-button/+esm"></script>
<style>
body { font-family: Roboto, sans-serif; padding: 32px; background: #fef7ff; }
.row { display: flex; gap: 12px; flex-wrap: wrap; align-items: center; margin-bottom: 24px; }
</style>
</head>
<body>
<div class="row">
<m3-button variant="filled">Filled</m3-button>
<m3-button variant="elevated">Elevated</m3-button>
<m3-button variant="tonal">Tonal</m3-button>
<m3-button variant="outlined">Outlined</m3-button>
<m3-button variant="text">Text</m3-button>
</div>
<div class="row">
<m3-button variant="filled" size="extra-large" shape="round">Get Started</m3-button>
<m3-button variant="outlined" size="small" disabled>Disabled</m3-button>
<m3-button variant="tonal" id="loading-btn">Click to load</m3-button>
</div>
<script>
const btn = document.getElementById('loading-btn');
btn.addEventListener('button-click', async () => {
btn.loading = true;
await new Promise(r => setTimeout(r, 2000));
btn.loading = false;
});
</script>
</body>
</html>Material Design 3 provides five button types for different emphasis levels:
The highest emphasis button for primary actions.
<m3-button variant="filled">Filled Button</m3-button>
<!-- or simply -->
<m3-button>Filled Button</m3-button>Medium-high emphasis with subtle elevation shadow.
<m3-button variant="elevated">Elevated Button</m3-button>Medium emphasis with tinted background.
<m3-button variant="tonal">Tonal Button</m3-button>Medium emphasis with border outline.
<m3-button variant="outlined">Outlined Button</m3-button>Low emphasis for less important actions.
<m3-button variant="text">Text Button</m3-button>Choose from five size options to match your design needs:
<!-- Extra Small (32px) - Compact layouts -->
<m3-button size="extra-small">Extra Small</m3-button>
<!-- Small (40px) - Default size -->
<m3-button size="small">Small</m3-button>
<m3-button>Small (default)</m3-button>
<!-- Medium (48px) - Increased prominence -->
<m3-button size="medium">Medium</m3-button>
<!-- Large (56px) - High emphasis -->
<m3-button size="large">Large</m3-button>
<!-- Extra Large (64px) - Hero actions -->
<m3-button size="extra-large">Extra Large</m3-button>Round (default) or square corners with dynamic morphing animation:
<!-- Round shape with fully rounded corners (default) -->
<m3-button shape="round">Round Button</m3-button>
<m3-button>Round (default)</m3-button>
<!-- Square shape with minimal rounding -->
<m3-button shape="square">Square Button</m3-button>Shape Morphing: Buttons dynamically morph their shape when pressed:
- Round buttons become less round (60% of original radius)
- Square buttons become more round (150% of original radius)
Choose between default and compact padding:
<!-- Default padding (24dp) - Traditional spacing -->
<m3-button padding="default">Default Padding</m3-button>
<m3-button>Default (default)</m3-button>
<!-- Small padding (16dp) - Recommended for new designs -->
<m3-button padding="small">Small Padding</m3-button>Mix and match size, shape, and padding for maximum expressiveness:
<!-- Hero CTA -->
<m3-button variant="filled" size="extra-large" shape="round" padding="small">
Get Started
</m3-button>
<!-- Modern card action -->
<m3-button variant="tonal" size="medium" shape="square" padding="small">
Continue
</m3-button>
<!-- Compact toolbar button -->
<m3-button icon-only size="extra-small" shape="square" padding="small" aria-label="Edit">
<svg slot="icon" viewBox="0 0 24 24" width="16" height="16">...</svg>
</m3-button>Add an icon using the icon slot:
<m3-button>
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
Add Item
</m3-button>For buttons with only an icon (requires aria-label for accessibility):
<m3-button icon-only aria-label="Add item">
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</m3-button>Show loading spinner while processing:
<m3-button id="submit-btn">Submit</m3-button>
<script>
const btn = document.getElementById('submit-btn');
btn.addEventListener('button-click', async () => {
btn.loading = true;
try {
await submitForm();
} finally {
btn.loading = false;
}
});
</script><m3-button disabled>Disabled Button</m3-button><m3-button full-width>Full Width Button</m3-button><form id="my-form">
<input type="text" name="username" required />
<m3-button type="submit" form="my-form">Submit</m3-button>
<m3-button type="reset" variant="text">Reset</m3-button>
</form>| Property | Type | Default | Description |
|---|---|---|---|
variant |
'filled' | 'elevated' | 'tonal' | 'outlined' | 'text' |
'filled' |
Button style variant |
size |
'extra-small' | 'small' | 'medium' | 'large' | 'extra-large' |
'small' |
Button size (M3 Expressive) |
shape |
'round' | 'square' |
'round' |
Button corner shape with morphing on press |
padding |
'default' | 'small' |
'default' |
Horizontal padding (24dp or 16dp) |
disabled |
boolean |
false |
Disables the button |
loading |
boolean |
false |
Shows loading spinner and disables interaction |
full-width |
boolean |
false |
Makes button full width |
icon-only |
boolean |
false |
Button contains only an icon (hides label) |
type |
'button' | 'submit' | 'reset' |
'button' |
Button type for form handling |
aria-label |
string |
undefined |
Accessible label (required for icon-only) |
name |
string |
undefined |
Name for form submission |
value |
string |
undefined |
Value for form submission |
form |
string |
undefined |
Associates button with a form by ID |
| Event | Detail | Description |
|---|---|---|
button-click |
{ variant: string, size: string, shape: string, padding: string, name?: string, value?: string } |
Fired when button is clicked (not fired when disabled or loading) |
| Slot | Description |
|---|---|
| (default) | Button label text |
icon |
Optional icon (18x18px recommended) |
| Method | Description |
|---|---|
focus() |
Programmatically focus the button |
blur() |
Remove focus from the button |
| Property | Default | Description |
|---|---|---|
--md-button-container-height |
Varies by size* | Height of the button |
--md-button-container-shape |
Varies by shape/size* | Border radius |
--md-button-label-text-size |
Varies by size* | Font size of label |
--md-button-label-text-weight |
500 |
Font weight of label |
--md-button-icon-size |
Varies by size* | Size of the icon |
--md-button-spacing |
24dp or 16dp* |
Horizontal padding |
--md-sys-color-primary |
#6750a4 |
Primary color |
--md-sys-color-on-primary |
#ffffff |
Text color on primary |
--md-sys-color-secondary-container |
#e8def8 |
Secondary container color |
--md-sys-color-on-secondary-container |
#1d192b |
Text on secondary container |
--md-sys-color-surface-container-low |
#f7f2fa |
Surface color for elevated |
--md-sys-color-outline |
#79747e |
Border color for outlined |
Size-based defaults:
- Extra Small: 32px height, 16px icon, 12px text
- Small (default): 40px height, 18px icon, 14px text
- Medium: 48px height, 20px icon, 16px text
- Large: 56px height, 24px icon, 18px text
- Extra Large: 64px height, 28px icon, 20px text
Shape-based border radius:
- Round (default): Fully rounded (50% of height)
- Square: Minimal rounding (4-14px based on size)
This component follows WCAG 2.1 Level AA guidelines and Material Design 3 accessibility standards:
- Enter/Space: Activates the button
- Tab: Moves focus to/from the button
- Focus indicator visible via
:focus-visible
- Proper button role and label
- Loading state announced via
aria-busy - Disabled state properly conveyed
- Icon-only buttons require
aria-label
- Minimum 48x48px touch target (includes invisible padding)
- Visual feedback on hover and press states
- No pointer events when disabled
<!-- ✅ Good: Icon-only with aria-label -->
<m3-button icon-only aria-label="Close dialog">
<svg slot="icon">...</svg>
</m3-button>
<!-- ❌ Bad: Icon-only without aria-label -->
<m3-button icon-only>
<svg slot="icon">...</svg>
</m3-button>
<!-- ✅ Good: Descriptive text -->
<m3-button>Delete item</m3-button>
<!-- ❌ Bad: Ambiguous text -->
<m3-button>Click here</m3-button>Customize the button appearance with CSS custom properties:
/* Global theme */
:root {
--md-sys-color-primary: #0066cc;
--md-sys-color-on-primary: #ffffff;
--md-sys-color-secondary-container: #d6e3ff;
--md-sys-color-on-secondary-container: #001a41;
}
/* Per-component customization */
m3-button {
--md-button-container-height: 48px;
--md-button-container-shape: 24px;
--md-button-label-text-size: 16px;
}
/* Specific instance */
m3-button.large {
--md-button-container-height: 56px;
--md-button-spacing: 32px;
}<m3-button id="my-btn">Click me</m3-button>
<script type="module">
import '@banegasn/m3-button';
const btn = document.getElementById('my-btn');
btn.addEventListener('button-click', (e) => {
console.log('Button clicked!', e.detail);
});
</script>import '@banegasn/m3-button';
function App() {
const handleClick = (e) => {
console.log('Clicked!', e.detail);
};
return (
<m3-button
variant="filled"
onbutton-click={handleClick}
>
Click me
</m3-button>
);
}// app.component.ts
import { Component, CUSTOM_ELEMENTS_SCHEMA } from '@angular/core';
import '@banegasn/m3-button';
@Component({
selector: 'app-root',
template: `
<m3-button
variant="filled"
(button-click)="handleClick($event)"
>
Click me
</m3-button>
`,
schemas: [CUSTOM_ELEMENTS_SCHEMA]
})
export class AppComponent {
handleClick(event: CustomEvent) {
console.log('Clicked!', event.detail);
}
}<template>
<m3-button
variant="filled"
@button-click="handleClick"
>
Click me
</m3-button>
</template>
<script setup>
import '@banegasn/m3-button';
const handleClick = (event) => {
console.log('Clicked!', event.detail);
};
</script><script>
import '@banegasn/m3-button';
function handleClick(event) {
console.log('Clicked!', event.detail);
}
</script>
<m3-button
variant="filled"
on:button-click={handleClick}
>
Click me
</m3-button><!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>M3 Button Demo</title>
<style>
body {
font-family: Roboto, system-ui, sans-serif;
padding: 24px;
background: #fef7ff;
}
.demo-section {
margin-bottom: 32px;
}
.button-group {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
h2 {
margin-bottom: 16px;
color: #1d1b20;
}
</style>
<script type="module">
import '@banegasn/m3-button';
</script>
</head>
<body>
<h1>Material Design 3 Button Demo</h1>
<div class="demo-section">
<h2>Button Variants</h2>
<div class="button-group">
<m3-button variant="filled">Filled</m3-button>
<m3-button variant="elevated">Elevated</m3-button>
<m3-button variant="tonal">Tonal</m3-button>
<m3-button variant="outlined">Outlined</m3-button>
<m3-button variant="text">Text</m3-button>
</div>
</div>
<div class="demo-section">
<h2>With Icons</h2>
<div class="button-group">
<m3-button variant="filled">
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
Add Item
</m3-button>
<m3-button variant="elevated">
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M9 16.2L4.8 12l-1.4 1.4L9 19 21 7l-1.4-1.4L9 16.2z"/>
</svg>
Confirm
</m3-button>
</div>
</div>
<div class="demo-section">
<h2>Icon-Only Buttons</h2>
<div class="button-group">
<m3-button icon-only aria-label="Add" variant="filled">
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/>
</svg>
</m3-button>
<m3-button icon-only aria-label="Edit" variant="tonal">
<svg slot="icon" viewBox="0 0 24 24" width="18" height="18">
<path fill="currentColor" d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/>
</svg>
</m3-button>
</div>
</div>
<div class="demo-section">
<h2>States</h2>
<div class="button-group">
<m3-button disabled>Disabled</m3-button>
<m3-button id="loading-btn">Loading Demo</m3-button>
</div>
</div>
<div class="demo-section">
<h2>Full Width</h2>
<m3-button full-width variant="filled">Full Width Button</m3-button>
</div>
<script>
// Add click handlers
document.querySelectorAll('m3-button').forEach(btn => {
btn.addEventListener('button-click', (e) => {
console.log('Button clicked:', e.detail);
});
});
// Loading demo
const loadingBtn = document.getElementById('loading-btn');
loadingBtn.addEventListener('button-click', async () => {
loadingBtn.loading = true;
await new Promise(resolve => setTimeout(resolve, 2000));
loadingBtn.loading = false;
});
</script>
</body>
</html>Works in all modern browsers that support:
- Web Components (Custom Elements v1)
- Shadow DOM v1
- ES Modules
- @banegasn/m3-card - Material Design 3 Card
- @banegasn/m3-navigation-rail - Material Design 3 Navigation Rail
- @banegasn/m3-split-button - Material Design 3 Split Button
MIT
