Icon-only buttons seem simple, yet small mistakes can hide them from assistive tech, break keyboard flow, or shrink the target below WCAG sizes. Start with a native <button>
, give it an accessible name, and treat the SVG as decoration unless the icon itself must be announced. This approach aligns with ARIA and WCAG guidance and avoids cross-browser inconsistencies in how SVG titles are exposed. The goal is a control that works with screen readers, touch, mouse, and keyboard in every dialog you ship.
Browsers give native buttons semantics, focus, and keyboard activation by default, so do not add role="button"
. Icon buttons still need an accessible name; supply it on the button with aria-label
or associate visible text with aria-labelledby
. Do not rely solely on <svg><title>…</title></svg>
for the name because support is inconsistent across screen reader and browser combinations. This keeps the name stable regardless of how the SVG is exposed.
Pattern: icon-only button
<button type="button" class="icon-btn" aria-label="Close">
<svg aria-hidden="true" viewBox="0 0 13 13" width="16" height="16" fill="currentColor">
<path d="…" />
</svg>
</button>
Pattern: icon + visible text
<button type="button" class="icon-btn">
<svg aria-hidden="true" viewBox="0 0 13 13" width="16" height="16" fill="currentColor">
<path d="…" />
</svg>
<span class="label">Close</span>
</button>
aria-label
on the button is the most reliable way to provide the accessible name for icon-only controls; the SVG can remain hidden from the accessibility tree. If you prefer the SVG to carry the name, use role="img"
plus aria-labelledby
and a <title>
element, but test across assistive tech combinations.
Keep the SVG out of the accessibility tree (in most cases)
When the button’s name comes from aria-label
or visible text, hide the SVG with aria-hidden="true"
so it does not create duplicate announcements. Do not combine role="img"
and aria-hidden="true"
; an element cannot be both exposed and hidden. Hiding removes the SVG and all its children from the accessibility tree while keeping it visible. That simplifies what screen readers announce.
Good
<button type="button" aria-label="Close">
<svg aria-hidden="true" …>…</svg>
</button>
Avoid
<!-- Conflicting signals -->
<svg role="img" aria-hidden="true">…</svg>
Size the target, not just the icon
WCAG 2.2 requires a minimum target size of 24×24 CSS px for pointer inputs unless specific exceptions apply. The easiest way to meet it is to give the button padding or a fixed min-size, regardless of the SVG’s dimensions. This benefits touch users and reduces accidental taps. Test using a 24 px reference box or a tool that checks SC 2.5.8.
.icon-btn {
inline-size: 2.25rem; /* 36px comfortable target */
block-size: 2.25rem;
display: inline-grid;
place-items: center;
padding: 0; /* grid centers the icon */
border: 1px solid transparent;
background: transparent;
color: inherit;
cursor: pointer;
}
Make focus states visible and keyboard behavior standard
Ensure the button is reachable with Tab
, activates with Enter
and Space
, and shows a visible focus indicator that meets contrast requirements. Let the browser provide default keyboard semantics by sticking with <button>
instead of custom roles. In modals, keep focus trapped within the dialog and return focus to the trigger on close; also support Esc
to close. These steps match WAI-ARIA Authoring Practices for modal dialogs.
CSS
.icon-btn:focus-visible {
outline: 2px solid currentColor;
outline-offset: 2px;
}
JS
// Inside your modal setup:
dialogEl.addEventListener('keydown', (e) => {
if (e.key === 'Escape') closeDialog();
});
closeBtn.addEventListener('click', closeDialog);
Keep the close control as a descendant of the modal container so it participates in the dialog’s focus management and is reachable by assistive tech users. This mirrors ARIA guidance for modal behavior and reduces surprises with focus restoration. It also keeps Esc
and click handlers scoped to the right region.
SVG details that trip people up
Use numeric width
and height
values on inline SVG, which map to CSS pixels; units are optional for inline use and commonly omitted. Prefer fill="currentColor"
so the icon inherits text color, improving theming and contrast control through CSS. If you need the SVG itself to be announced, give it role="img"
and reference a <title>
with aria-labelledby="svgTitleId"
. Otherwise keep it aria-hidden="true"
. These patterns agree with MDN and common auditing rules.
<svg aria-hidden="true" viewBox="0 0 13 13" width="16" height="16" fill="currentColor">…</svg>
And with role…
<svg role="img" aria-labelledby="closeTitle" viewBox="0 0 13 13" width="16" height="16">
<title id="closeTitle">Close</title>
<path d="…" />
</svg>
What not to do
Do not add role="button"
to a <button>
, and do not invent attributes like display="flex"
on HTML elements; use CSS or data-*
attributes for custom data. Do not rely on the SVG’s <title>
as the only name for the control in icon-only buttons. Avoid placing the close button outside the modal, which breaks focus containment and predictability. These anti-patterns create redundant roles, invalid HTML, and inconsistent announcements.
Complete recipe
See the Pen Untitled by Alex Ivanovs (@stackdiary) on CodePen.
This version meets naming, focus, and target size requirements and works across AT combinations.
HTML
<!-- Trigger -->
<button type="button" id="openDialog">Open settings</button>
<!-- Modal dialog wrapper -->
<div role="dialog" aria-modal="true" aria-labelledby="dlg-title" hidden id="settingsDialog">
<div class="dialog">
<h2 id="dlg-title">Settings</h2>
<button type="button" class="icon-btn" aria-label="Close" id="closeDialog">
<svg aria-hidden="true" viewBox="0 0 13 13" width="16" height="16" fill="currentColor">
<path d="…" />
</svg>
</button>
<!-- dialog content ... -->
</div>
</div>
CSS
.dialog { position: relative; }
.icon-btn {
inline-size: 2.25rem; block-size: 2.25rem; display: inline-grid; place-items: center;
border-radius: .5rem;
}
.icon-btn:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
JS
const open = document.getElementById('openDialog');
const dlg = document.getElementById('settingsDialog');
const close = document.getElementById('closeDialog');
function openDialog() {
dlg.hidden = false;
// Move focus inside the dialog per APG guidance
close.focus();
}
function closeDialog() {
dlg.hidden = true;
open.focus(); // restore focus to trigger
}
open.addEventListener('click', openDialog);
close.addEventListener('click', closeDialog);
dlg.addEventListener('keydown', e => { if (e.key === 'Escape') closeDialog(); });
This follows the APG modal pattern: focus stays inside the dialog while open, Esc
closes, and focus returns to the opener. The close control is a native button with a robust name, and the SVG is hidden from AT to avoid duplicate announcements.
Quick audit checklist
Name present on the button (aria-label
or visible text). SVG hidden unless it needs its own announcement. Target size ≥ 24×24 CSS px via padding or min-size. Visible :focus-visible
ring. Close button is in the modal, and Esc
works. No redundant role="button"
. If the SVG is exposed as role="img"
, it has a non-empty accessible name via <title>
or aria-labelledby
. These items match WCAG, APG, and common automated rules.
Use this baseline everywhere you render a close icon. It keeps your UI predictable for assistive tech, satisfies WCAG, and reduces edge cases you would otherwise have to debug later.