Most React applications ship with inaccessible icons. Not because developers do not care about accessibility — they do — but because the default behavior of every major icon library is to render bare SVG elements with no accessible semantics. Lucide, Heroicons, Tabler, and Feather all output SVGs that screen readers either skip entirely or read aloud as meaningless noise.
This is not a niche concern. Roughly 7 million Americans use screen readers. A further 253 million people worldwide have some form of vision impairment. And beyond screen reader users, accessible icons also benefit keyboard-only users, users on high-contrast displays, and anyone using a browser extension that modifies visual styling.
The good news is that fixing icon accessibility takes five minutes per component once you understand the three patterns. This guide covers every scenario you will encounter — decorative icons, interactive icons, standalone icons, and icons inside buttons — with copy-paste code for each.
Why SVG Icons Are Inaccessible by Default
When Lucide renders a <Home /> icon, the output is roughly this:
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"> <path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/> <polyline points="9 22 9 12 15 12 15 22"/> </svg>
No title. No aria-label. No role. Just a geometric shape.
A screen reader encountering this SVG has three possible behaviors depending on the browser and assistive technology combination. It may skip the element entirely (best case). It may read "image" with no further context (confusing). Or it may try to announce the SVG children and read out the path data coordinates as numbers (catastrophic).
Which behavior the user gets depends on factors outside your control. The solution is to make the intent explicit so there is nothing to guess.
The Three Icon Patterns You Need to Know
Every icon in your application falls into one of three categories. Getting the right pattern for each category is the entire job.
Pattern 1: Decorative icons — The icon is purely visual and the meaning is conveyed by adjacent text. Example: a search icon next to the word "Search". Screen readers should ignore these entirely.
Pattern 2: Interactive standalone icons — The icon is inside a button, link, or other interactive element, and there is no visible text label. Example: an icon-only toolbar button. Screen readers need to announce what the button does.
Pattern 3: Informative standalone icons — The icon conveys meaning on its own without an interactive container and without adjacent text. Example: a warning icon next to a form field error. Screen readers need to announce what the icon means.
Pattern 1: Decorative Icons
When an icon sits next to a text label that already describes it, the icon adds nothing to screen reader users. Announcing both the icon and the label creates redundant noise.
The fix is two attributes: aria-hidden="true" and focusable="false".
import { Search } from 'lucide-react'
// Correct — icon is decorative, text label provides the meaning
function SearchButton() {
return (
<button>
<Search size={16} aria-hidden="true" focusable="false" />
Search
</button>
)
}
// Wrong — screen reader may announce "image Search" then "Search" — redundant
function SearchButtonBad() {
return (
<button>
<Search size={16} />
Search
</button>
)
}The focusable="false" attribute is specifically for Internet Explorer and older Edge where SVGs are focusable by default. Modern browsers do not need it, but it costs nothing to include and prevents a bug that still affects real users on legacy enterprise systems.
Apply aria-hidden="true" to any icon where the surrounding text, label, or context already conveys the meaning. This is the most common pattern and covers the majority of icons in a typical application.
Pattern 2: Interactive Standalone Icons (Icon-Only Buttons)
Icon-only buttons are the most common accessibility failure in React applications. A toolbar with six icon buttons — bold, italic, underline, align left, align center, align right — is completely unusable to a screen reader user unless each button has an accessible name.
There are two ways to provide the accessible name. The aria-label approach is simplest:
import { Trash2, Edit, Share } from 'lucide-react'
// Correct — aria-label gives the button an accessible name
function ActionButtons({ itemId }: { itemId: string }) {
return (
<div role="toolbar" aria-label="Item actions">
<button aria-label="Edit item" onClick={() => handleEdit(itemId)}>
<Edit size={16} aria-hidden="true" focusable="false" />
</button>
<button aria-label="Share item" onClick={() => handleShare(itemId)}>
<Share size={16} aria-hidden="true" focusable="false" />
</button>
<button aria-label="Delete item" onClick={() => handleDelete(itemId)}>
<Trash2 size={16} aria-hidden="true" focusable="false" />
</button>
</div>
)
}Note that when using aria-label on the button, the icon inside must still have aria-hidden="true". The button's accessible name comes from aria-label. The icon is still decorative from the screen reader's perspective — the label is doing the work.
The alternative is visually hidden text, which has the advantage of being translatable by browser translation tools (aria-label is not translated by default in most browsers):
// Reusable visually-hidden utility
function VisuallyHidden({ children }: { children: React.ReactNode }) {
return (
<span
style={{
position: 'absolute',
width: '1px',
height: '1px',
padding: 0,
margin: '-1px',
overflow: 'hidden',
clip: 'rect(0, 0, 0, 0)',
whiteSpace: 'nowrap',
borderWidth: 0,
}}
>
{children}
</span>
)
}
// Correct — visually hidden text is announced by screen reader
// and translated by browser translation tools
function DeleteButton({ itemName }: { itemName: string }) {
return (
<button>
<Trash2 size={16} aria-hidden="true" focusable="false" />
<VisuallyHidden>Delete {itemName}</VisuallyHidden>
</button>
)
}The visually hidden text approach is particularly useful when the label needs to include dynamic content — like "Delete [item name]" — which gives screen reader users context about which item they are acting on.
Pattern 3: Informative Standalone Icons
Sometimes an icon conveys meaning on its own with no surrounding text and no interactive wrapper. A warning icon next to a form field. A checkmark indicating a completed step. A lock icon showing a secure connection.
For these cases, the icon itself needs to be announced:
import { AlertTriangle, CheckCircle, Lock } from 'lucide-react'
// Correct — role="img" + aria-label makes the icon meaningful
function WarningIcon({ message }: { message: string }) {
return (
<AlertTriangle
size={20}
role="img"
aria-label={`Warning: ${message}`}
color="#f59e0b"
/>
)
}
// Correct — for complex descriptions, use aria-labelledby with a title element
function StatusIcon({ status }: { status: 'success' | 'error' | 'pending' }) {
const labels = {
success: 'Task completed successfully',
error: 'Task failed — click to retry',
pending: 'Task is in progress',
}
return (
<CheckCircle
size={20}
role="img"
aria-label={labels[status]}
/>
)
}The role="img" attribute tells the browser this SVG is a meaningful image, not a decorative shape. Combined with aria-label, screen readers will announce it the way they announce an <img alt="..."> element.
Building a Reusable Accessible Icon Component
Rather than remembering to add the right attributes every time, the cleanest approach is a wrapper component that enforces the correct pattern based on props:
import type { LucideIcon } from 'lucide-react'
interface AccessibleIconProps {
icon: LucideIcon
label?: string // Required for informative icons, omit for decorative
size?: number
color?: string
className?: string
}
export function Icon({
icon: IconComponent,
label,
size = 20,
color,
className,
}: AccessibleIconProps) {
// Decorative — hidden from screen readers
if (!label) {
return (
<IconComponent
size={size}
color={color}
className={className}
aria-hidden="true"
focusable="false"
/>
)
}
// Informative — announced by screen readers
return (
<IconComponent
size={size}
color={color}
className={className}
role="img"
aria-label={label}
focusable="false"
/>
)
}Usage is clean and intent is explicit:
// Decorative — adjacent text provides context
<Icon icon={Home} size={18} />
<span>Home</span>
// Informative — icon conveys meaning on its own
<Icon icon={AlertTriangle} label="Warning: unsaved changes" color="#f59e0b" />
// Inside an accessible button — icon is decorative, button gets the label
<button aria-label="Go to homepage">
<Icon icon={Home} size={18} />
</button>This pattern makes accessibility the path of least resistance. Developers do not need to remember which attributes to use — the component API guides them toward the correct pattern.
Color Contrast for Icons
Accessible icons are not just about screen readers. For low-vision users who do not use screen readers, color contrast is equally important.
WCAG 2.1 requires a contrast ratio of at least 3:1 for graphical objects like icons (compared to 4.5:1 for body text). This means your icon color against its background must pass the 3:1 threshold.
Common failure patterns:
// Likely failing — light gray icon on white background
<Settings size={20} color="#9ca3af" /> // gray-400 on white: ~2.2:1
// Passing — darker gray maintains 3:1
<Settings size={20} color="#6b7280" /> // gray-500 on white: ~3.9:1
// Failing — purple icon on dark background (common in dark mode)
<Star size={20} color="#818cf8" /> // indigo-400 on #1e1e2e: ~2.8:1
// Passing — lighter purple clears the threshold in dark mode
<Star size={20} color="#a5b4fc" /> // indigo-300 on #1e1e2e: ~4.1:1Use the WebAIM Contrast Checker or the browser DevTools accessibility panel to verify ratios before shipping. PageSpeed Insights will flag contrast failures in production, but catching them in development is faster.
Focus Visibility for Keyboard Users
Icon-only buttons have a secondary accessibility concern beyond screen readers — keyboard focus visibility. When a user navigates your interface with Tab, every interactive element must have a visible focus indicator.
The browser default outline is sufficient in some designs, but many applications override it with outline: none globally, which breaks keyboard navigation entirely.
// Custom focus ring that matches your design system
const iconButtonStyles = {
background: 'none',
border: 'none',
cursor: 'pointer',
padding: '8px',
borderRadius: '6px',
// Never remove focus — only restyle it
outline: 'none',
':focus-visible': {
boxShadow: '0 0 0 2px var(--accent)',
},
}
// With Tailwind
<button
aria-label="Settings"
className="p-2 rounded-md focus-visible:ring-2 focus-visible:ring-indigo-500 focus:outline-none"
>
<Settings size={16} aria-hidden="true" />
</button>The :focus-visible pseudo-class (and Tailwind's focus-visible: variant) is the modern approach — it shows the ring for keyboard navigation but not for mouse clicks, which gives you full accessibility without the visual clutter of focus rings on every mouse interaction.
Accessible Icon Tooltips
Icon-only buttons often have tooltips that appear on hover to explain what the icon does. Tooltips improve usability for sighted users but need to be implemented correctly to also work for keyboard users and screen readers.
The common mistake is tooltip implementations that only trigger on mouse hover, leaving keyboard users with no way to discover the label.
import { useState } from 'react'
function IconButtonWithTooltip({
icon: IconComponent,
label,
onClick,
}: {
icon: LucideIcon
label: string
onClick: () => void
}) {
const [showTooltip, setShowTooltip] = useState(false)
const tooltipId = `tooltip-${label.replace(/\s/g, '-').toLowerCase()}`
return (
<div style={{ position: 'relative', display: 'inline-block' }}>
<button
onClick={onClick}
aria-describedby={tooltipId}
// Focus and blur for keyboard users
onFocus={() => setShowTooltip(true)}
onBlur={() => setShowTooltip(false)}
// Hover for mouse users
onMouseEnter={() => setShowTooltip(true)}
onMouseLeave={() => setShowTooltip(false)}
className="p-2 rounded-md focus-visible:ring-2 focus-visible:ring-indigo-500 focus:outline-none"
>
<IconComponent size={16} aria-hidden="true" focusable="false" />
</button>
{/* Tooltip — announced by screen reader via aria-describedby */}
<div
id={tooltipId}
role="tooltip"
style={{
position: 'absolute',
bottom: '100%',
left: '50%',
transform: 'translateX(-50%)',
visibility: showTooltip ? 'visible' : 'hidden',
background: '#1e1e2e',
color: 'white',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '12px',
whiteSpace: 'nowrap',
marginBottom: '4px',
}}
>
{label}
</div>
</div>
)
}The aria-describedby attribute connects the button to the tooltip element. Screen readers will announce the button's accessible name followed by the tooltip text when the user focuses the button — without needing the tooltip to be visually visible.
Quick Audit Checklist
Before shipping any interface with icons, run through these five questions:
1. Every icon-only interactive element has an accessible name — either aria-label on the parent element or visually hidden text inside it.
2. Every decorative icon has `aria-hidden="true"` — icons that have adjacent text labels should not be announced twice.
3. Every informative standalone icon has `role="img"` and `aria-label` — icons that convey meaning without adjacent text must announce that meaning.
4. Icon colors pass 3:1 contrast ratio against their backgrounds — verify with DevTools or WebAIM Contrast Checker.
5. Focus rings are visible on all interactive elements — never apply outline: none without replacing it with a visible :focus-visible alternative.
Five minutes with this checklist before each pull request prevents the accessibility failures that PageSpeed Insights flags in production.