CSS Day 2025

Beyond Borders (and Outlines):
Crafting Inclusive Experiences with Modern CSS

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:

Go to Example

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 Contacts
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;
}
    Wrong child
  • Okay
    Wrong child
  1. 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.

A random image
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 no for attribute
  • label with no input or textarea
  • input or textarea with no id 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;
}
(We do not see a message in the latest case, because input does not allow pseudoelements.)
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
This text color adapts based on background color
This text color adapts based on background color
This text color adapts based on background color
This text color adapts based on background color
This text color adapts based on background 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.