Miscellaneous Tips & Tricks
Jun. 6th, 2025 01:23 pm![[personal profile]](https://www.dreamwidth.org/img/silk/identity/user.png)
![[community profile]](https://www.dreamwidth.org/img/silk/identity/community.png)
I’ve been playing around with the style system, and while it isn’t much, I’ve learned a few things that I feel are worth sharing.
( Cut for length. )I’ve been playing around with the style system, and while it isn’t much, I’ve learned a few things that I feel are worth sharing.
( Cut for length. )If you’re following along, this is the third post in a series about the new CSS shape()
function. We’ve learned how to draw lines and arcs and, in this third part, I will introduce the curve
command — the missing command you need to know to have full control over the shape()
function. In reality, there are more commands, but you will rarely need them and you can easily learn about them later by checking the documentation.
shape()
curve
commandThis command adds a Bézier curve between two points by specifying control points. We can either have one control point and create a Quadratic curve or two control points and create a Cubic curve.
Bézier, Quadratic, Cubic, control points? What?!
For many of you, that definition is simply unclear, or even useless! You can spend a few minutes reading about Bézier curves but is it really worth it? Probably not, unless your job is to create shapes all the day and you have a solid background in geometry.
We already have cubic-bezier()
as an easing function for animations but, honestly, who really understands how it works? We either rely on a generator to get the code or we read a “boring” explanation that we forget in two minutes. (I have one right here by the way!)
Don’t worry, this article will not be boring as I will mostly focus on practical examples and more precisely the use case of rounding the corners of irregular shapes. Here is a figure to illustrate a few examples of Bézier curves.
The blue dots are the starting and ending points (let’s call them A and B) and the black dots are the control points. And notice how the curve is tangent to the dashed lines illustrated in red.
In this article, I will consider only one control point. The syntax will follow this pattern:
clip-path: shape(
from Xa Ya,
curve to Xb Yb with Xc Yc
);
arc
command vs. curve
commandWe already saw in Part 1 and Part 2 that the arc
command is useful establishing rounded edges and corners, but it will not cover all the cases. That’s why you will need the curve
command. The tricky part is to know when to use each one and the answer is “it depends.” There is no generic rule but my advice is to first see if it’s possible (and easy) using arc
. If not, then you have to use curve
.
For some shapes, we can have the same result using both commands and this is a good starting point for us to understand the curve
command and compare it with arc
.
Take the following example:
This is the code for the first shape:
.shape {
clip-path: shape(from 0 0,
arc to 100% 100% of 100% cw,
line to 0 100%)
}
And for the second one, we have this:
.shape {
clip-path: shape(from 0 0,
curve to 100% 100% with 100% 0,
line to 0 100%)
}
The arc
command needs a radius (100%
in this case), but the curve
command needs a control point (which is 100% 0
in this example).
Now, if you look closely, you will notice that both results aren’t exactly the same. The first shape using the arc
command is creating a quarter of a circle, whereas the shape using the curve
command is slightly different. If you place both of them above each other, you can clearly see the difference.
This is interesting because it means we can round some corners using either an arc
or a curve
, but with slightly different results. Which one is better, you ask? I would say it depends on your visual preference and the shape you are creating.
In Part 1, we created rounded tabs using the arc
command, but we can also create them with curve
.
Can you spot the difference? It’s barely visible but it’s there.
Notice how I am using the by
directive the same way I am doing with arc
, but this time we have the control point, which is also relative. This part can be confusing, so pay close attention to this next bit.
Consider the following:
shape(from Xa Ya, curve by Xb Yb with Xc Yc)
It means that both (Xb,Yb)
and (Xc,Yc)
are relative coordinates calculated from the coordinate of the starting point. The equivalent of the above using a to
directive is this:
shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xc) (Yb + Yc))
We can change the reference of the control point by adding a from
directive. We can either use start
(the default value), end
, or origin
.
shape(from Xa Ya, curve by Xb Yb with Xc Yc from end)
The above means that the control point will now consider the ending point instead of the starting point. The result is similar to:
shape(from Xa Ya, curve to (Xa + Xb) (Ya + Yb) with (Xa + Xb + Xc) (Ya + Yb + Yc))
If you use origin
, the reference will be the origin, hence the coordinate of the control point becomes absolute instead of relative.
The from
directive may add some complexity to the code and the calculation, so don’t bother yourself with it. Simply know it exists in case you face it, but keep using the default value.
I think it’s time for your first homework! Similar to the rounded tab exercise, try to create the inverted radius shape we covered in the Part 1 using curve
instead of arc
. Here are both versions for you to reference, but try to do it without peeking first, if you can.
Now that we have a good overview of the curve
command, let’s consider more complex shapes where arc
won’t help us round the corners and the only solution is to draw curves instead. Considering that each shape is unique, so I will focus on the technique rather than the code itself.
Let’s start with a rectangular shape with a slanted edge.
Getting the shape on the left is quite simple, but the shape on the right is a bit tricky. We can round two corners with a simple border-radius
, but for the slanted edge, we will use shape()
and two curve
commands.
The first step is to write the code of the shape without rounded corners (the left one) which is pretty straightforward since we’re only working with the line
command:
.shape {
--s: 90px; /* slant size */
clip-path:
shape(from 0 0,
line to calc(100% - var(--s)) 0,
line to 100% 100%,
line to 0 100%
);
}
Then we take each corner and try to round it by modifying the code. Here is a figure to illustrate the technique I am going to use for each corner.
We define a distance, R
, that controls the radius. From each side of the corner point, I move by that distance to create two new points, which are illustrated above in red. Then, I draw my curve
using the new points as starting and ending points. The corner point will be the control point.
The code becomes:
.shape {
--s: 90px; /* slant size */
clip-path:
shape(from 0 0,
Line to Xa Ya,
curve to Xb Yb with calc(100% - var(--s)) 0,
line to 100% 100%,
line to 0 100%
);
}
Notice how the curve
is using the coordinates of the corner point in the with
directive, and we have two new points, A and B.
Until now, the technique is not that complex. For each corner point, you replace the line
command with line
+ curve
commands where the curve
command reuses the old point in its with
directive.
If we apply the same logic to the other corner, we get the following:
.shape {
--s: 90px; /* slant size */
clip-path:
shape(from 0 0,
line to Xa Ya,
curve to Xb Yb with calc(100% - var(--s)) 0,
line to Xc Yc,
curve to Xd Yd with 100% 100%,
line to 0 100%
);
}
Now we need to calculate the coordinates of the new points. And here comes the tricky part because it’s not always simple and it may require some complex calculation. Even if I detail this case, the logic won’t be the same for the other shapes we’re making, so I will skip the math part and give you the final code:
.box {
--h: 200px; /* element height */
--s: 90px; /* slant size */
--r: 20px; /* radius */
height: var(--h);
border-radius: var(--r) 0 0 var(--r);
--_a: atan2(var(--s), var(--h));
clip-path:
shape(from 0 0,
line to calc(100% - var(--s) - var(--r)) 0,
curve by calc(var(--r) * (1 + sin(var(--_a))))
calc(var(--r) * cos(var(--_a)))
with var(--r) 0,
line to calc(100% - var(--r) * sin(var(--_a)))
calc(100% - var(--r) * cos(var(--_a))),
curve to calc(100% - var(--r)) 100% with 100% 100%,
line to 0 100%
);
}
I know the code looks a bit scary, but the good news is that the code is also really easy to control using CSS variables. So, even if the math is not easy to grasp, you don’t have to deal with it. It should be noted that I need to know the height to be able to calculate the coordinates which means the solution isn’t perfect because the height is a fixed value.
Here’s a similar shape, but this time we have three corners to round using the curve
command.
The final code is still complex but I followed the same steps. I started with this:
.shape {
--s: 90px;
clip-path:
shape(from 0 0,
/* corner #1 */
line to calc(100% - var(--s)) 0,
/* corner #2 */
line to 100% 50%,
/* corner #3 */
line to calc(100% - var(--s)) 100%,
line to 0 100%
);
}
Then, I modified it into this:
.shape {
--s: 90px;
clip-path:
shape(from 0 0,
/* corner #1 */
line to Xa Ya
curve to Xb Yb with calc(100% - var(--s)) 0,
/* corner #2 */
line to Xa Ya
curve to Xb Yb with 100% 50%,
/* corner #3 */
line to Xa Yb
curve to Xb Yb with calc(100% - var(--s)) 100%,
line to 0 100%
);
}
Lastly, I use a pen and paper to do all the calculations.
You might think this technique is useless if you are not good with math and geometry, right? Not really, because you can still grab the code and use it easily since it’s optimized using CSS variables. Plus, you aren’t obligated to be super accurate and precise. You can rely on the above technique and use trial and error to approximate the coordinates. It will probably take you less time than doing all the math.
I know you are waiting for this, right? Thanks to the new shape()
and the curve
command, we can now have rounded polygon shapes!
Here is my implementation using Sass where you can control the radius, number of sides and the rotation of the shape:
If we omit the complex geometry part, the loop is quite simple as it relies on the same technique with a line
+ curve
per corner.
$n: 9; /* number of sides*/
$r: .2; /* control the radius [0 1] */
$a: 15deg; /* control the rotation */
.poly {
aspect-ratio: 1;
$m: ();
@for $i from 0 through ($n - 1) {
$m: append($m, line to Xai Yai, comma);
$m: append($m, curve to Xbi Ybi with Xci Yci, comma);
}
clip-path: shape(#{$m});
}
Here is another implementation where I define the variables in CSS instead of Sass:
Having the variables in CSS is pretty handy especially if you want to have some animations. Here is an example of a cool hover effect applied to hexagon shapes:
I have also updated my online generator to add the radius parameter. If you are not familiar with Sass, you can easily copy the CSS code from there. You will also find the border-only and cut-out versions!
Are we done with the curve
command? Probably not, but we have a good overview of its potential and all the complex shapes we can build with it. As for the code, I know that we have reached a level that is not easy for everyone. I could have extended the explanation by explicitly breaking down the math, but then this article would be overly complex and make it seem like using shape()
is harder than it is.
This said, most of the shapes I code are available within my online collection that I constantly update and optimize so you can easily grab the code of any shape!
If you want a good follow-up to this article, I wrote an article for Frontend Masters where you can create blob shapes using the curve
command.
shape()
Better CSS Shapes Using shape() — Part 3: Curves originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The State of CSS 2025 Survey dropped a few days ago, and besides waiting for the results, it’s exciting to see a lot of the new things shipped to CSS over the past year reflected in the questions. To be specific, the next survey covers the following features:
calc-size()
shape()
text-box-edge
and text-box-trim
field-sizing
::target-text
@function
display: contents
attr()
if()
sibling-index()
and sibling-count()
Again, a lot!
However, I think the most important questions (regarding CSS) are asked at the end of each section. I am talking about the “What are your top CSS pain points related to ______?” questions. These sections are optional, but help user agents and the CSS Working Group know what they should focus on next.
By nature of comments, those respondents with strong opinions are most likely to fill them in, skewing data towards issues that maybe the majority doesn’t have. So, even if you don’t have a hard-set view on a CSS pain point, I encourage you to fill them — even with your mild annoyances.
The State of CSS 2025 Survey is out! originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
In many countries, web accessibility is a human right and the law, and there can be heavy fines for non-compliance. Naturally, this means that text and icons and such must have optimal color contrast in accordance with the benchmarks set by the Web Content Accessibility Guidelines (WCAG). Now, there are quite a few color contrast checkers out there (Figma even has one built-in now), but the upcoming contrast-color()
function doesn’t check color contrast, it outright resolves to either black or white (whichever one contrasts the most with your chosen color).
Right off the bat, you should know that we’ve sorta looked at this feature before. Back then, however, it was called color-contrast()
instead of contrast-color()
and had a much more convoluted way of going about things. It was only released in Safari Technology Preview 122 back in 2021, and that’s still the case at the time I’m writing this (now at version 220).
You’d use it like this:
button {
--background-color: darkblue;
background-color: var(--background-color);
color: contrast-color(var(--background-color));
}
Here, contrast-color()
has determined that white contrasts with darkblue
better than black does, which is why contrast-color()
resolves to white
. Pretty simple, really, but there are a few shortcomings, which includes a lack of browser support (again, it’s only in Safari Technology Preview at the moment).
We can use contrast-color()
conditionally, though:
@supports (color: contrast-color(red)) {
/* contrast-color() supported */
}
@supports not (color: contrast-color(red)) {
/* contrast-color() not supported */
}
contrast-color()
First, let me just say that improvements are already being considered, so here I’ll explain the shortcomings as well as any improvements that I’ve heard about.
Undoubtedly, the number one shortcoming is that contrast-color()
only resolves to either black or white. If you don’t want black or white, well… that sucks. However, the draft spec itself alludes to more control over the resolved color in the future.
But there’s one other thing that’s surprisingly easy to overlook. What happens when neither black nor white is actually accessible against the chosen color? That’s right, it’s possible for contrast-color()
to just… not provide a contrasting color. Ideally, I think we’d want contrast-color()
to resolve to the closest accessible variant of a preferred color. Until then, contrast-color()
isn’t really usable.
Another shortcoming of contrast-color()
is that it only accepts arguments of the <color>
data type, so it’s just not going to work with images or anything like that. I did, however, manage to make it “work” with a gradient (basically, two instances of contrast-color()
for two color stops/one linear gradient):
<button>
<span>A button</span>
</button>
button {
background: linear-gradient(to right, red, blue);
span {
background: linear-gradient(to right, contrast-color(red), contrast-color(blue));
color: transparent;
background-clip: text;
}
}
The reason this looks so horrid is that, as mentioned before, contrast-color()
only resolves to black or white, so in the middle of the gradient we essentially have 50% grey on purple. This problem would also get solved by contrast-color()
resolving to a wider spectrum of colors.
But what about the font size? As you might know already, the criteria for color contrast depends on the font size, so how does that work? Well, at the moment it doesn’t, but I think it’s safe to assume that it’ll eventually take the font-size
into account when determining the resolved color. Which brings us to APCA.
APCA (Accessible Perceptual Contrast Algorithm) is a new algorithm for measuring color contrast reliably. Andrew Somers, creator of APCA, conducted studies (alongside many other independent studies) and learned that 23% of WCAG 2 “Fails” are actually accessible. In addition, an insane 47% of “Passes” are inaccessible.
Not only should APCA do a better job, but the APCA Readability Criterion (ARC) is far more nuanced, taking into account a much wider spectrum of font sizes and weights (hooray for me, as I’m very partial to 600
as a standard font weight). While the criterion is expectedly complex and unnecessarily confusing, the APCA Contrast Calculator does a decent-enough job of explaining how it all works visually, for now.
contrast-color()
doesn’t use APCA, but the draft spec does allude to offering more algorithms in the future. This wording is odd as it suggests that we’ll be able to choose between the APCA and WCAG algorithms. Then again, we have to remember that the laws of some countries will require WCAG 2 compliance while others require WCAG 3 compliance (when it becomes a standard).
That’s right, we’re a long way off of APCA becoming a part of WCAG 3, let alone contrast-color()
. In fact, it might not even be a part of it initially (or at all), and there are many more hurdles after that, but hopefully this sheds some light on the whole thing. For now, contrast-color()
is using WCAG 2 only.
contrast-color()
Here’s a simple example (the same one from earlier) of a darkblue
-colored button with accessibly-colored text chosen by contrast-color()
. I’ve put this darkblue
color into a CSS variable so that we can define it once but reference it as many times as is necessary (which is just twice for now).
button {
--background-color: darkblue;
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
And the same thing but with lightblue
:
button {
--background-color: lightblue;
background-color: var(--background-color);
/* Resolves to black */
color: contrast-color(var(--background-color));
}
First of all, we can absolutely switch this up and use contrast-color()
on the background-color
property instead (or in-place of any <color>
, in fact, like on a border):
button {
--color: darkblue;
color: var(--color);
/* Resolves to white */
background-color: contrast-color(var(--color));
}
Any valid <color>
will work (named, HEX, RGB, HSL, HWB, etc.):
button {
/* HSL this time */
--background-color: hsl(0 0% 0%);
background-color: var(--background-color);
/* Resolves to white */
color: contrast-color(var(--background-color));
}
Need to change the base color on the fly (e.g., on hover)? Easy:
button {
--background-color: hsl(0 0% 0%);
background-color: var(--background-color);
/* Starts off white, becomes black on hover */
color: contrast-color(var(--background-color));
&:hover {
/* 50% lighter */
--background-color: hsl(0 0% 50%);
}
}
Similarly, we could use contrast-color()
with the light-dark()
function to ensure accessible color contrast across light and dark modes:
:root {
/* Dark mode if checked */
&:has(input[type="checkbox"]:checked) {
color-scheme: dark;
}
/* Light mode if not checked */
&:not(:has(input[type="checkbox"]:checked)) {
color-scheme: light;
}
body {
/* Different background for each mode */
background: light-dark(hsl(0 0% 50%), hsl(0 0% 0%));
/* Different contrasted color for each mode */
color: light-dark(contrast-color(hsl(0 0% 50%)), contrast-color(hsl(0 0% 0%));
}
}
The interesting thing about APCA is that it accounts for the discrepancies between light mode and dark mode contrast, whereas the current WCAG algorithm often evaluates dark mode contrast inaccurately. This one nuance of many is why we need not only a new color contrast algorithm but also the contrast-color()
CSS function to handle all of these nuances (font size, font weight, etc.) for us.
This doesn’t mean that contrast-color()
has to ensure accessibility at the expense of our “designed” colors, though. Instead, we can use contrast-color()
within the prefers-contrast: more
media query only:
button {
--background-color: hsl(270 100% 50%);
background-color: var(--background-color);
/* Almost white (WCAG AA: Fail) */
color: hsl(270 100% 90%);
@media (prefers-contrast: more) {
/* Resolves to white (WCAG AA: Pass) */
color: contrast-color(var(--background-color));
}
}
Personally, I’m not keen on prefers-contrast: more
as a progressive enhancement. Great color contrast benefits everyone, and besides, we can’t be sure that those who need more contrast are actually set up for it. Perhaps they’re using a brand new computer, or they just don’t know how to customize accessibility settings.
So, contrast-color()
obviously isn’t useful in its current form as it only resolves to black or white, which might not be accessible. However, if it were improved to resolve to a wider spectrum of colors, that’d be awesome. Even better, if it were to upgrade colors to a certain standard (e.g., WCAG AA) if they don’t already meet it, but let them be if they do. Sort of like a failsafe approach? This means that web browsers would have to take the font size, font weight, element, and so on into account.
To throw another option out there, there’s also the approach that Windows takes for its High Contrast Mode. This mode triggers web browsers to overwrite colors using the forced-colors: active
media query, which we can also use to make further customizations. However, this effect is quite extreme (even though we can opt out of it using the forced-colors-adjust
CSS property and use our own colors instead) and macOS’s version of the feature doesn’t extend to the web.
I think that forced colors is an incredible idea as long as users can set their contrast preferences when they set up their computer or browser (the browser would be more enforceable), and there are a wider range of contrast options. And then if you, as a designer or developer, don’t like the enforced colors, then you have the option to meet accessibility standards so that they don’t get enforced. In my opinion, this approach is the most user-friendly and the most developer-friendly (assuming that you care about accessibility). For complete flexibility, there could be a CSS property for opting out, or something. Just color contrast by default, but you can keep the colors you’ve chosen as long as they’re accessible.
What do you think? Is contrast-color()
the right approach, or should the user agent bear some or all of the responsibility? Or perhaps you’re happy for color contrast to be considered manually?
Exploring the CSS contrast-color() Function… a Second Time originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
“Do I need to translate text alternatives?”
Writing accessible content like alt text and ARIA labels can be challenging. If your website supports multiple languages, then there’s an added layer of complexity to consider—what information should and should not be translated?
On the tech side, there are multiple ways to translate a website.
Regardless of which approach you take, you need to know which words you translate and which you don’t. This gets tricky when some of those words are only exposed to people using assistive technology! Things like CSS tricks and ARIA attributes hide information from sighted developers making them easy to miss. So, here’s a guide for all you developers out there who are asking questions like “do I need to translate text alternatives?”
If you’re just looking for the quick cheat sheet of which attributes and text nodes to include, feel free to jump to the Summary list at the end. Otherwise, read on Macduff!
In general, you must translate attributes if their value is an authorable string instead of a tokenized value or ID reference. It’s possible that URL-based attributes like src
or href
may need to be localized (for example, if the files you’re referencing have a country or language in their folder structure). But that’s not necessarily the same as being translated, so I’m leaving them off the list.
Note: tokenized values mean that the attribute’s value must match one of a pre-defined list of options. For example, type, autocomplete, role, and aria-expanded all have tokenized values.
Translate: Yes
There are a handful of common attributes in HTML that render text to the user (either visually, or through assistive technology).
title
– provides an accessible name for some elements (like an <iframe>
) or provides an accessible description if the element already has a name. The value also displays as hover text when using a mouse.alt
– provides a text alternative to image-based content like <img>
or <input type="image">
. The alt
attribute’s value also displays in the image’s place if that image fails to load.placeholder
– shows hint text in a form field until the user starts to type (worth noting that the placeholder attribute isn’t favorable in accessibility).A few less common attributes you may encounter are:
enterkeyhint
– customizes the “Enter” key text on a virtual keyboard.label
– sets the title text when selecting subtitle or caption tracks in a video player.Here’s a fun word of the day: autoglossonym. This is when a language’s name is written in its own language (for example, Español for Spanish or Français for French). Autoglossonyms are relevant because whether language names are written in the page language vs. their own language will determine if they need to be translated in certain attributes.
Let’s use the HTML <video>
element with multiple caption tracks as an example.
<video controls src="pirates-of-the-caribbean.mp4">
<track src="caption_en.vtt" kind="captions" srclang="en" label="English">
<track src="caption_tlh.vtt" kind="captions" srclang="tlh" label="Klingon">
</video>
Within the <video>
element’s UI, there’s a menu for selecting different caption tracks. The <track>
element’s label
attribute defines the text for each menu option.
If the label is written as an autoglossonym, then there’s no need to translate it (just make sure you have the lang
attribute set on each <track>
). But if each label is written in the page language (like in the screenshot), make sure you translate it! (And as it turns out, some languages do have translations for the word Klingon!)
Translate: Yes
Conveniently, the ARIA specification already has a section for translatable states and properties. It lists these 4 attributes:
aria-label
– used to assign an accessible name to semantic components.aria-placeholder
– like the native HTML placeholder
attribute. It provides hint text for custom form controls where the native attribute can’t be used.aria-roledescription
– overrides a component’s default role with an author-defined value.aria-valuetext
– assigns a human readable text value to a range widget such as a volume slider.In the next ARIA spec version (1.3), W3C proposes a new attribute called aria-description. This attribute has the same purpose as aria-describedby
, but instead of using an ID reference its value is an authorable string (similar to aria-label
vs. aria-labelledby
). I’m including this in the Summary list at the end of the article since it’s already starting to get browser support, even though version 1.3 is still just a working draft as of writing this.
Translate: No
Besides title
, global HTML attributes like id
and class
must not be translated. These are often used for scripting or presentational purposes and shouldn’t be tied to the language on the page. Translating these would likely break functionality or break the visual presentation.
<section id="section-1" class="container">...</section>
Most global HTML and ARIA attributes are either tokenized or reference an HTML ID. These values are interpreted by machines, not by people. Even though the tokenized values are written in English, they should never be translated. Doing so would break a machine’s ability to parse the code correctly.
For example, even though the word “banner” (representing an ARIA landmark role) is written in English, and the page language is Spanish, the code should stay like this:
<html lang="es">
<body>
<div role="banner">...</div>
...
</body>
</html>
The banner role must not be translated like this:
<html lang="es">
<body>
<div role="bandera">...</div>
...
</body>
</html>
Translate: Yes (but…)
There’s one edge-case where you can technically render text on the page using CSS properties (though it’s arguably a bad practice to do so). I’m talking about the CSS content
property. You see this property used with the pseudo-elements ::before
and ::after
. For example, this code would append the text “Email me at” to the beginning of any mailto link.
a[href^="mailto:"]::before {
content: "Email me at ";
}
The text “Email me at” also gets added to the link’s accessible name and needs to be translated. I say this is arguably bad practice because now you’re translating content inside stylesheets which blurs the line between content and presentation assets.
Text nodes in your markup can be hidden in several different ways while still being read by assistive technology. Since users can still perceive this text, it must be translated.
Translate: Yes
If you come across class names like .sr-only
or .visually-hidden
, these are common examples of a CSS trick for writing content that doesn’t appear on screen but is still available to assistive technology.
<span class="visually-hidden">Do you read me?</span>
In short, the text renders with a size of zero so it doesn’t take up any space. But it’s still included in the accessibility tree. If you want to learn all there is to know about this technique, I highly recommend reading The anatomy of visually-hidden by James Edwards.
Translate: Yes
The HTML hidden
attribute or the CSS display:none
and visibility:hidden
properties can also be used to hide content. But in this case, it’s hidden from everyone (unless it isn’t).
Consider this markup:
<fieldset>
<legend>Did you find this article helpful?</legend>
<button aria-pressed="false">Yes</button>
<button aria-pressed="false" aria-describedby="follow-up">No</button>
<div hidden id="follow-up">Triggers dialog with follow-up questions</div>
</fieldset>
This example asks for “Yes” or “No” feedback about an article. Pressing “No” changes the user’s context by launching a dialog, so it’s trying to warn people using screen readers about that change in advance. The <div>
element with the warning text isn’t included in the accessibility tree because of the HTML hidden
attribute. But it’s still announced by screen readers as the button’s description because of the aria-describedby
attribute.
Even though this node is never unhidden, it’s still announced by screen readers with the rest of the button’s properties. So it needs to be translated!
Translate: Yes
Sometimes websites include fallback content for certain scenarios. A common example is the <noscript>
element. This element lets you define content that displays if JavaScript is disabled in the user’s browser. Other elements like the HTML5 <video>
element supports fallback content if the browser doesn’t support that element.
It’s easy to forget about fallback content since, by design, it only shows if the primary content fails. But when someone encounters that fallback content, they’ll want to know what it says. So make sure it’s translated for them!
Translate values for these attributes:
title
alt
placeholder
enterkeyhint
label
(unless the value is written in its own language)aria-label
aria-placeholder
aria-roledescription
aria-valuetext
aria-description
Translate all text nodes, even if they’re hidden with one of these methods:
hidden
attributedisplay:none
in the CSSvisibility:none
in the CSS.sr-only
and .visually-hidden
<noscript>
Don’t translate other global attributes or those that use tokenized values or ID references. For example:
id
class
autocomplete
role
aria-labelledby
aria-describedby
aria-pressed
aria-live
And just avoid using the CSS content
property for rendering text or images that require text alternatives. It’s not worth the hassle.
The post Translating Accessibility appeared first on TPGi.
Like ’em or loath ’em, whether you’re showing an alert, a message, or a newsletter signup, dialogue boxes draw attention to a particular piece of content without sending someone to a different page. In the past, dialogues relied on a mix of divisions, ARIA, and JavaScript. But the HTML dialog
element has made them more accessible and style-able in countless ways.
So, how can you take dialogue box design beyond the generic look of frameworks and templates? How can you style them to reflect a brand’s visual identity and help to tell its stories? Here’s how I do it in CSS using ::backdrop
, backdrop-filter
, and animations.
I mentioned before that Emmy-award-winning game composer Mike Worth hired me to create a highly graphical design. Mike loves ’90s animation, and he challenged me to find ways to incorporate its retro style without making a pastiche. However, I also needed to achieve that retro feel while maintaining accessibility, performance, responsiveness, and semantics.
dialog
and ::backdrop
Let’s run through a quick refresher.
Note: While I mostly refer to “dialogue boxes” throughout, the HTML element is spelt dialog
.
dialog
is an HTML element designed for implementing modal and non-modal dialogue boxes in products and website interfaces. It comes with built-in functionality, including closing a box using the keyboard Esc
key, focus trapping to keep it inside the box, show and hide methods, and a ::backdrop
pseudo-element for styling a box’s overlay.
The HTML markup is just what you might expect:
<dialog>
<h2>Keep me informed</h2>
<!-- ... -->
<button>Close</button>
</dialog>
This type of dialogue box is hidden by default, but adding the open
attribute makes it visible when the page loads:
<dialog open>
<h2>Keep me informed</h2>
<!-- ... -->
<button>Close</button>
</dialog>
I can’t imagine too many applications for non-modals which are open by default, so ordinarily I need a button which opens a dialogue box:
<dialog>
<!-- ... -->
</dialog>
<button>Keep me informed</button>
Plus a little bit of JavaScript, which opens the modal:
const dialog = document.querySelector("dialog");
const showButton = document.querySelector("dialog + button");
showButton.addEventListener("click", () => {
dialog.showModal();
});
Closing a dialogue box also requires JavaScript:
const closeButton = document.querySelector("dialog button");
closeButton.addEventListener("click", () => {
dialog.close();
});
Unless the box contains a form using method="dialog"
, which allows it to close automatically on submit without JavaScript:
<dialog>
<form method="dialog">
<button>Submit</button>
</form>
</dialog>
The dialog
element was developed to be accessible out of the box. It traps focus, supports the Esc
key, and behaves like a proper modal. But to help screen readers announce dialogue boxes properly, you’ll want to add an aria-labelledby
attribute. This tells assistive technology where to find the dialogue box’s title so it can be read aloud when the modal opens.
<dialog aria-labelledby="dialog-title">
<h2 id="dialog-title">Keep me informed</h2>
<!-- ... -->
</dialog>
Most tutorials I’ve seen include very little styling for dialog
and ::backdrop
, which might explain why so many dialogue boxes have little more than border radii and a box-shadow
applied.
I believe that every element in a design — no matter how small or infrequently seen — is an opportunity to present a brand and tell a story about its products or services. I know there are moments during someone’s journey through a design where paying special attention to design can make their experience more memorable.
Dialogue boxes are just one of those moments, and Mike Worth’s design offers plenty of opportunities to reflect his brand or connect directly to someone’s place in Mike’s story. That might be by styling a newsletter sign-up dialogue to match the scrolls in his news section.
Or making the form modal on his error pages look like a comic-book speech balloon.
dialog
in actionMike’s drop-down navigation menu looks like an ancient stone tablet.
I wanted to extend this look to his dialogue boxes with a three-dimensional tablet and a jungle leaf-filled backdrop.
This dialog
contains a newsletter sign-up form with an email input and a submit button:
<dialog>
<h2>Keep me informed</h2>
<form>
<label for="email" data-visibility="hidden">Email address</label>
<input type="email" id="email" required>
<button>Submit</button>
</form>
<button>x</button>
</dialog>
I started by applying dimensions to the dialog
and adding the SVG stone tablet background image:
dialog {
width: 420px;
height: 480px;
background-color: transparent;
background-image: url("dialog.svg");
background-repeat: no-repeat;
background-size: contain;
}
Then, I added the leafy green background image to the dialogue box’s generated backdrop using the ::backdrop
pseudo element selector:
dialog::backdrop {
background-image: url("backdrop.svg");
background-size: cover;
}
I needed to make it clear to anyone filling in Mike’s form that their email address is in a valid format. So I combined :has
and :valid
CSS pseudo-class selectors to change the color of the submit button from grey to green:
dialog:has(input:valid) button {
background-color: #7e8943;
color: #fff;
}
I also wanted this interaction to reflect Mike’s fun personality. So, I also changed the dialog
background image and applied a rubberband animation to the box when someone inputs a valid email address:
dialog:has(input:valid) {
background-image: url("dialog-valid.svg");
animation: rubberBand 0.82s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}
@keyframes rubberBand {
from { transform: scale3d(1, 1, 1); }
30% { transform: scale3d(1.25, 0.75, 1); }
40% { transform: scale3d(0.75, 1.25, 1); }
50% { transform: scale3d(1.15, 0.85, 1); }
65% { transform: scale3d(0.95, 1.05, 1); }
75% { transform: scale3d(1.05, 0.95, 1); }
to { transform: scale3d(1, 1, 1); }
}
Tip: Daniel Eden’s Animate.css library is a fabulous source of “Just-add-water CSS animations” like the rubberband I used for this dialogue box.
Changing how an element looks when it contains a valid input is a fabulous way to add interactions that are, at the same time, fun and valuable for the user.
That combination of :has
and :valid
selectors can even be extended to the ::backdrop
pseudo-class, to change the backdrop’s background image:
dialog:has(input:valid)::backdrop {
background-image: url("backdrop-valid.svg");
}
Try it for yourself:
We often think of dialogue boxes as functional elements, as necessary interruptions, but nothing more. But when you treat them as opportunities for expression, even the smallest parts of a design can help shape a product or website’s personality.
The HTML dialog
element, with its built-in behaviours and styling potential, opens up opportunities for branding and creative storytelling. There’s no reason a dialogue box can’t be as distinctive as the rest of your design.
Often referred to as one of the pioneers of web design, Andy Clarke has been instrumental in pushing the boundaries of web design and is known for his creative and visually stunning designs. His work has inspired countless designers to explore the full potential of product and website design.
Andy’s written several industry-leading books, including ‘Transcending CSS,’ ‘Hardboiled Web Design,’ and ‘Art Direction for the Web.’ He’s also worked with businesses of all sizes and industries to achieve their goals through design.
Visit Andy’s studio, Stuff & Nonsense, and check out his Contract Killer, the popular web design contract template trusted by thousands of web designers and developers.
Getting Creative With HTML Dialog originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
The European Accessibility Act (EAA) is now enforceable, bringing clear expectations for any organization offering digital services or devices within the European Union (EU).
Teams that manage websites, mobile apps, or self-service kiosks for services such as e-commerce, public transport, and banking are responsible for making those experiences accessible and usable for people with disabilities.
The EAA aims to ensure that digital interactions are accessible to everyone, including people with cognitive, motor, or sensory disabilities, as well as those using assistive technologies.
The benefits go beyond just compliance. Accessible design also helps people pushing strollers, older adults, or multilingual users. It’s about making things easier for more people in more situations.
For websites, the EAA guidance aligns with EN 301 549, which references the Web Content Accessibility Guidelines (WCAG). Automated tools are useful for identifying some WCAG issues, but they don’t tell the whole story.
A web accessibility testing tool is extremely valuable, especially during the development process. Still, many issues (like poor keyboard navigation or screen reader confusion) require manual review and usability testing to identify and improve the user experience.
Procurement teams in both public and private sectors are already asking vendors to prove conformance. If your digital platform isn’t accessible, you could be excluded from major contracts.
Accessibility audits help you understand where your digital products fall short. They can act as a roadmap, but it’s up to you to follow it.
It’s the remediation, or the actual fixing of accessibility barriers, that improves the accessibility of your websites and apps, supporting people with disabilities while aligning with standards like EN 301 549. Acting on those findings brings you closer to meeting requirements and creates a better user experience for everyone.
Most companies are focused on their websites and apps. But kiosks? They’re often the last to be tested, even though they can often be the first customer touch point.
Organizations offering self-service technology, such as websites, apps, and kiosks, across various sectors, including fast food, transit, healthcare, or finance, must evaluate kiosk accessibility more holistically.
The EAA requires that your kiosks meet accessibility standards not only at the software level but also in their real-world functionality. That includes their physical placement, user interface (UI) design, and interactive features.
If your kiosk screen isn’t readable by a screen reader, or if someone using a wheelchair can’t reach the controls, that’s a barrier. And under the EAA, those barriers are not acceptable.
Too often, accessibility is treated as an afterthought in kiosk design. Only addressed after deployment, when fixes are expensive and disruptive. We’ve worked with clients who discovered critical usability gaps after users with disabilities reported issues directly. That’s avoidable.
Your kiosk should work for people who are blind, have low vision, have limited reach, strength, or dexterity, or navigate content in different ways. That includes:
Automated tools won’t catch most of these. And often, automated accessibility tools are optimized for web accessibility rather than kiosk accessibility, where there are subtle but significant differences in what is needed to ensure an accessible user experience for people with disabilities. You need real-world testing guided by experts.
Whether you need to evaluate an existing platform or build accessibility into a new one, we’ll meet you where you are and help you get where you need to be.
Whether you’re deploying a new kiosk system or updating your website, we’re here to help you build accessibility into your process or fix the gaps before they create risk. Our team provides:
Contact us today to assess your kiosks, test your websites and apps, or explore how our services can support your accessibility and compliance goals. The sooner your digital tools are usable by everyone, the better they work for everyone.
The post Are Your Self-Service Kiosk Devices and Websites EAA-Compliant? appeared first on TPGi.
Artificial Intelligence is transforming how digital experiences are created, but is accessibility keeping up?
Join hosts Mark Miller and David Sloan as they explore how AI is reshaping design and development workflows, from automated code generation to dynamic user interfaces. They’ll spotlight both the promises and pitfalls of AI when it comes to inclusive digital experiences.
Discover what your teams need to consider as you integrate AI tools into your processes, and how to ensure these innovations benefit all users. Don’t miss this timely discussion streaming live on our LinkedIn on June 18, 2025, at 10:30 AM EST.
The post The State of Accessibility – Episode 10 appeared first on TPGi.
Ready for the second part? We are still exploring the shape()
function, and more precisely, the arc command. I hope you took the time to digest the first part because we will jump straight into creating more shapes!
As a reminder, the shape()
function is only supported in Chrome 137+ and Safari 18.4+ as I’m writing this in May 2025.
shape()
Another classic shape that can also be used in pie-like charts.
It’s already clear that we have one arc. As for the points, we have two points that don’t move and one that moves depending on how much the sector is filled.
The code will look like this:
.sector {
--v: 35; /* [0 100]*/
aspect-ratio: 1;
clip-path: shape(from top, arc to X Y of R, line to center);
}
We define a variable that will control the filling of the sector. It has a value between 0
and 100
. To draw the shape, we start from the top
, create an arc until the point (X, Y), and then we move to the center
.
Are we allowed to use keyword values like
top
andcenter
?
Yes! Unlike the polygon()
function, we have keywords for the particular cases such as top
, bottom
, left
, etc. It’s exactly like background-position
that way. I don’t think I need to detail this part as it’s trivial, but it’s good to know because it can make your shape a bit easier to read.
The radius of the arc should be equal to 50%
. We are working with a square element and the sector, which is a portion of a circle, need to fill the whole element so the radius is equal to half the width (or height).1
As for the point, it’s placed within that circle, and its position depends on the V value. You don’t want a boring math explanation, right? No need for it, here is the formula of X and Y:
X = 50% + 50% * sin(V * 3.6deg)
Y = 50% - 50% * cos(V * 3.6deg)
Our code becomes:
.sector {
--v: 35; /* [0 100] */
aspect-ratio: 1;
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--v) * 3.6deg))
calc(50% - 50% * cos(var(--v) * 3.6deg)) of 50%,
line to center);
}
Hmm, the result is not good, but there are no mistakes in the code. Can you figure out what we are missing?
It’s the size and direction of the arc!
Remember what I told you in the last article? You will always have trouble with them, but if we try the different combinations, we can easily fix the issue. In our case, we need to use: small cw
.
Better! Let’s try it with more values and see how the shape behaves:
Oops, some values are good, but others not so much. The direction needs to be clockwise, but maybe we should use large
instead of small
? Let’s try:
Still not working. The issue here is that we are moving one point of the arc based on the V value, and this movement creates a different configuration for the arc
command.
Here is an interactive demo to better visualize what is happening:
When you update the value, notice how large cw
always tries to follow the largest arc between the points, while small cw
tries to follow the smallest one. When the value is smaller than 50
, small cw
gives us a good result. But when it’s bigger than 50
, the large cw
combination is the good one.
I know, it’s a bit tricky and I wanted to study this particular example to emphasize the fact that we can have a lot of headaches working with arcs. But the more issues we face, the better we get at fixing them.
The solution in this case is pretty simple. We keep the use of large cw
and add a border-radius
to the element. If you check the previous demo, you will notice that even if large cw
is not producing a good result, it’s filling the area we want. All we need to do is clip the extra space and a simple border-radius: 50%
will do the job!
I am keeping the box-shadow
in there so we can see the arc, but we can clearly see how border-radius
is making a difference on the main shape.
There is still one edge case we need to consider. When the value is equal to 100
, both points of the arc will have the same coordinates, which is logical since the sector is full and we have a circle. But when it’s the case, the arc will do nothing by definition and we won’t get a full circle.
To fix this, we can limit the value to, for example, 99.99
to avoid reaching 100
. It’s kind of hacky, but it does the job.
.sector {
--v: 35; /* [0 100]*/
--_v: min(99.99, var(--v));
aspect-ratio: 1;
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--_v) * 3.6deg))
calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% large cw,
line to center);
border-radius: 50%;
}
Now our shape is perfect! And don’t forget that you can apply it to image elements:
Similar to the sector shape, we can also create an arc shape. After all, we are working with the arc
command, so we have to do it.
We already have half the code since it’s basically a sector shape without the inner part. We simply need to add more commands to cut the inner part.
.arc {
--v: 35;
--b: 30px;
--_v: min(99.99, var(--v));
aspect-ratio: 1;
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--_v) * 3.6deg))
calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw large,
line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
arc to 50% var(--b) of calc(50% - var(--b)) large
);
border-radius: 50%;
}
From the sector shape, we remove the line to center
piece and replace it with another line
command that moves to a point placed on the inner circle. If you compare its coordinates with the previous point, you will see an offset equal to --b
, which is a variable that defines the arc’s thickness. Then we draw an arc in the opposite direction (ccw
) until the point 50% var(--b)
, which is also a point with an offset equal to --b
from the top.
I am not defining the direction of the second arc since, by default, the browser will use ccw
.
Ah, the same issue we hit with the sector shape is striking again! Not all the values are giving a good result due to the same logic we saw earlier, and, as you can see, border-radius
is not fixing it. This time, we need to find a way to conditionally change the size of the arc based on the value. It should be large
when V is bigger than 50
, and small
otherwise.
Conditions in CSS? Yes, it’s possible! First, let’s convert the V value like this:
--_f: round(down, var(--_v), 50)
The value is within the range [0 99.99]
(don’t forget that we don’t want to reach the value 100). We use round()
to make sure it’s always equal to a multiple of a specific value, which is 50
in our case. If the value is smaller than 50
, the result is 0
, otherwise it’s 50
.
There are only two possible values, so we can easily add a condition. If --_f
is equal to 0
we use small; otherwise, we use large:
.arc {
--v: 35;
--b: 30px;
--_v: min(99.99, var(--v));
--_f: round(down,var(--_v), 50);
--_c: if(style(--_f: 0): small; else: large);
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--_v) * 3.6deg))
calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c),
line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
arc to 50% var(--b) of calc(50% - var(--b)) var(--_c)
);
}
I know what you are thinking, but let me tell you that the above code is valid. You probably don’t know it yet, but CSS has recently introduced inline conditionals using an if()
syntax. It’s still early to play with it, but we have found a perfect use case for it. Here is a demo that you can test using Chrome Canary:
Another way to express conditions is to rely on style queries that have better support:
.arc {
--v: 35;
--b: 30px;
--_v: min(99.99, var(--v));
--_f: round(down, var(--_v), 50);
aspect-ratio: 1;
container-name: arc;
}
.arc:before {
content: "";
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--_v) * 3.6deg))
calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
line to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)),
arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large)
);
@container style(--_f: 0) { --_c: small }
}
The logic is the same but, this feature requires a parent-child relation, which is why I am using a pseudo-element. By default, the size will be large
, and if the value of --_f
is equal to 0
, we switch to small
.
Note that we have to register the variable --_f
using @property
to be able to either use the if()
function or style queries.
Did you notice another subtle change I have made to the shape? I removed border-radius
and I applied the conditional logic to the first arc. Both have the same issue, but border-radius
can fix only one of them while the conditional logic can fix both, so we can optimize the code a little.
What about adding rounded edges to our arc? It’s better, right?
Can you see how it’s done? Take it as a small exercise and update the code from the previous examples to add those rounded edges. I hope you are able to find it by yourself because the changes are pretty straightforward — we update one line
command with an arc
command and we add another arc
command at the end.
clip-path: shape(from top,
arc to calc(50% + 50% * sin(var(--_v) * 3.6deg))
calc(50% - 50% * cos(var(--_v) * 3.6deg)) of 50% cw var(--_c, large),
arc to calc(50% + (50% - var(--b)) * sin(var(--_v) * 3.6deg))
calc(50% - (50% - var(--b)) * cos(var(--_v) * 3.6deg)) of 1% cw,
arc to 50% var(--b) of calc(50% - var(--b)) var(--_c, large),
arc to top of 1% cw
);
If you do not understand the changes, get out a pen and paper, then draw the shape to better see the four arcs we are drawing. Previously, we had two arcs and two lines, but now we are working with arcs instead of lines.
And did you remember the trick of using a 1%
value for the radius? The new arcs are half circles, so we can rely on that trick where you specify a tiny radius and the browser will do the job for you and find the correct value!
We are done — enough about the arc
command! I had to write two articles that focus on this command because it’s the trickiest one, but I hope it’s now clear how to use it and how to handle the direction and size thing, as that is probably the source of most headaches.
By the way, I have only studied the case of circular arcs because, in reality, we can specify two radii and draw elliptical ones, which is even more complex. Unless you want to become a shape()
master, you will rarely need elliptical arcs, so don’t bother yourself with them.
Until the next article, I wrote an article for Frontend Masters where you can create more fancy shapes using the arc
command that is a good follow-up to this one.
shape()
(1) The arc
command is defined to draw elliptical arcs by taking two radii, but if we define one radius value, it means that the vertical and horizontal radius will use that same value and we have circular arcs. When it’s a length, it’s trivial, but when we use percentages, the value will resolve against the direction-agnostic size, which is equal to the length of the diagonal of the box, divided by sqrt(2)
.
In our case, we have a square element so 50% of the direction-agnostic size will be equal to 50% of sqrt(Width² + Height²)/sqrt(2)
. And since both width and height are equal, we end with 50% of the width (or the height). ⮑
Better CSS Shapes Using shape() — Part 2: More on Arcs originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.
I don’t use large language models. My objection to using them is ethical. I know how the sausage is made.
I wanted to clarify that. I’m not rejecting large language models because they’re useless. They can absolutely be useful. I just don’t think the usefulness outweighs the ethical issues in how they’re trained.
Molly White came to the same conclusion:
The benefits, though extant, seem to pale in comparison to the costs.
What I do know is that I find LLMs useful on occasion, but every time I use one I die a little inside.
I genuinely look forward to being able to use a large language model with a clear conscience. Such a model would need to be trained ethically. When we get a free-range organic large language model I’ll be the first in line to use it. Until then, I’ll abstain. Remember:
You don’t get companies to change their behaviour by rewarding them for it. If you really want better behaviour from the purveyors of generative tools, you should be boycotting the current offerings.
Still, in anticipation of an ethical large language model someday becoming reality, I think it’s good for me to have an understanding of which tasks these tools are good at.
Prototyping seems like a good use case. My general attitude to prototyping is the exact opposite to my attitude to production code; use absolutely any tool you want and prioritise speed over quality.
When it comes to coding in general, I think Laurie is really onto something when he says:
Is what you’re doing taking a large amount of text and asking the LLM to convert it into a smaller amount of text? Then it’s probably going to be great at it. If you’re asking it to convert into a roughly equal amount of text it will be so-so. If you’re asking it to create more text than you gave it, forget about it.
In other words, despite what the hype says, these tools are far better at transforming than they are at generating.
Iris Meredith goes deeper into this distinction between transformative and compositional work:
Compositionality relies (among other things) on two core values or functions: choice and precision, both of which are antithetical to LLM functioning.
My own take on this is that transformative work is often the drudge work—take this data dump and convert it to some other format; take this mock-up and make a disposable prototype. I want my tools to help me with that.
But compositional work that relies on judgement, taste, and choice? Not only would I not use a large language model for that, it’s exactly the kind of work that I don’t want to automate away.
Transformative work is done with broad brushstrokes. Compositional work is done with a scalpel.
Large language models are big messy brushes, not scalpels.
The reading-flow
and reading-order
proposed CSS properties are designed to specify the source order of HTML elements in the DOM tree, or in simpler terms, how accessibility tools deduce the order of elements. You’d use them to make the focus order of focusable elements match the visual order, as outlined in the Web Content Accessibility Guidelines (WCAG 2.2).
To get a better idea, let’s just dive in!
(Oh, and make sure that you’re using Chrome 137 or higher.)
reading-flow
reading-flow
determines the source order of HTML elements in a flex, grid, or block layout. Again, this is basically to help accessibility tools provide the correct focus order to users.
The default value is normal
(so, reading-flow: normal
). Other valid values include:
flex-visual
flex-flow
grid-rows
grid-columns
grid-order
source-order
Let’s start with the flex-visual
value. Imagine a flex row with five links. Assuming that the reading direction is left-to-right (by the way, you can change the reading direction with the direction
CSS property), that’d look something like this:
Now, if we apply flex-direction: row-reverse
, the links are displayed 5-4-3-2-1. The problem though is that the focus order still starts from 1 (tab through them!), which is visually wrong for somebody that reads left-to-right.
But if we also apply reading-flow: flex-visual
, the focus order also becomes 5-4-3-2-1, matching the visual order (which is an accessibility requirement!):
<div>
<a>1</a>
<a>2</a>
<a>3</a>
<a>4</a>
<a>5</a>
</div>
div {
display: flex;
flex-direction: row-reverse;
reading-flow: flex-visual;
}
To apply the default flex behavior, reading-flow: flex-flow
is what you’re looking for. This is very akin to reading-flow: normal
, except that the container remains a reading flow container, which is needed for reading-order
(we’ll dive into this in a bit).
For now, let’s take a look at the grid-y values. In the grid below, the grid items are all jumbled up, and so the focus order is all over the place.
We can fix this in two ways. One way is that reading-flow: grid-rows
will, as you’d expect, establish a row-by-row focus order:
<div>
<a>1</a>
<a>2</a>
<a>3</a>
<a>4</a>
<a>5</a>
<a>6</a>
<a>7</a>
<a>8</a>
<a>9</a>
<a>10</a>
<a>11</a>
<a>12</a>
</div>
div {
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-auto-rows: 100px;
reading-flow: grid-rows;
a:nth-child(2) {
grid-row: 2 / 4;
grid-column: 3;
}
a:nth-child(5) {
grid-row: 1 / 3;
grid-column: 1 / 3;
}
}
Or, reading-flow: grid-columns
will establish a column-by-column focus order:
reading-flow: grid-order
will give us the default grid behavior (i.e., the jumbled up version). This is also very akin to reading-flow: normal
(except that, again, the container remains a reading flow container, which is needed for reading-order
).
There’s also reading-flow: source-order
, which is for flex, grid, and block containers. It basically turns containers into reading flow containers, enabling us to use reading-order
. To be frank, unless I’m missing something, this appears to make the flex-flow
and grid-order
values redundant?
reading-order
reading-order
sort of does the same thing as reading-flow
. The difference is that reading-order
is for specific flex or grid items, or even elements in a simple block container. It works the same way as the order
property, although I suppose we could also compare it to tabindex
.
Note: To use reading-order
, the container must have the reading-flow
property set to anything other than normal
.
I’ll demonstrate both reading-order
and order
at the same time. In the example below, we have another flex container where each flex item has the order
property set to a different random number, making the order of the flex items random. Now, we’ve already established that we can use reading-flow
to determine focus order regardless of visual order, but in the example below we’re using reading-order
instead (in the exact same way as order
):
div {
display: flex;
reading-flow: source-order; /* Anything but normal */
/* Features at the end because of the higher values */
a:nth-child(1) {
/* Visual order */
order: 567;
/* Focus order */
reading-order: 567;
}
a:nth-child(2) {
order: 456;
reading-order: 456;
}
a:nth-child(3) {
order: 345;
reading-order: 345;
}
a:nth-child(4) {
order: 234;
reading-order: 234;
}
/* Features at the beginning because of the lower values */
a:nth-child(5) {
order: -123;
reading-order: -123;
}
}
Yes, those are some rather odd numbers. I’ve done this to illustrate how the numbers don’t represent the position (e.g., order: 3
or reading-order: 3
doesn’t make it third in the order). Instead, elements with lower numbers are more towards the beginning of the order and elements with higher numbers are more towards the end. The default value is 0
. Elements with the same value will be ordered by source order.
In practical terms? Consider the following example:
div {
display: flex;
reading-flow: source-order;
a:nth-child(1) {
order: 1;
reading-order: 1;
}
a:nth-child(5) {
order: -1;
reading-order: -1;
}
}
Of the five flex items, the first one is the one with order: -1
because it has the lowest order
value. The last one is the one with order: 1
because it has the highest order
value. The ones with no declaration default to having order: 0
and are thus ordered in source order, but otherwise fit in-between the order: -1
and order: 1
flex items. And it’s the same concept for reading-order
, which in the example above mirrors order
.
However, when reversing the direction of flex items, keep in mind that order
and reading-order
work a little differently. For example, reading-order: -1
would, as expected, and pull a flex item to the beginning of the focus order. Meanwhile, order: -1
would pull it to the end of the visual order because the visual order is reversed (so we’d need to use order: 1
instead, even if that doesn’t seem right!):
div {
display: flex;
flex-direction: row-reverse;
reading-flow: source-order;
a:nth-child(5) {
/* Because of row-reverse, this actually makes it first */
order: 1;
/* However, this behavior doesn’t apply to reading-order */
reading-order: -1;
}
}
reading-order
overrides reading-flow
. If we, for example, apply reading-flow: flex-visual
, reading-flow: grid-rows
, or reading-flow: grid-columns
(basically, any declaration that does in fact change the reading flow), reading-order
overrides it. We could say that reading-order
is applied after reading-flow
.
Well, that obviously rules out all of the flex-y and grid-y reading-flow
values; however, you can still set reading-flow: source-order
on a block element and then manipulate the focus order with reading-order
(as we did above).
tabindex
HTML attribute?They’re not equivalent. Negative tabindex
values make targets unfocusable and values other than 0
and -1
aren’t recommended, whereas a reading-order
declaration can use any number as it’s only contextual to the reading flow container that contains it.
For the sake of being complete though, I did test reading-order
and tabindex
together and reading-order
appeared to override tabindex
.
Going forward, I’d only use tabindex
(specifically, tabindex="-1"
) to prevent certain targets from being focusable (the disabled
attribute will be more appropriate for some targets though), and then reading-order
for everything else.
Being able to define reading order is useful, or at least it means that the order
property can finally be used as intended. Up until now (or rather when all web browsers support reading-flow
and reading-order
, because they only work in Chrome 137+ at the moment), order
hasn’t been useful because we haven’t been able to make the focus order match the visual order.
What We Know (So Far) About CSS Reading Order originally published on CSS-Tricks, which is part of the DigitalOcean family. You should get the newsletter.