squirrelscan
GitHub

Accessible names for buttons: what counts and how to check

A button with only an icon has no name for a screen reader. squirrel lists every button on every page that has nothing to announce.

What an accessible name is

The accessible name is the string a screen reader announces for a control. Browsers compute it from a fixed order of sources: aria-labelledby first, then aria-label, then the element’s own text content, then title. An <input type="button"> uses its value attribute.

A button with visible text already has a name. The problem case is a button whose whole content is a picture: an icon <svg>, a background image, or a font glyph in a pseudo-element. The user hears “button” and nothing else.

What squirrel checks

Two checks, at rule severity error. They cover every <button>, every element with role="button", and every <input> of type button, submit or reset. A button has a name when it has any of the following: a non-empty aria-label; an aria-labelledby pointing at an element that has text; a non-empty title; text content; a value; a nested <img> with non-empty alt; a nested <svg><title> with text; or a nested element carrying aria-label.

  • button-name fails with Buttons without accessible names for unnamed buttons that are in the accessibility tree. Up to ten offenders are listed as button#id, button.class, input[name="..."] or input[type="..."], with a count of any remainder.
  • button-name-unconfirmed warns with N button(s) without accessible names may be hidden by a style (unconfirmed) for unnamed buttons a style may hide in a way squirrel cannot settle (see What squirrel can see). For buttons a media query hides only at some widths the message reads N button(s) without accessible names hidden only at some widths by a media query (unconfirmed), and each item says where it shows (hidden-only-at-some-widths, shown below 990px). On a page where an earlier audit reported button-name failing and this one reports the same buttons as unconfirmed, the earlier failure is closed: the check ran on the page and found nothing it could confirm.
  • button-name passes with All buttons have accessible names, or with All buttons in the accessibility tree have accessible names when some unnamed buttons were skipped as hidden. The pass carries buttonsChecked, and hiddenWithoutName when buttons were skipped.
  • Info with No buttons found when the page has none.

Hidden buttons

A button that is not in the accessibility tree has nothing to announce, so the rule does not apply to it and it is not reported. That is a button with the hidden attribute (except hidden="until-found"), aria-hidden="true", display: none on itself or an ancestor, visibility: hidden on itself or inherited from an ancestor (set inline or by a <style> rule squirrel can decide), or a button inside a closed <dialog> or an unopened popover. On a page that loads an external stylesheet, hiding from a <style> rule and inherited visibility are reported as unconfirmed, because an external rule can make the button visible again. A button in a closed overlay still needs a name: give it one before the overlay can open.

A disabled button is still in the accessibility tree, and so is one that is moved off-screen, clipped, or made transparent. Those are checked like any other button.

What squirrel can see

squirrel reads the markup, each element’s own inline style attribute and the page’s <style> blocks. A declaration counts only when it plainly sets display: none or visibility: hidden (upper case and a trailing !important are fine).

From <style> blocks, squirrel applies only the rules it can decide exactly: a rule outside any @media, @supports, @layer or other at-rule, whose every selector is a single compound of a tag, .class, #id, [attr] or [attr=value], such as .modal { display: none }. It orders them the way a browser does: an !important declaration first, then the inline style, then the more specific selector, then the later rule. A rule it cannot decide, such as one with a combinator (.nav .menu), a pseudo-class (:hover, :not()), an at-rule around it (other than the width-only @media described below), a selector a browser rejects (.123), or a value it cannot read, does not hide anything: it makes the elements it could reach unconfirmed. A <style> block whose type is anything but text/css is ignored, as a browser ignores it, and so is one with media="print". Every rule is undecided in a block that applies only sometimes or not as written: one with another media, one with a title (an alternate stylesheet can be switched off), one inside <svg>, or one with an @namespace. When an @keyframes on the page sets visibility, an element a rule or its inline style animates is unconfirmed too, because a running animation outranks every declaration but an !important one. A block inside <template> or <noscript>, nested CSS, an escaped at-rule name and a non-ASCII space in the CSS are not read, and count as a stylesheet squirrel did not read.

A display: none rule inside width-only @media queries is read for the widths it covers (the rule’s selectors follow the same single-compound limit as above). squirrel reads min-width, max-width and width, in the colon form or the range syntax ((width >= 990px), (400px <= width < 990px)), in px, em or rem, joined by and or a comma list, with an optional only and the screen or all media type, nested @media rules included. Where those queries do not cover every width, the element is shown at the others, so an unnamed control inside it is reported as unconfirmed with its own reason, hidden-only-at-some-widths, and the widths where it shows (“shown below 990px”), not with the generic reason. It stays unconfirmed rather than failing because a static read cannot tell which viewport a visitor has. A theme’s mobile menu, hidden on wide screens, is the usual case. Queries that together cover every width (min-width: 990px with max-width: 989.98px, in either syntax, in em or rem) leave the element hidden at every width, so it is not reported. A gap of up to 0.02px between them counts as covered, one of a whole pixel does not. The tolerance is a trade-off: a viewport at a fractional width (browser zoom, a fractional device pixel ratio) can land in such a gap and show the element, which squirrel then treats as hidden. A query pair that can never both match ((min-width: 990px) and (max-width: 989px)) hides nothing, so the element counts as shown there. Any other media feature (orientation, height, prefers-*, hover, print, resolution), not, @container, @supports or @layer around the rule, a <style media>, a hide outside the media query, a page that also loads an external stylesheet, or a visibility hide keeps the generic reason.

