Icons are fast for sighted users and silent for assistive tech unless you wire them correctly. By the end of this article you will know exactly when to use aria-hidden and when to use aria-label, and you will build two small icon components that speak clearly to screen readers without sacrificing visual polish.
Why Accessibility for Icons Matters
Screen reader users build a mental model from names, roles, and states. An unlabeled icon-only button becomes a mystery control. A decorative icon that is not hidden adds noise and slows down navigation. Many teams still ship icon fonts or background images that carry no accessible name, or they repeat labels by exposing both the icon and the text. The fix is straightforward: give interactive controls a meaningful accessible name, and hide purely decorative graphics. This article shows the pattern in code and explains the tradeoffs so you can ship UI that looks sharp and reads clean.
Prerequisites
You will work with semantic HTML and a few CSS techniques to draw icons without image files. A lightweight setup, no framework required.
- Basic HTML
- CSS custom properties
- CSS pseudo-elements (::before / ::after)
Step 1: The HTML Structure
Here is the complete HTML for two components: an icon-only Search button that needs an aria-label, and a text-labeled accordion toggle with a decorative triangle that must be hidden from assistive tech. The accordion button references a content panel with aria-controls. JavaScript would flip aria-expanded and the hidden attribute at runtime; for this article the states start collapsed.
<!-- HTML -->
<main class="container">
<section class="panel">
<h2 class="h">Icon-only control (needs an accessible name)</h2>
<button class="icon-btn search-btn" type="button" aria-label="Search">
<span class="icon icon-magnifier" aria-hidden="true"></span>
</button>
</section>
<section class="panel">
<h2 class="h">Text-labeled control (decorative icon must be hidden)</h2>
<button class="accordion-btn" type="button"
aria-expanded="false"
aria-controls="filters-panel">
<span class="btn-text">Filters</span>
<span class="icon icon-triangle" aria-hidden="true"></span>
</button>
<div id="filters-panel" hidden>
<p>This is the content area that would expand and collapse.</p>
</div>
</section>
</main>
Step 2: The Basic CSS & Styling
These styles set up color tokens, base layout, and reusable icon and button patterns. The icons use currentColor so they follow the text color of their parent. The .icon class defines a sizing box for shape work, and .sr-only is available for off-screen text if you want a visible text alternative without changing layout.
/* CSS */
:root {
--bg: #0f172a;
--panel: #111827;
--text: #e5e7eb;
--muted: #9ca3af;
--accent: #22c55e;
--accent-contrast: #052e16;
--ring: #22c55e66;
--radius: 10px;
--icon-size: 1.25rem;
--space: 1rem;
--focus: 3px;
}
*,
*::before,
*::after { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font: 16px/1.5 system-ui, -apple-system, Segoe UI, Roboto, Ubuntu, Cantarell, Noto Sans, Arial, "Apple Color Emoji", "Segoe UI Emoji";
color: var(--text);
background: radial-gradient(1200px 600px at 10% -20%, #1f2937 0%, #0b1020 50%, #080b16 100%);
}
.container {
max-width: 720px;
margin: 0 auto;
padding: calc(var(--space) * 2);
display: grid;
gap: calc(var(--space) * 1.5);
}
.panel {
background: linear-gradient(180deg, #111827, #0b1220);
border: 1px solid #1f2937;
border-radius: var(--radius);
padding: calc(var(--space) * 1.25);
box-shadow: 0 10px 30px rgba(0,0,0,0.3), inset 0 1px 0 rgba(255,255,255,0.03);
}
.h {
margin: 0 0 var(--space);
font-size: 1.1rem;
color: var(--muted);
}
.icon {
display: inline-block;
width: var(--icon-size);
height: var(--icon-size);
vertical-align: middle;
color: currentColor;
}
.icon-btn {
--icon-size: 1.35rem;
display: inline-grid;
place-items: center;
inline-size: 44px;
block-size: 44px;
border-radius: 999px;
border: 1px solid #1f2937;
background: linear-gradient(180deg, #0f172a, #0b1220);
color: var(--text);
cursor: pointer;
padding: 0;
box-shadow: 0 6px 20px rgba(0,0,0,0.25), inset 0 1px 0 rgba(255,255,255,0.05);
}
.icon-btn:hover {
color: white;
border-color: #2a384d;
}
.icon-btn:focus-visible,
.accordion-btn:focus-visible {
outline: var(--focus) solid transparent;
box-shadow: 0 0 0 var(--focus) var(--ring);
}
.accordion-btn {
--icon-size: 0.9rem;
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
border-radius: calc(var(--radius) / 2);
border: 1px solid #1f2937;
background: linear-gradient(180deg, #0f172a, #0b1220);
color: var(--text);
cursor: pointer;
}
.accordion-btn .btn-text {
font-weight: 600;
}
.sr-only {
position: absolute !important;
width: 1px; height: 1px;
padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0);
white-space: nowrap; border: 0;
}
Advanced Tip: Set icon color using currentColor and drive theme via parent text color. This keeps icons and labels in sync and avoids mismatched hues during hover and focus states.
Step 3: Building the Search Icon Button (icon-only)
The Search control uses only an icon, so the button itself carries the accessible name via aria-label, while the icon element is silence-only with aria-hidden. The magnifier shape is drawn in CSS for a crisp look at any scale. If you prefer a utility page that focuses on shape mechanics, see the magnifying glass icon reference.
/* CSS */
.search-btn .icon-magnifier {
position: relative;
width: var(--icon-size);
height: var(--icon-size);
}
.search-btn .icon-magnifier::before {
content: "";
position: absolute;
inset: 0;
border: 0.18em solid currentColor;
border-radius: 50%;
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.06);
}
.search-btn .icon-magnifier::after {
content: "";
position: absolute;
width: 0.6em;
height: 0.18em;
right: -0.05em;
bottom: -0.1em;
background: currentColor;
transform: rotate(45deg);
transform-origin: left center;
border-radius: 0.1em;
}
.search-btn:hover {
color: var(--accent);
text-shadow: 0 0 18px rgba(34, 197, 94, 0.6);
}
.search-btn:active {
transform: translateY(1px);
}
How This Works (Code Breakdown)
The accessible name comes from the button element, not the icon. aria-label=”Search” gives screen readers a clear string to announce along with the role and interaction hints. The nested span carries aria-hidden so assistive tech does not treat the decorative shape as content. If you put aria-label on the span instead, the button would still fail because the span is not the interactive element that needs a name.
The icon itself uses pseudo-elements. The circle is a bordered ::before with a 50 percent border-radius, which renders a clean ring that scales with the font-size of the button. The handle is a small rectangle in ::after, rotated by 45 degrees with transform so it aligns visually at the lower-right edge. Both pieces take currentColor, so a hover on the button can tint the icon without extra selectors.
This component is a textbook case for aria-label: the control has no visible text. If you ever redesign it to include “Search” next to the icon, you would remove aria-label from the button and keep aria-hidden on the icon. The icon remains visual-only, and the label becomes the accessible name.
Step 4: Building the Accordion Toggle (text + decorative icon)
The accordion button includes visible text. The triangle is purely decorative and should be hidden with aria-hidden. The button toggles aria-expanded between true and false, and CSS rotates the triangle to match the state. For deeper control over triangle drawing techniques, the triangle-down icon guide covers the border trick in detail.
/* CSS */
.accordion-btn .icon-triangle {
/* reset icon box so border triangle sizing is exact */
width: 0;
height: 0;
border-left: 0.38rem solid transparent;
border-right: 0.38rem solid transparent;
border-top: 0.5rem solid currentColor; /* points down */
filter: drop-shadow(0 1px 0 rgba(255,255,255,0.04));
transition: transform 200ms ease;
}
/* rotate to point up when expanded */
.accordion-btn[aria-expanded="true"] .icon-triangle {
transform: rotate(180deg);
}
/* open state colors for button */
.accordion-btn[aria-expanded="true"] {
color: var(--accent);
border-color: #2a384d;
box-shadow: 0 0 0 1px rgba(34,197,94,0.15), inset 0 1px 0 rgba(255,255,255,0.06);
}
/* demo: reveal the panel when aria-expanded is true */
.accordion-btn[aria-expanded="true"] + #filters-panel {
display: block;
}
How This Works (Code Breakdown)
The accessible name for this button is the visible text “Filters.” aria-hidden on the icon prevents duplication, which would otherwise cause some screen readers to stutter or repeat the label if the icon exposed a text alternative. The live state lies on the control itself with aria-expanded, and the content region has an id referenced by aria-controls. Many UI kits forget the aria-controls link, which makes spatial relationships clearer for screen reader users.
The triangle uses the classic border trick. By zeroing width and height you let border widths form the shape. The transparent left and right borders create a point, and the solid top border draws the downward-facing arrow. A transform rotates the shape when the accordion is open. This pattern also works for arrows or chevrons. If your pagination link reads “Next” and adds a chevron as a flourish, mark the chevron with aria-hidden. For a pure CSS approach, see the chevron right tutorial.
Notice that the content region toggles with CSS in this demo, but production code would use JavaScript to flip hidden and aria-expanded together. Sync the state so both visual users and assistive tech get the same signal.
Advanced Techniques: Animations and Hover Effects
Motion can reinforce meaning when used with restraint. The following rules add a gentle hover pulse to the search icon and a snappy but brief triangle rotation. A reduced-motion query disables the pulse for users who prefer less movement.
/* CSS */
@keyframes pulse-ring {
0% { text-shadow: 0 0 0 rgba(34, 197, 94, 0); }
50% { text-shadow: 0 0 18px rgba(34, 197, 94, 0.6); }
100% { text-shadow: 0 0 0 rgba(34, 197, 94, 0); }
}
.search-btn:hover .icon-magnifier::before {
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.2);
}
.search-btn:hover {
animation: pulse-ring 800ms ease-in-out;
}
/* prefer less motion: keep states but drop the pulse */
@media (prefers-reduced-motion: reduce) {
.search-btn,
.accordion-btn .icon-triangle {
transition: none;
animation: none;
}
}
Accessibility & Performance
The goal is a clean accessibility tree that maps to what a user can do and what the UI conveys. The patterns below answer the central question of this article: when to use aria-hidden and when to use aria-label.
Accessibility
Use aria-label on the interactive element when the control has no visible text. Typical cases include icon-only buttons for search, close, back, or share. The label belongs on the button, link, or input that users activate. The icon itself should be aria-hidden because it is a presentational graphic with no separate meaning from the control.
Use aria-hidden=”true” on decorative icons that sit next to meaningful text. A triangle next to “Filters” does not add new information; it indicates state visually. Hiding it prevents redundancy. With SVG, add role=”presentation” or focusable=”false” so it stays out of the tab order. With CSS pseudo-elements, the shapes are not in the DOM and are ignored by assistive tech by default, but if you wrap them in a span for layout control, include aria-hidden on that span.
Avoid placing aria-label on non-interactive elements and expecting it to name their parent. It does not. Also avoid the title attribute for naming controls; support is inconsistent and it often does not surface as the accessible name. Prefer visible text over aria-label when possible, then hide the icon. If both visible text and aria-label are present on the same control, the accessible name becomes the aria-label, which can surprise you if the strings drift out of sync.
State matters. A toggle should use aria-pressed on the button. An accordion should use aria-expanded and aria-controls. Make the icon reflect the state visually, but do not rely on the icon to carry the state text. Add “expanded” and “collapsed” to the accessible name only when design lacks context; most screen readers announce state changes tied to aria-expanded automatically.
Performance
CSS-drawn icons are tiny in payload and render sharply on high-DPI screens. The shapes in this article rely on borders and transforms, which are fast on modern engines. Animations that use transform and opacity are cheap compared to shadow-heavy or layout-triggering effects. Keep animation durations short, avoid animating large shadows, and gate motion behind the prefers-reduced-motion query for users who request it. Because icons take currentColor, you can theme them without extra DOM or repaint cost.
Ship Icons That Communicate
You built two small components that demonstrate the rule: label icon-only controls with aria-label, and hide decorative icons with aria-hidden. You now have practical patterns for names, roles, and states, plus CSS shapes that scale crisply. Carry these patterns into your design system and you will ship icons that both look right and read right.