CSS accesible
Lección 29 de 37 · 4 min
La accesibilidad empieza en el HTML, pero el CSS puede romperla o sostenerla: un foco que no se ve, una animación que marea o un botón demasiado chico son problemas de CSS. Acá están las herramientas para hacerlo bien.
Un foco que se vea
Quien navega con el teclado necesita ver en todo momento dónde está. :focus coincide con el elemento que tiene el foco, venga del teclado o de un clic. :focus-visible coincide solo cuando el navegador cree que conviene mostrarlo: al llegar con Tab sí, al hacer clic en un botón no. En un campo de texto coincide siempre, porque ahí el foco indica dónde vas a escribir.
El anillo se dibuja con outline, que no ocupa lugar ni mueve nada, y outline-offset lo separa del borde (más detalles en Bordes y sombras). Con currentColor toma el color del texto y contrasta en modo claro y oscuro. Ojo con los botones invertidos (fondo oscuro y texto claro, o al revés): ahí el anillo toma el color del texto y puede perderse contra la página.
Elegí :focus, hacé clic en una página y después repetí con :focus-visible. Probá también con Tab: el texto de arriba dice qué coincide en el botón enfocado.
Archivo de notas
13–24 de 140
.paginas button:focus-visible {outline: 2px solid currentColor;outline-offset: 2px;}
<button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current">Siguiente</button>
:focus-visible es Baseline ampliamente disponible desde hace tiempo. Nunca saques el contorno con outline: none sin poner otro igual de visible: WCAG pide un indicador de foco visible.
Preferencias del sistema
Las media queries leen ajustes que cada persona eligió en su sistema. Respetarlos no es un detalle: hay gente que se marea con el movimiento y gente que no distingue un gris claro de un blanco.
- Menos movimiento
prefers-reduced-motion: reduceNo hace falta sacar toda animación: dejá los fundidos y quitá desplazamientos, zooms y parallax. - Más contraste
prefers-contrast: moreBordes sólidos en lugar de translúcidos, texto secundario al mismo color que el principal. - Tema oscuro
prefers-color-scheme: darkSumalecolor-scheme: light darken:rootpara que los controles nativos y las barras de scroll también cambien. - Colores forzados
forced-colors: activeEl sistema reemplaza tus colores por su paleta, como el modo de alto contraste de Windows.
En modo de colores forzados el navegador pisa colores, fondos y sombras, y desaparece todo lo que dependía de ellos: un ícono hecho con un fondo de color, un borde dibujado con box-shadow. Para esos casos están los colores del sistema (Canvas, CanvasText, LinkText, ButtonText, Highlight…), que siempre valen lo que el sistema eligió.
@media (forced-colors: active) {.estado { border: 1px solid CanvasText; }/* este sí conserva su color */.muestra-de-color { forced-color-adjust: none; }}
Las cuatro media queries son Baseline ampliamente disponible. forced-color-adjust no: está en Chrome 89 y Firefox 113, pero no en Safari. Usalo solo para lo que de verdad necesita su color, como una muestra en un selector de colores.
Estilos según el estado ARIA
Un botón que abre un panel tiene que anunciar si está abierto, y para eso existe aria-expanded. Si el CSS lee ese mismo atributo, el dibujo y lo que oye el lector de pantalla salen de un solo lugar y no se pueden contradecir. Con una clase aparte (.abierta) hay dos estados que mantener, y alcanza con olvidarse uno.
Abrí y cerrá preguntas con las dos opciones. En la versión con clase, el script cambia solo la clase: se ve abierta, pero el lector sigue anunciando contraída.
Hasta cinco a la vez, por 15 días. Podés renovarlos dos veces.
No hay multa: el préstamo se suspende un día por cada día de atraso.
Sí, en cualquiera de las ocho sedes.
Hasta cinco a la vez, por 15 días. Podés renovarlos dos veces.
No hay multa: el préstamo se suspende un día por cada día de atraso.
Sí, en cualquiera de las ocho sedes.
Lo mismo sirve para otros estados que ya tienen atributo:
nav [aria-current="page"] { font-weight: 500; text-decoration: underline; }[aria-invalid="true"] { border-color: oklch(0.58 0.2 27); }[aria-pressed="true"] { background: oklch(0.92 0 0); }[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
Texto oculto y tamaño de los objetivos
Un botón que es solo un ícono necesita un nombre. Ponelo como texto dentro del botón y ocultalo con el patrón visually hidden: queda en el árbol de accesibilidad pero no ocupa lugar en pantalla. display: none o visibility: hidden no sirven, porque también lo esconden del lector.
El tamaño también importa. WCAG 2.2 pide que cada objetivo mida al menos 24 × 24 px (criterio 2.5.8, nivel AA), salvo que esté lo bastante separado de sus vecinos; el nivel AAA pide 44 × 44. Si el diseño quiere un ícono más chico, agrandá solo el área que recibe el clic con un ::before invisible.
Guardado
Compras del sábado
Yerba, pan de campo, dos kilos de naranjas y el repuesto del filtro de agua.
.barra button {position: relative;width: 28px;height: 28px;}.barra button::before {content: "";position: absolute;inset: min(0px, (100% - 24px) / 2);}.visually-hidden {position: absolute;width: 1px;height: 1px;margin: -1px;padding: 0;overflow: hidden;clip-path: inset(50%);white-space: nowrap;border: 0;}
<button class="relative size-7 before:absolute before:inset-[min(0px,(100%-24px)/2)] before:content-['']"><svg aria-hidden="true">…</svg><span class="sr-only">Negrita</span></button>
El inset con min() hace la cuenta solo: si el botón mide 20px, sale −2px por lado y el área llega a 24; si ya mide 24 o más, queda en 0 y no la achica.
En Tailwind
Tailwind trae variantes para todo esto. Los colores del sistema van entre corchetes, y el patrón visually hidden ya viene como sr-only (con not-sr-only para mostrarlo de nuevo, por ejemplo un enlace para saltar al contenido).
| CSS | Tailwind |
|---|---|
| :focus-visible | focus-visible: |
| outline: 2px solid currentColor | outline-2 outline-current |
| prefers-reduced-motion | motion-reduce: · motion-safe: |
| prefers-contrast: more | contrast-more: |
| forced-colors: active | forced-colors: |
| border-color: CanvasText | border-[CanvasText] |
| [aria-expanded="true"] | aria-expanded: · group-aria-expanded: |
| [aria-current="page"] | aria-[current=page]: |
| visually hidden | sr-only |
<button aria-pressed="true"class="relative size-5 aria-pressed:bg-neutral-200before:absolute before:content-['']before:inset-[min(0px,(100%-24px)/2)]focus-visible:outline-2 focus-visible:outline-offset-2focus-visible:outline-current"><svg aria-hidden="true">…</svg><span class="sr-only">Negrita</span></button><div class="motion-safe:animate-[entrar_250ms_ease-out]contrast-more:border-current">…</div>
Accessible CSS
Lesson 29 of 37 · 4 min
Accessibility starts in the HTML, but CSS can break it or hold it up: an invisible focus, an animation that makes people dizzy or a button too small to hit are CSS problems. Here are the tools to get it right.
A focus you can see
Keyboard users need to see where they are at all times. :focus matches the element that has focus, whether it came from the keyboard or a click. :focus-visible only matches when the browser thinks showing it helps: arriving with Tab, yes; clicking a button, no. In a text field it always matches, because there focus tells you where you'll type.
The ring is drawn with outline, which takes no room and moves nothing, and outline-offset moves it away from the border (more in Borders and shadows). With currentColor it takes the text color and contrasts in light and dark mode. Watch out for inverted buttons (dark fill with light text, or the reverse): there the ring takes the text color and can get lost against the page.
Pick :focus, click a page, then do the same with :focus-visible. Try Tab too: the text above says what matches on the focused button.
Article archive
13–24 of 140
.pages button:focus-visible {outline: 2px solid currentColor;outline-offset: 2px;}
<button class="focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-current">Next</button>
:focus-visible has been Baseline widely available for a while. Never remove the outline with outline: none without an equally visible replacement: WCAG requires a visible focus indicator.
System preferences
Media queries read settings each person chose on their system. Respecting them isn't a nicety: some people get dizzy from motion, and some can't tell a light gray from white.
- Less motion
prefers-reduced-motion: reduceYou don't need to drop every animation: keep the fades, remove slides, zooms and parallax. - More contrast
prefers-contrast: moreSolid borders instead of translucent ones, secondary text in the same color as the main text. - Dark theme
prefers-color-scheme: darkAddcolor-scheme: light darkon:rootso native controls and scrollbars switch too. - Forced colors
forced-colors: activeThe system swaps your colors for its own palette, like Windows high contrast mode.
In forced colors mode the browser overrides colors, backgrounds and shadows, and anything that relied on them vanishes: an icon made from a colored background, a border drawn with box-shadow. That's what system colors are for (Canvas, CanvasText, LinkText, ButtonText, Highlight…): they always hold whatever the system chose.
@media (forced-colors: active) {.status { border: 1px solid CanvasText; }/* this one keeps its color */.color-swatch { forced-color-adjust: none; }}
All four media queries are Baseline widely available. forced-color-adjust isn't: it's in Chrome 89 and Firefox 113, but not in Safari. Use it only for what truly needs its color, like a swatch in a color picker.
Styling by ARIA state
A button that opens a panel has to announce whether it's open, and that's what aria-expanded is for. If the CSS reads that same attribute, the drawing and what a screen reader hears come from one place and can't disagree. With a separate class (.is-open) there are two states to keep in sync, and forgetting one is enough.
Open and close questions with both options. In the class version the script only toggles the class: it looks open, but the screen reader still announces it collapsed.
Up to five at a time, for 15 days. You can renew them twice.
There's no fine: borrowing is paused one day for every day late.
Yes, at any of the eight branches.
Up to five at a time, for 15 days. You can renew them twice.
There's no fine: borrowing is paused one day for every day late.
Yes, at any of the eight branches.
The same goes for other states that already have an attribute:
nav [aria-current="page"] { font-weight: 500; text-decoration: underline; }[aria-invalid="true"] { border-color: oklch(0.58 0.2 27); }[aria-pressed="true"] { background: oklch(0.92 0 0); }[aria-disabled="true"] { opacity: 0.5; cursor: not-allowed; }
Hidden text and target size
An icon-only button needs a name. Put it as text inside the button and hide it with the visually hidden pattern: it stays in the accessibility tree but takes no room on screen. display: none or visibility: hidden won't do, because they hide it from screen readers too.
Size matters too. WCAG 2.2 asks for targets of at least 24 × 24 px (success criterion 2.5.8, level AA), unless they're spaced far enough from their neighbors; level AAA asks for 44 × 44. If the design wants a smaller icon, grow only the clickable area with an invisible ::before.
Saved
Saturday groceries
Coffee, a country loaf, two pounds of oranges and the spare water filter.
.toolbar button {position: relative;width: 28px;height: 28px;}.toolbar button::before {content: "";position: absolute;inset: min(0px, (100% - 24px) / 2);}.visually-hidden {position: absolute;width: 1px;height: 1px;margin: -1px;padding: 0;overflow: hidden;clip-path: inset(50%);white-space: nowrap;border: 0;}
<button class="relative size-7 before:absolute before:inset-[min(0px,(100%-24px)/2)] before:content-['']"><svg aria-hidden="true">…</svg><span class="sr-only">Bold</span></button>
The inset with min() does the math: for a 20px button it comes out at −2px per side and the area reaches 24; at 24 or more it stays at 0 and never shrinks it.
In Tailwind
Tailwind has variants for all of this. System colors go in brackets, and the visually hidden pattern comes ready as sr-only (with not-sr-only to show it again, for example a skip link).
| CSS | Tailwind |
|---|---|
| :focus-visible | focus-visible: |
| outline: 2px solid currentColor | outline-2 outline-current |
| prefers-reduced-motion | motion-reduce: · motion-safe: |
| prefers-contrast: more | contrast-more: |
| forced-colors: active | forced-colors: |
| border-color: CanvasText | border-[CanvasText] |
| [aria-expanded="true"] | aria-expanded: · group-aria-expanded: |
| [aria-current="page"] | aria-[current=page]: |
| visually hidden | sr-only |
<button aria-pressed="true"class="relative size-5 aria-pressed:bg-neutral-200before:absolute before:content-['']before:inset-[min(0px,(100%-24px)/2)]focus-visible:outline-2 focus-visible:outline-offset-2focus-visible:outline-current"><svg aria-hidden="true">…</svg><span class="sr-only">Bold</span></button><div class="motion-safe:animate-[slide-in_250ms_ease-out]contrast-more:border-current">…</div>