SVG Icons for Buttons and CTAs: Placement, Sizing, and Accessibility
The definitive guide to using SVG icons inside buttons — leading vs trailing placement, sizing rules, icon-only buttons, loading state swaps, and WCAG-compliant accessibility.
Icons inside buttons are one of the highest-leverage UI details you can get right. A leading arrow makes a CTA feel directional. A trailing chevron signals a menu will open. An icon-only button without an aria-label is a WCAG failure. The rules are small — but the impact compounds across every button in your product.
The four button-icon patterns
| Pattern | When to use | Example |
|---|---|---|
| Leading icon | Identifies the action type | [Download] Export CSV |
| Trailing icon | Indicates what happens next | Continue [→] |
| Icon-only | Dense UI, clear context | [✕] close button |
| Loading state | After async action fires | [spinner] Saving… |
Icon sizing relative to button text
The icon should match the cap height of the button label, not the full font size. A text-sm button (14px) uses a 16px icon, not a 14px one — because icons rendered at the same numeric size as font look smaller due to their padding within the viewBox.
Icon Size Advisor
Pick your UI context and get a practical icon size recommendation.
Practical sizing table
| Button size | Font size | Icon size | Gap (gap-*) |
|---|---|---|---|
| xs | 12px | 14px | gap-1 (4px) |
| sm | 14px | 16px | gap-1.5 (6px) |
| md (default) | 14–16px | 18px | gap-2 (8px) |
| lg | 16–18px | 20px | gap-2 (8px) |
| xl | 20px | 22–24px | gap-2.5 (10px) |
// Medium button — 18px icon, gap-2
<button className="flex items-center gap-2 px-4 py-2 text-sm font-medium
bg-blue-600 text-white rounded-lg hover:bg-blue-700 active:bg-blue-800">
<Download size={18} />
<span>Export CSV</span>
</button>
Never place icons and text in a flex container without items-center. A 1px vertical misalignment is always visible and always looks like a bug.
Leading vs trailing — which to choose
Leading icon: action reinforcement
Use a leading icon when the icon clarifies what type of action the button performs. The icon is seen first, then confirmed by the label.
// Good leading icon candidates
<Button><Plus size={16} /> New Project</Button>
<Button><Download size={16} /> Export</Button>
<Button><Trash size={16} /> Delete</Button>
<Button><Edit size={16} /> Edit Profile</Button>
The action verb in the label (New, Export, Delete, Edit) is reinforced by the icon — not repeated. Never pair <Plus /> with “Add New” and <PlusCircle /> inconsistently; pick one and stick with it.
Trailing icon: destination or expansion
Use a trailing icon when the button navigates somewhere or expands something. The label describes the destination; the icon signals the type of transition.
// Trailing icons signal what happens next
<Button>View all posts <ChevronRight size={16} /></Button>
<Button>Open in Figma <ExternalLink size={16} /></Button>
<Button>More options <ChevronDown size={16} /></Button>
ChevronRight means “go forward / navigate”. ChevronDown means “expand / reveal”. Using ChevronRight on a dropdown button is wrong — it tells the user they’ll navigate, not expand. Keep the semantics consistent across your entire product.
Icon-only buttons
Icon-only buttons are common in toolbars, data tables, card headers, and inline actions. They save space but require explicit accessibility work because there is no visible label.
The aria-label requirement
// Correct — aria-label on the button, icon is decorative
<button
aria-label="Delete post"
className="p-2 rounded-lg text-gray-500 hover:text-red-600 hover:bg-red-50"
>
<Trash2 size={18} aria-hidden="true" />
</button>
// Wrong — no label, screen reader announces nothing useful
<button className="p-2">
<Trash2 size={18} />
</button>
The aria-hidden="true" on the icon prevents double-announcement: without it, a screen reader might say “button, delete, delete post” if the SVG has a title element.
Tooltip + aria-label together
For icon buttons in toolbars, pair a visual tooltip with the aria-label so both sighted users and keyboard users get the label:
<Tooltip content="Delete post">
<button
aria-label="Delete post"
className="p-2 rounded-md text-gray-500 hover:text-red-600 hover:bg-red-50
focus-visible:ring-2 focus-visible:ring-red-500"
>
<Trash2 size={18} aria-hidden="true" />
</button>
</Tooltip>
An 18px icon in a p-2 button gives a 34px tap target — below the 44px minimum required by WCAG 2.5.5 and Apple HIG. Use p-3 or add a minimum size: min-w-[44px] min-h-[44px].
Loading state icon swap
Replacing a button icon with a spinner on click is the most common icon animation in product UIs. The pattern:
- On click, set
isLoading = true - Swap the icon for
<Loader2>with a spin animation - Disable the button to prevent double-submission
- Restore the original state on completion
import { Download, Loader2 } from 'lucide-react';
import { useState } from 'react';
function ExportButton() {
const [loading, setLoading] = useState(false);
async function handleExport() {
setLoading(true);
await exportData();
setLoading(false);
}
return (
<button
onClick={handleExport}
disabled={loading}
aria-busy={loading}
className="flex items-center gap-2 px-4 py-2 text-sm font-medium
bg-blue-600 text-white rounded-lg disabled:opacity-60 disabled:cursor-not-allowed"
>
{loading
? <Loader2 size={18} className="animate-spin" aria-hidden="true" />
: <Download size={18} aria-hidden="true" />
}
<span>{loading ? 'Exporting…' : 'Export CSV'}</span>
</button>
);
}
Key details:
disabledprevents double-clickaria-busy={loading}announces the loading state to screen readers- The label text also changes (“Exporting…”) — don’t rely solely on the icon change
animate-spinis a Tailwind utility foranimation: spin 1s linear infinite
Icon variants across button variants
In a multi-variant button system (primary, secondary, ghost, destructive), the icon should always follow the text color — never hardcode it.
// The icon inherits correctly because of currentColor
const buttonVariants = {
primary: "bg-blue-600 text-white hover:bg-blue-700",
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-100",
ghost: "text-gray-600 hover:text-gray-900 hover:bg-gray-100",
destructive: "bg-red-600 text-white hover:bg-red-700",
};
// All Lucide icons use currentColor by default — they just work
<button className={buttonVariants.destructive}>
<Trash2 size={18} />
Delete
</button>
If you’re using an icon that doesn’t inherit currentColor, fix the source SVG rather than hardcoding a color for each variant.
CTA button icons
Primary CTA buttons deserve extra attention. The icon should reinforce the benefit, not just the action.
// Good — icon matches the outcome (sending, not the tool)
<button className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-xl font-semibold">
Get Started
<ArrowRight size={20} />
</button>
// Less effective — generic action icon adds no clarity
<button className="flex items-center gap-2 px-6 py-3 bg-blue-600 text-white rounded-xl font-semibold">
<MousePointer size={20} />
Get Started
</button>
For payment/checkout CTAs, trust badges with lock icons reduce anxiety:
<button className="flex items-center justify-center gap-2 w-full py-3
bg-green-600 text-white rounded-xl font-semibold">
<Lock size={18} />
Pay securely — $49
</button>
Full accessibility checklist for icon buttons
- Every icon-only button has
aria-labelon the<button>element - Icons inside labeled buttons have
aria-hidden="true" - Loading state sets
aria-busy="true"and changes the visible label text - Tap target is minimum 44×44px on mobile
- Focus ring is visible (
focus-visible:ring-2) - Disabled buttons use
disabledattribute, not justopacity-50 - Chevron direction matches the action (down = expand, right = navigate)
- Icons use
currentColorso they adapt to all button variants and dark mode
Related Reading
- SVG Icons for Navigation Menus
- Icon Size Guidelines for Web and Mobile UI
- Accessible SVG Icons: aria-label and role
- Micro-Interactions with SVG: Hover States and Click Animations
- WCAG 2.2 Guidelines for Web Icons and SVGs
- SVG Icons in Dark Mode: The Complete Tailwind & CSS Guide
- How to Animate SVG Icons Without Making UI Feel Janky