Component
Stable v1.0.0 Updated Jul 2026 Interactive

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

Use button when
  • 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
Don't use button when
  • 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
Base class required: Every button must have .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.

Container
Icon
Label
1
Container
The outer element — always a <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).
2
Icon (optional)
Inline SVG, Heroicons outline. Always placed before the label. Size: 18px (lg), 16px (md), 14px (sm), 12px (xs). flex-shrink:0 always. stroke="currentColor" inherits button text color.
3
Label
Text content of the button. Font: Plus Jakarta Sans. Size: 15px (lg), 14px (md), 12px (sm), 11px (xs). Weight: 600. White-space: nowrap. Never truncate button labels.
4
Loading Indicator
A 14px spinner rendered as a <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.
5
Focus Ring
Visible on keyboard focus only (:focus-visible). Style: outline: 2px solid #6366F1; outline-offset: 2px. Never hidden. Required for accessibility. Not shown on mouse click.

Variants

Primary
Main CTA. One per section maximum. bg:#6366F1
Outline
Secondary action. Pairs with primary.
Danger
Destructive actions only. Always needs confirm modal.
Success
Positive confirmations. Approve, complete.
Ghost
Tertiary. Minimal visual weight. No hover lift.
Dark
Dark accent role CTA. bg:#0F172A.

Sizes

Large
.btn-lg
Default
.btn (no modifier)
Small
.btn-sm
Extra Small
.btn-xs
lg · 15px · 13px 26px
md · 14px · 10px 20px
sm · 12px · 6px 12px
xs · 11px · 4px 10px
SizeClassFontPaddingRadiusMin HeightUse When
Large.btn-lg15px fw-60013px 26px13px48pxHero CTAs, prominent page actions
Default (md).btn14px fw-60010px 20px12px44pxStandard page actions, modal footers
Small.btn-sm12px fw-6006px 12px8px36pxTable row actions, filter bars, toolbars
Extra Small.btn-xs11px fw-6004px 10px7px28pxInline 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.

Default
Resting state. bg:#6366F1, shadow, cursor:pointer
Hover
translateY(-2px), deeper shadow, bg darkens
Focus
2px #6366F1 outline, 2px offset. :focus-visible only
Active
scale(.98), no translateY. Tactile press feel
Disabled
opacity:.45, cursor:not-allowed, no hover effect
Loading
Spinner + label change. pointer-events:none
Outline Hover
border+text → #6366F1, bg → #EEF2FF
Outline Disabled
opacity:.45, same visual weight

Properties

Complete API reference. In HTML, properties map to CSS classes and native attributes. In React, they map to component props.

PropertyTypeRequiredDefaultDescriptionAccepted 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

KeyAction
TabMove focus to button
Shift + TabMove focus away from button
EnterActivate button (same as click)
SpaceActivate button (same as click)

ARIA Requirements

ScenarioRequired AttributeExample
Icon-only buttonaria-label<button aria-label="Delete user">
Loading statearia-busy="true"Announces "busy" to screen readers
Disabled stateNative disabled attrNever use aria-disabled alone — native disabled prevents all interaction
Toggle buttonaria-pressedaria-pressed="true|false"
Opens dialogaria-haspopup="dialog"Announces the intent to open dialog
Expands contentaria-expandedaria-expanded="true|false"
Touch target: Minimum 44×44px on touch devices. Default md button height is 44px. btn-sm is 36px — add extra padding wrapper on mobile if needed.

Behavior

