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.

Amit Yadav
Amit Yadav

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

PatternWhen to useExample
Leading iconIdentifies the action type[Download] Export CSV
Trailing iconIndicates what happens nextContinue [→]
Icon-onlyDense UI, clear context[✕] close button
Loading stateAfter 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.

Recommended tokens
16px20px24px32px
Nearest match
24px

Practical sizing table

Button sizeFont sizeIcon sizeGap (gap-*)
xs12px14pxgap-1 (4px)
sm14px16pxgap-1.5 (6px)
md (default)14–16px18pxgap-2 (8px)
lg16–18px20pxgap-2 (8px)
xl20px22–24pxgap-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>
Always center icons with items-center

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>
Chevron direction is semantic

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>
44px minimum tap target on mobile

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:

  1. On click, set isLoading = true
  2. Swap the icon for <Loader2> with a spin animation
  3. Disable the button to prevent double-submission
  4. 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:

  • disabled prevents double-click
  • aria-busy={loading} announces the loading state to screen readers
  • The label text also changes (“Exporting…”) — don’t rely solely on the icon change
  • animate-spin is a Tailwind utility for animation: 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-label on 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 disabled attribute, not just opacity-50
  • Chevron direction matches the action (down = expand, right = navigate)
  • Icons use currentColor so they adapt to all button variants and dark mode
Share this post