Debugging with CSS
We could prepare a small debug.css
file that can be
used to track some accessibility issues, like having tables without
caption
, labels without for
or
button
with only an icon and no aria-label
Interactive Element with role="button" or role="link"
I have found this to be a common mistake, especially when using frameworks or libraries that allow you to
create interactive elements without the need of a button
or a
element.
<div class="btn" role="button" tabindex="0" aria-label="Open menu">
☰
</div>
<span class="btn" role="link" tabindex="0" onclick="window.location='https://example.com'">
Go to Example
</span>
Theoretically, you can try to create your own buttons and links like this, but the default native elements
already bring a lot of accessibility features that you would need to implement yourself. It is not enough
with adding the semantic role, you also need to make sure you have the necessary event listeners, add the
tabindex
, default styles...
If we want to hunt all these cases, we could do the following:
[role="button"],
[role="link"] {
outline: 2px dashed var(--color-debug-warning);
outline-offset: 4px;
position: relative;
}
[role="button"]::after,
[role="link"]::after {
content: "⚠️ Role needs keyboard support";
color: var(--color-debug-warning);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
border: 1px solid var(--color-dark);
background-color: var(--color-light);
padding: 0.25em 0.75em;
border-radius: 1.25em;
}
This way we could debug these occurrences like this:
So that we can change it accordingly with something like this:
<button class="btn" aria-label="Open menu">
☰
</button>
<a class="btn" href="https://example.com">
Go to Example
</a>
Table without caption
It is true that if the table is already clearly introduced by a nearby heading or text, a
caption
tag may be redundant—but including it still enhances semantic clarity.
In this case, we could check for the non-existence of the caption and then make the decision we think it's right.
table:not(:has(caption)) {
outline: 2px dashed var(--color-debug-error);
outline-offset: 4px;
position: relative;
width: 100%;
}
table:not(:has(caption))::after {
content: "❌ Table missing ";
color: var(--color-dark);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-block: 0.25em;
background-color: var(--color-debug-light-error);
border: 1px solid var(--color-dark);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
Company | Contact | Country |
---|---|---|
Alfreds Futterkiste | Maria Anders | Germany |
Centro comercial Moctezuma | Francisco Chang | Mexico |
If we want to add a caption, we could do it like this:
<table>
<caption>Company Contacts</caption>
<tr>
<th>Company</th>
<th>Contact</th>
<th>Country</th>
</tr>
<tr>
<td>Alfreds Futterkiste</td>
<td>Maria Anders</td>
<td>Germany</td>
</tr>
<tr>
<td>Centro comercial Moctezuma</td>
<td>Francisco Chang</td>
<td>Mexico</td>
</tr>
</table>
Company | Contact | Country |
---|---|---|
Alfreds Futterkiste | Maria Anders | Germany |
Centro comercial Moctezuma | Francisco Chang | Mexico |
UL/OL with invalid children
By mistake, we can have a list with children that are not valid, like a div
or a p
inside an ul
or ol
.
We can address that with a similar code like we had before:
ul:has(> :not(li)),
ol:has(> :not(li)) {
outline: 2px dashed var(--color-debug-warning);
outline-offset: 4px;
position: relative;
}
ul:has(> :not(li))::before,
ol:has(> :not(li))::before{
content: "⚠️ List has children that aren't <li>";
color: var(--color-debug-warning);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
border: 1px solid var(--color-dark);
background-color: var(--color-light);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
- Okay
- Okay
Empty headings
It is not uncommon to have headings that are empty, either by mistake or because they are used as a visual element. We can check for this with the following code:
h1:empty,
h2:empty,
h3:empty,
h4:empty,
h5:empty,
h6:empty {
outline: 2px solid var(--color-debug-error);
outline-offset: 4px;
position: relative;
}
h1:empty::before,
h2:empty::before,
h3:empty::before,
h4:empty::before,
h5:empty::before,
h6:empty::before {
content: "⚠️ Empty heading";
color: var(--color-debug-warning);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
border: 1px solid var(--color-dark);
background-color: var(--color-light);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
This way we will quickly find the empty headings.
Img without alt tag
It is a common mistake to forget to add an alt
attribute to an img
element.
We can check for this with the following code:
*:has(> img:not([alt])) {
outline: 2px dashed var(--color-debug-error);
outline-offset: 4px;
position: relative;
}
*:has(> img:not([alt]))::after {
content: "❌ Image missing alt text";
color: var(--color-dark);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
background-color: var(--color-debug-light-error);
border: 1px solid var(--color-dark);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
This way we will quickly find the images without alt text.
Label with no for and input
When it comes to labels, it is important to ensure that they are associated with the correct input elements
and that they have a for
attribute that matches the id
of the input element.
We can check for this with the following code:
For example, if we had just a label
or both label
and input
or
textarea but no connected via for
and id
, we could show some error messages.
Here we will handle three different cases:
-
label
with nofor
attribute -
label
with noinput
ortextarea
-
input
ortextarea
with noid
attribute
Let's see how to address these cases.
label:not([for]):not(:has(input, select, textarea)),
label[for] + input:not([id])
{
outline: 2px dashed var(--color-debug-error);
outline-offset: 4px;
position: relative;
}
label:not([for]):not(:has(input, select, textarea))::before,
label[for] + input:not([id])::before {
content: " ⚠️ Label missing 'for' or control";
color: var(--color-debug-error);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
background-color: var(--color-debug-light-error);
border: 1px solid var(--color-dark);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
Icon button with no text/aria-label
We often use icon buttons, but it is important to ensure that they have a text alternative for screen readers. We can check for this with the following code:
button:has(svg):not(:has(span)):not([aria-label]) {
outline: 2px dashed var(--color-debug-warning);
outline-offset: 4px;
position: relative;
}
button:has(svg):not(:has(span)):not([aria-label])::after {
content: " ⚠️ Icon-only button needs a label";
color: var(--color-debug-error);
font-size: 0.675rem;
font-weight: bold;
display: block;
margin-top: 0.25em;
background-color: var(--color-debug-light-error);
border: 1px solid var(--color-dark);
padding: 0.25em 0.75em;
border-radius: 1.25em;
width: fit-content;
}
This will help us find icon buttons that are missing a text alternative.
And then, we could add a text alternative like this with aria-label
or a span
with
the text inside the button:
CSS Background Color Contrast
Following Lea Verou's approach to emulate the upcoming CSS contrast-colors, we can actually get a compliant foreground/background pairing combination.
Examples of changing text color depending on bg color
Examples of changing outline color depending on bg color
How does this work: Steps
Step 1: Choose a color to start with!
The resulting text color is determined by the background color, which is set using the --color
CSS variable. The text color is calculated to ensure a contrast ratio of at least 4.5:1 with the background
color.
Let's say we are selecting the following color:
--color: oklch(65% 0.15 270); // A medium purple
We are using the oklch
color space, which is a perceptually uniform color space that allows us
to define colors in terms of lightness, chroma, and hue.
- Lightness: 65% - This determines how light or dark the color is.
- Chroma: 0.15 - This indicates the saturation of the color, with lower values being less saturated.
- Hue: 270 - This represents the color's position on the color wheel, with 270 degrees corresponding to a shade of purple.
Step 2: Compare the bg lightness to a threshold
We then compare the lightness of the background color to a threshold value (in this case, 62.3%). Why this number? After many tests with thousands of colors, Lea Verou found out that:
--threshold: 62.3%; // A threshold value for lightness
- White text always passes WCAG AA when the lightness of the bg is less than 62.3%
- Black text always passes WCAG AA when the lightness of the bg is greater than 62.3%
So, below the threshold, white text is WCAG-safe, and above, black text is WCAG-safe.
Step 3: We determine the text color with clamp() and add fallback support
We can use the clamp()
function to determine the text color based on the background color's
lightness.
The clamp()
function allows us to set a minimum, preferred, and maximum value for the text
color.
In this case, if the background is darker than the threshold, we will get 1 as output (meaning white text), otherwise, we will get 0 and therefore black text.
.contrast-text {
/* Fallback */
color: white;
text-shadow: 0 0 0.05em black, 0 0 0.05em black;
@supports (color: oklch(from red l c h)) {
--l: clamp(0, (l / var(--l-threshold) - 1) * -100000, 1);
color: oklch(from var(--color) var(--l) 0 h);
text-shadow: none;
}
@supports (color: contrast-color(red)) {
color: contrast-color(var(--color));
}
}
If the browser supports this trick with oklch
(92.13% as of May 2025), it will use that to
determine the text color based on the background color. If not, it will use a white text color with a black
text shadow as a fallback.
And also, when one day the contrast-color()
function is widely supported, we can use it
directly to get the compliant text color based on the background color.