TriggerBehavior
ClickFires onClick handler. If type="submit", submits parent form. If loading or disabled, click is blocked entirely (pointer-events:none).
HovertranslateY(-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/Pressscale(0.98) + translateY(0). Removes the hover lift on press. Creates tactile pressed feeling.
Disabledopacity:0.45, cursor:not-allowed, pointer-events:none. No hover/focus visual changes. Screen reader still announces as disabled.
Loading startAdd .btn-loading class + prepend spinner element + update label text (e.g. "Save" → "Saving..."). Block clicks immediately.
Loading endRemove .btn-loading + remove spinner + restore original label. Show success/error toast as feedback.
Destructive clickMUST open confirmation modal before executing. Never fire delete/destructive action directly from button click.
Form submittype="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.

Allowed Composition
Modal footer — Cancel (outline) + Confirm (primary or danger)
Form footer — Cancel (outline) + Submit (primary)
Table toolbar — Export (outline) + Add Item (primary)
Card action — single ghost or outline CTA
Page header — primary CTA right-aligned
Icon-only buttons in toolbars and sidebars
Forbidden Composition
Two primary buttons side by side — ambiguous hierarchy
Primary button inside another button — invalid HTML
Danger button without confirmation modal
More than one primary CTA per section
Icon-only button without aria-label
Button inside a table cell (use ghost-sm instead)

Real Composition Examples

Modal Footer (cancel left, confirm right)
Destructive Modal Footer
Table Toolbar
User Directory

Layout Rules

Gap (icon + label)
8px (md), 6px (sm), 5px (xs)
Min touch target
44×44px (mobile)
Alignment
inline-flex, center
Width
auto (hug content)
Full width
width:100% on container
White-space
nowrap — never wrap
RuleDetail
Button groupsUse display:flex; gap:8px for button groups. Never use margin on individual buttons.
Modal footer alignmentAlways justify-content:flex-end. Cancel left, confirm right. Gap: 12px.
Full-width buttonsApply width:100% to the button element directly. Used in auth forms and mobile CTAs.
Page header placementPrimary CTA always right-aligned in page header. Use margin-left:auto.
Vertical rhythmButtons sit on the same baseline as adjacent text. Use align-items:center in flex containers.
ResponsiveOn 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.

Save Changes
Names the action and implies the object
Submit
Generic, doesn't say what is being submitted
Delete User
Names the entity being deleted
Delete
Ambiguous — delete what?
Add User
Verb + object. Clear and scannable.
Click Here
Describes the interaction not the outcome
Cancel
Standard, universally understood dismissal
Never mind / Go back / Close
Inconsistent — always use "Cancel"
PatternLabelNotes
Create action+ Add [Entity]Always start with verb. Use "Add" not "Create" or "New".
Save actionSave [Entity] / Save ChangesUse "Save" not "Update" or "Confirm".
DestructiveDelete [Entity]Always name the entity. Use "Delete" not "Remove" or "Erase".
ExportExport CSV / Export PDFSpecify the format in the label.
Cancel/dismissCancelNever use "Close", "Back", or "No".
Loading stateSaving... / Deleting...Add "..." suffix. Change verb to present progressive.

Best Practices

Do
One primary CTA, one secondary. Clear hierarchy. Cancel on left, confirm on right.
Don't
Two primary buttons create ambiguity. User doesn't know which action is preferred.
Do
Specific label + icon. User knows exactly what happens.
Don't
Vague label. "Click Here" describes the interaction, not the outcome.

Common Mistakes

MistakeProblemCorrect Approach
Two primary buttonsCreates visual ambiguity, user doesn't know the preferred actionOne primary max per section. Use outline for secondary.
No confirmation for deleteDestructive action fires immediately, can't be undoneAlways open a confirm modal with .btn-danger before deleting
Using div/span as buttonNo keyboard support, no accessibility, breaks focus managementAlways use native <button> element
Removing focus ringBreaks keyboard navigation, WCAG failureNever add outline:none — use :focus-visible only
Icon-only without aria-labelScreen reader users can't understand the button's purposeAlways add aria-label="[action]" to icon-only buttons
Just disabling on asyncUser gets no feedback that action is in progressAdd .btn-loading + spinner + change label to "Saving..."
Mixing button sizes in a groupVisual inconsistency breaks hierarchyAll buttons in a group must be the same size
Using full-size buttons in tablesTakes too much space, clutters the rowUse .btn-ghost-sm for table row actions
Hardcoded colorsBreaks theming, diverges from design systemNever use inline color styles. Use .btn-{variant} classes only.
Button wrapping textBreaks layout, inconsistent appearanceButton labels use white-space:nowrap — shorten the label instead

Real Examples

User Directory
Table Toolbar
Outline (secondary) + Primary CTA
Confirm Delete Modal
Outline (cancel) + Danger (confirm)
Form Submit Loading
Full-width primary in loading state
Table Row Actions
Ghost-sm icon-only buttons in table
Dark Card Actions
Translucent white variant on dark bg
Hero CTA Group
Primary + ghost-outline on hero bg

Code Examples

HTML — All Variants
HTML — With Icon + Loading + Disabled

AI Implementation Guide

This section is designed for AI coding assistants. It describes the Button component as a complete engineering specification so it can be implemented without ambiguity.
AspectSpecification
ElementAlways 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.
Variantsprimary | outline | danger | success | ghost | dark — applied as .btn-{variant}
Sizeslg | md (default, no modifier) | sm | xs — applied as .btn-{size}
FontPlus Jakarta Sans, 14px (md), font-weight: 600. Never change font family or weight.
Hover behaviorALL variants: translateY(-2px). Exception: ghost — no lift, background only. Transition: 0.2s cubic-bezier(0.4,0,0.2,1).
Active behaviorALL variants: scale(0.98), translateY(0). Creates tactile press.
Focus ringNEVER remove. outline: 2px solid #6366F1; outline-offset: 2px on :focus-visible.
Icon sizinglg: 18px | md: 16px | sm: 14px | xs: 12px. Always inline SVG, stroke="currentColor".
Icon placementAlways BEFORE label. Never after. Gap: 8px (md), 6px (sm), 5px (xs).
Loading patternAdd .btn-loading + prepend .btn-spinner span + update label text. DO NOT just disable.
Destructive ruleDanger buttons MUST trigger a confirmation modal. NEVER fire delete directly.
Form submitUse type="submit" inside forms. Use type="button" for all other buttons.
Icon-onlyAdd .btn-icon-only class + aria-label attribute. No label text.
ColorsPrimary: #6366F1 (accent) | Dark: #0F172A | Never hardcode. Use .btn-{variant} classes.

Common AI Mistakes

MistakeCorrect
Using <div onclick> as buttonAlways use native <button type="button">
Adding only .btn-primary without .btnAlways include base .btn class: class="btn btn-primary"
Two primary buttons side by sideOne primary max. Use outline for secondary.
Firing delete without modalAlways open confirm modal with .btn-danger before any destructive action
Using Tailwind bg-indigo-600 for buttonUse .btn-primary class — never override with utility classes
Icon-only without aria-labelAdd aria-label="[action description]" to every icon-only button
Just disabling during asyncAdd .btn-loading + spinner element + change label text

Design Decisions

Why does hover lift the button (translateY)?
The upward movement creates a sense of physical elevation — the button "rises to meet" the cursor. This provides clear hover feedback without changing color dramatically, and creates a satisfying, responsive feel. Ghost buttons are excluded because their low visual weight means a lift would feel disproportionate.
Why is border-radius 12px for the default size?
12px sits between sharp (4px) and pill (999px). It feels modern and friendly without being too casual. It also scales proportionally — larger buttons get 13px, smaller buttons get 8px and 7px, maintaining visual harmony across sizes.
Why does the primary button use #6366F1 (indigo) not pure blue?
Indigo sits between blue and violet on the spectrum, giving it more sophistication than standard blue (#3B82F6) while remaining professional. It also differentiates the design system from generic Material/Tailwind-default blue that saturates most web UIs.
Why is ghost button the exception to hover lift?
Ghost buttons have no visual "weight" — they're transparent. Lifting a weightless object feels wrong physically. Instead, ghost buttons gain a subtle background on hover (#F8FAFC), which provides feedback through color change rather than movement.
Why must danger buttons always open a confirm modal?
Destructive actions cannot be undone. A single misclick would cause permanent data loss. The confirmation step is a deliberate friction point — it protects users from accidental destruction while keeping the button visually available (not hidden or disabled).

Changelog

v1.0.0 July 2026 Added
  • 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