A page past squirrel’s style limits (more than 5,000 style rules, a <style> block over the size limit, a very long selector, a no-break or other non-ASCII space in a style block, or a rule nested inside another) is read as unconfirmed for every element a style might hide: the rules past the limit could hide or show any of them, so an unnamed control is reported as unconfirmed, not as a failure. CSS nesting is not read: a nested rule that sets display or visibility (b { @media (min-width: 0) { display: none } }, .a { & .b { display: none } }) leaves the elements it could reach unconfirmed, and a nested rule that sets neither changes nothing.

External stylesheets are not read. On a page that loads one (<link rel="stylesheet">, a <link> preloaded as="style", or an @import), hiding from a <style> rule and visibility inherited from an ancestor are reported as unconfirmed, because an external rule could show the element again. An inline display: none still counts. A button hidden only by the site’s external CSS, such as a closed modal whose .modal rule lives in a stylesheet, is still reported. Check those findings in a browser.

A control that carries a no-js class, itself or on an ancestor other than <html>, is reported as unconfirmed on a page with an external stylesheet or an @import, with the reason that it may be hidden when JavaScript runs. A theme usually hides that fallback with a rule such as .js .no-js { display: none } in a stylesheet squirrel does not read. The same control on a page whose styles were all read keeps its verdict, and a class such as no-js-hidden is not a marker. A “hide until JavaScript runs” class on a page that was not rendered stays unconfirmed: a static read cannot settle it, and a rendered crawl does.

A <style> rule that shows an element again is read: a display rule on an element the browser hides by default (one with the hidden attribute, a closed <dialog> or a popover), or an !important rule over an inline display: none. On a page that also loads an external stylesheet, showing an element the browser hides by default is unconfirmed, whether a <style> rule or an inline style shows it, because a stylesheet rule such as [hidden] { display: none !important } keeps it hidden. Only an inline display with !important settles it. The same show in an external stylesheet is missed, and that element is treated as hidden.

squirrel’s parser can also build a different tree from a browser’s around some elements: a <p>, list items, headings, buttons, forms, links and other inline formatting elements, table parts, <select>, <svg>, and tags a browser ignores or treats as empty where they appear. When a hidden element is one of those, or sits inside one, what is inside it is reported as unconfirmed rather than skipped.

Only a display, visibility or all declaration can hide, so every other declaration is skipped, whatever its value holds: a custom property such as --font-display: swap or a :root block of design tokens changes nothing. A display or visibility declaration (inline or in a <style> rule) written in any other form is reported by button-name-unconfirmed rather than as a failure, because squirrel will not guess at it. That covers comments, escapes, var() and other functions, non-ASCII characters, the all shorthand, revert, a ! anywhere but a trailing !important, and an unlisted keyword. A vendor-prefixed display (-webkit-box, -webkit-flex, -ms-flexbox and the like) is read as a value that shows the element, including the old fallback chain that ends in display: flex; a -ms- or -moz- value that no later standard display overrides is reported as unconfirmed, because engines differ on it. An attribute selector with an operator ([id$="-drawer"], [href^="#"], [data-a*="x"], [class~="a"], [lang|="en"]) in a rule squirrel cannot decide reaches only elements that have that attribute with a value that could satisfy it, so it no longer makes every element on the page unconfirmed.

How to fix it

<button aria-label="Close settings">
  <svg aria-hidden="true">...</svg>
</button>

Put the label on the button, not on the icon inside it, and hide the icon from assistive technology with aria-hidden="true" so it is not announced twice.

A button whose only content is an icon-font glyph in a ::before pseudo-element is the common false positive. squirrel sees an empty button because the glyph is not in the markup. Adding aria-label is the correct fix in that case anyway.

Rule IDa11y/button-name
CategoryAccessibility
ScopePer-page
Severityerror
Weight8/10

Enable / disable

Disable this rule

[rules]
disable = ["a11y/button-name"]

Disable all Accessibility rules

[rules]
disable = ["a11y/*"]

Enable only this rule

[rules]
enable = ["a11y/button-name"]

enable replaces the default ["*"], so this is the only rule selected. Leave disable out: a rule that matches any disable pattern is skipped even when enable names it. Only an enabled setting under a rule’s own [rule_options] table overrides both lists.

Accessibility findings ship in every audit next to the SEO, performance and agent experience rules. See Website security scan with AI for how an agent works through a report.

References

Check your site

Run squirrel audit https://example.com and open the Accessibility section of the report. Every unnamed button is listed by page with a selector you can search for. Signed-out local audits need no account and cost nothing. Signed-in audits spend credits.

Type to search…

↑↓ navigate↵ openesc close