Button
Buttons trigger actions or navigate users to a new context. They communicate what happens when tapped and establish a clear visual hierarchy through variant, size, and placement. Every interactive action in the system uses a button.
Overview
When to Use
- User needs to trigger an action (save, delete, submit)
- User needs to navigate to a new page or context
- A clear call-to-action is needed in a form or dialog
- Confirming or cancelling a destructive operation
- Triggering a modal, dropdown, or overlay
- Linking within body text — use a text link instead
- Displaying status — use a badge instead
- Showing options in a list — use a select or dropdown
- Tab-based navigation — use a tabs component
- There are more than 3–4 CTAs in one area — reconsider hierarchy
.btn as its base class plus one variant class. Never use a bare <button> without classes. Never apply Tailwind utility classes to buttons.Live Preview
Anatomy
The button is composed of 5 parts. Each part has a defined role, style, and behavior.
<button> or <a>. Has the background color, border-radius, padding, and shadow. Receives hover and focus states. Border-radius: 12px (md), 8px (sm), 7px (xs), 13px (lg).flex-shrink:0 always. stroke="currentColor" inherits button text color.<span class="btn-spinner">. Replaces or precedes the icon. Border-color matches button variant — white for filled buttons, indigo for outline. Added programmatically on async action start.:focus-visible). Style: outline: 2px solid #6366F1; outline-offset: 2px. Never hidden. Required for accessibility. Not shown on mouse click.Variants
Sizes
| Size | Class | Font | Padding | Radius | Min Height | Use When |
|---|---|---|---|---|---|---|
| Large | .btn-lg | 15px fw-600 | 13px 26px | 13px | 48px | Hero CTAs, prominent page actions |
| Default (md) | .btn | 14px fw-600 | 10px 20px | 12px | 44px | Standard page actions, modal footers |
| Small | .btn-sm | 12px fw-600 | 6px 12px | 8px | 36px | Table row actions, filter bars, toolbars |
| Extra Small | .btn-xs | 11px fw-600 | 4px 10px | 7px | 28px | Inline chips, dense list actions |
States
Every state has defined visual treatment and behavioral impact. Always communicate state changes visually — never rely on label text alone.
Properties
Complete API reference. In HTML, properties map to CSS classes and native attributes. In React, they map to component props.
| Property | Type | Required | Default | Description | Accepted Values |
|---|---|---|---|---|---|
| variant | string | optional | 'primary' | Visual style of the button. Controls background, text color, shadow. | 'primary' | 'outline' | 'danger' | 'success' | 'ghost' | 'dark' |
| size | string | optional | 'md' | Controls padding, font-size, and border-radius. | 'lg' | 'md' | 'sm' | 'xs' |
| disabled | boolean | optional | false | Disables interaction. Adds opacity:.45, cursor:not-allowed. Use HTML disabled attribute — do NOT use aria-disabled alone. | true | false |
| loading | boolean | optional | false | Shows loading spinner, blocks clicks. Add .btn-loading class + spinner element + change label text. Do NOT just disable. | true | false |
| type | string | optional | 'button' | HTML button type. Always set explicitly. Use 'submit' for form submission, 'button' for everything else. | 'button' | 'submit' | 'reset' |
| icon | ReactNode | optional | undefined | SVG icon rendered before label. Heroicons outline, sized per button size (lg:18px, md:16px, sm:14px, xs:12px). | SVG element |
| iconOnly | boolean | optional | false | Removes horizontal padding, making button square. MUST have aria-label for accessibility. | true | false |
| onClick | function | optional | undefined | Click handler. Not needed for type="submit" buttons inside forms. | (event: MouseEvent) => void |
| className | string | optional | undefined | Additional CSS classes. Should only extend, never override base button styles. | any CSS class string |
Accessibility
Keyboard Navigation
| Key | Action |
|---|---|
| Tab | Move focus to button |
| Shift + Tab | Move focus away from button |
| Enter | Activate button (same as click) |
| Space | Activate button (same as click) |
ARIA Requirements
| Scenario | Required Attribute | Example |
|---|---|---|
| Icon-only button | aria-label | <button aria-label="Delete user"> |
| Loading state | aria-busy="true" | Announces "busy" to screen readers |
| Disabled state | Native disabled attr | Never use aria-disabled alone — native disabled prevents all interaction |
| Toggle button | aria-pressed | aria-pressed="true|false" |
| Opens dialog | aria-haspopup="dialog" | Announces the intent to open dialog |
| Expands content | aria-expanded | aria-expanded="true|false" |
Behavior
| Trigger | Behavior |
|---|---|
| Click | Fires onClick handler. If type="submit", submits parent form. If loading or disabled, click is blocked entirely (pointer-events:none). |
| Hover | translateY(-2px) + deeper shadow (all variants except ghost). Ghost: background changes to #F8FAFC, no lift. Transition: 0.2s cubic-bezier(0.4,0,0.2,1). |
| Focus (:focus-visible) | 2px solid #6366F1 outline, 2px offset. Only shown on keyboard navigation, not mouse click. Never remove or hide focus ring. |
| Active/Press | scale(0.98) + translateY(0). Removes the hover lift on press. Creates tactile pressed feeling. |
| Disabled | opacity:0.45, cursor:not-allowed, pointer-events:none. No hover/focus visual changes. Screen reader still announces as disabled. |
| Loading start | Add .btn-loading class + prepend spinner element + update label text (e.g. "Save" → "Saving..."). Block clicks immediately. |
| Loading end | Remove .btn-loading + remove spinner + restore original label. Show success/error toast as feedback. |
| Destructive click | MUST open confirmation modal before executing. Never fire delete/destructive action directly from button click. |
| Form submit | type="submit" triggers native form validation before firing. type="button" never triggers form submission. |
Composition
Buttons must be placed intentionally. Incorrect placement creates hierarchy confusion and accessibility issues.
Real Composition Examples
Layout Rules
| Rule | Detail |
|---|---|
| Button groups | Use display:flex; gap:8px for button groups. Never use margin on individual buttons. |
| Modal footer alignment | Always justify-content:flex-end. Cancel left, confirm right. Gap: 12px. |
| Full-width buttons | Apply width:100% to the button element directly. Used in auth forms and mobile CTAs. |
| Page header placement | Primary CTA always right-aligned in page header. Use margin-left:auto. |
| Vertical rhythm | Buttons sit on the same baseline as adjacent text. Use align-items:center in flex containers. |
| Responsive | On mobile (<768px), consider full-width primary CTAs in forms. Table row ghost buttons remain the same size. |
Content Rules
Button labels communicate what happens. They must be specific, action-oriented, and name the object being acted on.
| Pattern | Label | Notes |
|---|---|---|
| Create action | + Add [Entity] | Always start with verb. Use "Add" not "Create" or "New". |
| Save action | Save [Entity] / Save Changes | Use "Save" not "Update" or "Confirm". |
| Destructive | Delete [Entity] | Always name the entity. Use "Delete" not "Remove" or "Erase". |
| Export | Export CSV / Export PDF | Specify the format in the label. |
| Cancel/dismiss | Cancel | Never use "Close", "Back", or "No". |
| Loading state | Saving... / Deleting... | Add "..." suffix. Change verb to present progressive. |
Best Practices
Common Mistakes
| Mistake | Problem | Correct Approach |
|---|---|---|
| Two primary buttons | Creates visual ambiguity, user doesn't know the preferred action | One primary max per section. Use outline for secondary. |
| No confirmation for delete | Destructive action fires immediately, can't be undone | Always open a confirm modal with .btn-danger before deleting |
| Using div/span as button | No keyboard support, no accessibility, breaks focus management | Always use native <button> element |
| Removing focus ring | Breaks keyboard navigation, WCAG failure | Never add outline:none — use :focus-visible only |
| Icon-only without aria-label | Screen reader users can't understand the button's purpose | Always add aria-label="[action]" to icon-only buttons |
| Just disabling on async | User gets no feedback that action is in progress | Add .btn-loading + spinner + change label to "Saving..." |
| Mixing button sizes in a group | Visual inconsistency breaks hierarchy | All buttons in a group must be the same size |
| Using full-size buttons in tables | Takes too much space, clutters the row | Use .btn-ghost-sm for table row actions |
| Hardcoded colors | Breaks theming, diverges from design system | Never use inline color styles. Use .btn-{variant} classes only. |
| Button wrapping text | Breaks layout, inconsistent appearance | Button labels use white-space:nowrap — shorten the label instead |
Real Examples
Code Examples
AI Implementation Guide
| Aspect | Specification |
|---|---|
| Element | Always native <button> or <a>. Never div, span, or any non-interactive element. |
| Base class | .btn is required on every button. Adding only a variant class without .btn is invalid. |
| Variants | primary | outline | danger | success | ghost | dark — applied as .btn-{variant} |
| Sizes | lg | md (default, no modifier) | sm | xs — applied as .btn-{size} |
| Font | Plus Jakarta Sans, 14px (md), font-weight: 600. Never change font family or weight. |
| Hover behavior | ALL variants: translateY(-2px). Exception: ghost — no lift, background only. Transition: 0.2s cubic-bezier(0.4,0,0.2,1). |
| Active behavior | ALL variants: scale(0.98), translateY(0). Creates tactile press. |
| Focus ring | NEVER remove. outline: 2px solid #6366F1; outline-offset: 2px on :focus-visible. |
| Icon sizing | lg: 18px | md: 16px | sm: 14px | xs: 12px. Always inline SVG, stroke="currentColor". |
| Icon placement | Always BEFORE label. Never after. Gap: 8px (md), 6px (sm), 5px (xs). |
| Loading pattern | Add .btn-loading + prepend .btn-spinner span + update label text. DO NOT just disable. |
| Destructive rule | Danger buttons MUST trigger a confirmation modal. NEVER fire delete directly. |
| Form submit | Use type="submit" inside forms. Use type="button" for all other buttons. |
| Icon-only | Add .btn-icon-only class + aria-label attribute. No label text. |
| Colors | Primary: #6366F1 (accent) | Dark: #0F172A | Never hardcode. Use .btn-{variant} classes. |
Common AI Mistakes
| Mistake | Correct |
|---|---|
Using <div onclick> as button | Always use native <button type="button"> |
| Adding only .btn-primary without .btn | Always include base .btn class: class="btn btn-primary" |
| Two primary buttons side by side | One primary max. Use outline for secondary. |
| Firing delete without modal | Always open confirm modal with .btn-danger before any destructive action |
| Using Tailwind bg-indigo-600 for button | Use .btn-primary class — never override with utility classes |
| Icon-only without aria-label | Add aria-label="[action description]" to every icon-only button |
| Just disabling during async | Add .btn-loading + spinner element + change label text |
Related Components
Design Decisions
Changelog
- Initial release — 6 variants (primary, outline, danger, success, ghost, dark)
- 4 sizes (lg, md, sm, xs) with proportional radius scaling
- Loading state with spinner animation
- Icon support with per-size sizing rules
- Icon-only variant with accessibility requirements
- Full keyboard navigation and focus management
- WCAG AA compliant contrast ratios across all variants